Ver código fonte

Una/modules updates for 15.0.0 rc3 (#5930)

* Modules updates.

* Modules updates.

* Modules updates.
Anton 1 dia atrás
pai
commit
2a22e82793
100 arquivos alterados com 25934 adições e 0 exclusões
  1. 51 0
      modules/boonex/accounts/updates/15.0.2_15.0.3/install/config.php
  2. 29 0
      modules/boonex/accounts/updates/15.0.2_15.0.3/install/installer.php
  3. 802 0
      modules/boonex/accounts/updates/15.0.2_15.0.3/source/classes/BxAccntGridAdministration.php
  4. 71 0
      modules/boonex/accounts/updates/15.0.2_15.0.3/source/install/config.php
  5. 51 0
      modules/boonex/acl/updates/15.0.4_15.0.5/install/config.php
  6. 28 0
      modules/boonex/acl/updates/15.0.4_15.0.5/install/installer.php
  7. 266 0
      modules/boonex/acl/updates/15.0.4_15.0.5/source/classes/BxAclGridView.php
  8. 725 0
      modules/boonex/acl/updates/15.0.4_15.0.5/source/classes/BxAclModule.php
  9. 173 0
      modules/boonex/acl/updates/15.0.4_15.0.5/source/classes/BxAclTemplate.php
  10. 70 0
      modules/boonex/acl/updates/15.0.4_15.0.5/source/install/config.php
  11. 18 0
      modules/boonex/acl/updates/15.0.4_15.0.5/source/template/css/view.css
  12. 11 0
      modules/boonex/acl/updates/15.0.4_15.0.5/source/template/level_icon.html
  13. 51 0
      modules/boonex/ads/updates/15.0.5_15.0.6/install/config.php
  14. 28 0
      modules/boonex/ads/updates/15.0.5_15.0.6/install/installer.php
  15. 414 0
      modules/boonex/ads/updates/15.0.5_15.0.6/source/classes/BxAdsTemplate.php
  16. 151 0
      modules/boonex/ads/updates/15.0.5_15.0.6/source/install/config.php
  17. 51 0
      modules/boonex/albums/updates/15.0.4_15.0.5/install/config.php
  18. 28 0
      modules/boonex/albums/updates/15.0.4_15.0.5/install/installer.php
  19. 143 0
      modules/boonex/albums/updates/15.0.4_15.0.5/source/install/config.php
  20. 51 0
      modules/boonex/analytics/updates/15.0.1_15.0.2/install/config.php
  21. 43 0
      modules/boonex/analytics/updates/15.0.1_15.0.2/install/installer.php
  22. 688 0
      modules/boonex/analytics/updates/15.0.1_15.0.2/install/langs/en.xml
  23. 224 0
      modules/boonex/analytics/updates/15.0.1_15.0.2/install/sql/enable.sql
  24. 305 0
      modules/boonex/analytics/updates/15.0.1_15.0.2/install/sql/install.sql
  25. 29 0
      modules/boonex/analytics/updates/15.0.1_15.0.2/source/classes/BxAnalyticsAlertsResponse.php
  26. 1316 0
      modules/boonex/analytics/updates/15.0.1_15.0.2/source/classes/BxAnalyticsCollector.php
  27. 385 0
      modules/boonex/analytics/updates/15.0.1_15.0.2/source/classes/BxAnalyticsConfig.php
  28. 80 0
      modules/boonex/analytics/updates/15.0.1_15.0.2/source/classes/BxAnalyticsCsv.php
  29. 2722 0
      modules/boonex/analytics/updates/15.0.1_15.0.2/source/classes/BxAnalyticsDb.php
  30. 352 0
      modules/boonex/analytics/updates/15.0.1_15.0.2/source/classes/BxAnalyticsErrors.php
  31. 297 0
      modules/boonex/analytics/updates/15.0.1_15.0.2/source/classes/BxAnalyticsFilter.php
  32. 82 0
      modules/boonex/analytics/updates/15.0.1_15.0.2/source/classes/BxAnalyticsGeo.php
  33. 2434 0
      modules/boonex/analytics/updates/15.0.1_15.0.2/source/classes/BxAnalyticsModule.php
  34. 31 0
      modules/boonex/analytics/updates/15.0.1_15.0.2/source/classes/BxAnalyticsPage.php
  35. 3009 0
      modules/boonex/analytics/updates/15.0.1_15.0.2/source/classes/BxAnalyticsReports.php
  36. 436 0
      modules/boonex/analytics/updates/15.0.1_15.0.2/source/classes/BxAnalyticsRequest.php
  37. 1107 0
      modules/boonex/analytics/updates/15.0.1_15.0.2/source/classes/BxAnalyticsRevenue.php
  38. 280 0
      modules/boonex/analytics/updates/15.0.1_15.0.2/source/classes/BxAnalyticsSource.php
  39. 55 0
      modules/boonex/analytics/updates/15.0.1_15.0.2/source/classes/BxAnalyticsStudioMenu.php
  40. 49 0
      modules/boonex/analytics/updates/15.0.1_15.0.2/source/classes/BxAnalyticsStudioMenuItems.php
  41. 128 0
      modules/boonex/analytics/updates/15.0.1_15.0.2/source/classes/BxAnalyticsStudioPage.php
  42. 1991 0
      modules/boonex/analytics/updates/15.0.1_15.0.2/source/classes/BxAnalyticsTemplate.php
  43. 311 0
      modules/boonex/analytics/updates/15.0.1_15.0.2/source/classes/BxAnalyticsUserAgent.php
  44. 176 0
      modules/boonex/analytics/updates/15.0.1_15.0.2/source/classes/BxAnalyticsVitals.php
  45. 77 0
      modules/boonex/analytics/updates/15.0.1_15.0.2/source/install/config.php
  46. 33 0
      modules/boonex/analytics/updates/15.0.1_15.0.2/source/install/installer.php
  47. 725 0
      modules/boonex/analytics/updates/15.0.1_15.0.2/source/install/langs/en.xml
  48. 29 0
      modules/boonex/analytics/updates/15.0.1_15.0.2/source/install/sql/disable.sql
  49. 32 0
      modules/boonex/analytics/updates/15.0.1_15.0.2/source/install/sql/enable-app.sql
  50. 186 0
      modules/boonex/analytics/updates/15.0.1_15.0.2/source/install/sql/enable.sql
  51. 319 0
      modules/boonex/analytics/updates/15.0.1_15.0.2/source/install/sql/install.sql
  52. 9 0
      modules/boonex/analytics/updates/15.0.1_15.0.2/source/install/sql/uninstall.sql
  53. 815 0
      modules/boonex/analytics/updates/15.0.1_15.0.2/source/js/dashboard.js
  54. 362 0
      modules/boonex/analytics/updates/15.0.1_15.0.2/source/js/studio_tools.js
  55. 750 0
      modules/boonex/analytics/updates/15.0.1_15.0.2/source/js/tracker.js
  56. 24 0
      modules/boonex/analytics/updates/15.0.1_15.0.2/source/template/api_params.html
  57. 14 0
      modules/boonex/analytics/updates/15.0.1_15.0.2/source/template/bars.html
  58. 1 0
      modules/boonex/analytics/updates/15.0.1_15.0.2/source/template/block.html
  59. 15 0
      modules/boonex/analytics/updates/15.0.1_15.0.2/source/template/bots.html
  60. 7 0
      modules/boonex/analytics/updates/15.0.1_15.0.2/source/template/code.html
  61. 21 0
      modules/boonex/analytics/updates/15.0.1_15.0.2/source/template/credits.html
  62. 1743 0
      modules/boonex/analytics/updates/15.0.1_15.0.2/source/template/css/dashboard.css
  63. 45 0
      modules/boonex/analytics/updates/15.0.1_15.0.2/source/template/css/studio.css
  64. 43 0
      modules/boonex/analytics/updates/15.0.1_15.0.2/source/template/error.html
  65. 4 0
      modules/boonex/analytics/updates/15.0.1_15.0.2/source/template/error_missing.html
  66. 2 0
      modules/boonex/analytics/updates/15.0.1_15.0.2/source/template/errors.html
  67. 25 0
      modules/boonex/analytics/updates/15.0.1_15.0.2/source/template/event.html
  68. 35 0
      modules/boonex/analytics/updates/15.0.1_15.0.2/source/template/filters.html
  69. 76 0
      modules/boonex/analytics/updates/15.0.1_15.0.2/source/template/funnels.html
  70. 61 0
      modules/boonex/analytics/updates/15.0.1_15.0.2/source/template/goals.html
  71. 1 0
      modules/boonex/analytics/updates/15.0.1_15.0.2/source/template/icon.html
  72. 8 0
      modules/boonex/analytics/updates/15.0.1_15.0.2/source/template/import.html
  73. 59 0
      modules/boonex/analytics/updates/15.0.1_15.0.2/source/template/journeys.html
  74. 30 0
      modules/boonex/analytics/updates/15.0.1_15.0.2/source/template/map.html
  75. 8 0
      modules/boonex/analytics/updates/15.0.1_15.0.2/source/template/member.html
  76. 5 0
      modules/boonex/analytics/updates/15.0.1_15.0.2/source/template/notes.html
  77. 15 0
      modules/boonex/analytics/updates/15.0.1_15.0.2/source/template/overview.html
  78. 38 0
      modules/boonex/analytics/updates/15.0.1_15.0.2/source/template/performance.html
  79. 48 0
      modules/boonex/analytics/updates/15.0.1_15.0.2/source/template/query.html
  80. 1 0
      modules/boonex/analytics/updates/15.0.1_15.0.2/source/template/rating.html
  81. 44 0
      modules/boonex/analytics/updates/15.0.1_15.0.2/source/template/realtime.html
  82. 30 0
      modules/boonex/analytics/updates/15.0.1_15.0.2/source/template/retention.html
  83. 8 0
      modules/boonex/analytics/updates/15.0.1_15.0.2/source/template/revenue.html
  84. 16 0
      modules/boonex/analytics/updates/15.0.1_15.0.2/source/template/sales.html
  85. 32 0
      modules/boonex/analytics/updates/15.0.1_15.0.2/source/template/session.html
  86. 28 0
      modules/boonex/analytics/updates/15.0.1_15.0.2/source/template/setup_check.html
  87. 29 0
      modules/boonex/analytics/updates/15.0.1_15.0.2/source/template/setup_status.html
  88. 1 0
      modules/boonex/analytics/updates/15.0.1_15.0.2/source/template/spark.html
  89. 55 0
      modules/boonex/analytics/updates/15.0.1_15.0.2/source/template/studio_api.html
  90. 46 0
      modules/boonex/analytics/updates/15.0.1_15.0.2/source/template/studio_dashboards.html
  91. 8 0
      modules/boonex/analytics/updates/15.0.1_15.0.2/source/template/studio_menu.html
  92. 10 0
      modules/boonex/analytics/updates/15.0.1_15.0.2/source/template/studio_section.html
  93. 60 0
      modules/boonex/analytics/updates/15.0.1_15.0.2/source/template/studio_setup.html
  94. 36 0
      modules/boonex/analytics/updates/15.0.1_15.0.2/source/template/studio_traffic.html
  95. 21 0
      modules/boonex/analytics/updates/15.0.1_15.0.2/source/template/subscriptions.html
  96. 57 0
      modules/boonex/analytics/updates/15.0.1_15.0.2/source/template/table.html
  97. 5 0
      modules/boonex/analytics/updates/15.0.1_15.0.2/source/template/tabs.html
  98. 16 0
      modules/boonex/analytics/updates/15.0.1_15.0.2/source/template/tiles.html
  99. 23 0
      modules/boonex/analytics/updates/15.0.1_15.0.2/source/template/top.html
  100. 1 0
      modules/boonex/analytics/updates/15.0.1_15.0.2/source/template/tracker.html

+ 51 - 0
modules/boonex/accounts/updates/15.0.2_15.0.3/install/config.php

@@ -0,0 +1,51 @@
+<?php
+/**
+ * Copyright (c) UNA, Inc - https://una.io
+ * MIT License - https://opensource.org/licenses/MIT
+ */
+
+$aConfig = array(
+    /**
+     * Main Section.
+     */
+    'title' => 'Accounts Manager',
+    'version_from' => '15.0.2',
+    'version_to' => '15.0.3',
+    'vendor' => 'UNA INC',
+
+    'compatible_with' => array(
+        '15.0.0-RC3'
+    ),
+
+    /**
+     * 'home_dir' and 'home_uri' - should be unique. Don't use spaces in 'home_uri' and the other special chars.
+     */
+    'home_dir' => 'boonex/accounts/updates/update_15.0.2_15.0.3/',
+    'home_uri' => 'accounts_update_1502_1503',
+
+    'module_dir' => 'boonex/accounts/',
+    'module_uri' => 'accounts',
+
+    'db_prefix' => 'bx_accnt_',
+    'class_prefix' => 'BxAccnt',
+
+    /**
+     * Installation/Uninstallation Section.
+     */
+    'install' => array(
+        'execute_sql' => 0,
+        'update_files' => 1,
+        'update_languages' => 0,
+        'clear_db_cache' => 0,
+    ),
+
+    /**
+     * Category for language keys.
+     */
+    'language_category' => 'Accounts',
+
+    /**
+     * Files Section
+     */
+    'delete_files' => array(),
+);

+ 29 - 0
modules/boonex/accounts/updates/15.0.2_15.0.3/install/installer.php

@@ -0,0 +1,29 @@
+<?php
+/**
+ * Copyright (c) UNA, Inc - https://una.io
+ * MIT License - https://opensource.org/licenses/MIT
+ */
+
+class BxAccntUpdater extends BxDolStudioUpdater
+{
+    function __construct($aConfig)
+    {
+        parent::__construct($aConfig);
+    }
+
+    protected function actionUpdateFiles($bInstall = true)
+    {
+        $iResult = parent::actionUpdateFiles($bInstall);
+
+        if($iResult == BX_DOL_STUDIO_INSTALLER_SUCCESS) {
+            $aModuleConfig = BxDolInstallerUtils::getModuleConfig(BX_DIRECTORY_PATH_MODULES . $this->_aConfig['module_dir'] . 'install/config.php');
+            if(isset($aModuleConfig['note']))
+                $this->oDb->query("UPDATE `sys_modules` SET `note`=:note WHERE `uri`=:uri", [
+                    'note' => $aModuleConfig['note'],
+                    'uri' => $this->_aConfig['module_uri']
+                ]);
+        }
+
+        return $iResult;
+    }
+}

+ 802 - 0
modules/boonex/accounts/updates/15.0.2_15.0.3/source/classes/BxAccntGridAdministration.php

@@ -0,0 +1,802 @@
+<?php defined('BX_DOL') or die('hack attempt');
+/**
+ * Copyright (c) UNA, Inc - https://una.io
+ * MIT License - https://opensource.org/licenses/MIT
+ *
+ * @defgroup    Accounts Accounts
+ * @ingroup     UnaModules
+ * 
+ * @{
+ */
+
+class BxAccntGridAdministration extends BxBaseModProfileGridAdministration
+{
+    protected $_sFilter2Name;
+    protected $_sFilter2Value;
+    protected $_aFilter2Values;
+
+    public function __construct ($aOptions, $oTemplate = false)
+    {
+    	$this->MODULE = 'bx_accounts';
+
+        parent::__construct ($aOptions, $oTemplate);
+
+        $this->_sPaginateType = 'load_more';
+        $this->_bDeleteWithBgJobs = true;
+
+        $CNF = &$this->_oModule->_oConfig->CNF;
+
+        $this->_aFilter1Values = array_merge($this->_aFilter1Values, array(
+            'unconfirmed' => $CNF['T']['filter_item_unconfirmed'],
+            'locked' => $CNF['T']['filter_item_locked'],
+            'without_profile' => $CNF['T']['filter_item_without_profile'],
+        ));
+
+        $this->_sFilter2Name = 'filter2';
+        $this->_aFilter2Values = array(
+            'operators' => $CNF['T']['filter_item_operators']
+        );
+
+        $sFilter2 = bx_get($this->_sFilter2Name);
+        if(!empty($sFilter2)) {
+            $this->_sFilter2Value = bx_process_input($sFilter2);
+            $this->_aQueryAppend[$this->_sFilter2Name] = $this->_sFilter2Value;
+        }
+    }
+
+    protected function _getDataSql($sFilter, $sOrderField, $sOrderDir, $iStart, $iPerPage)
+    {
+        if(strpos($sFilter, $this->_sParamsDivider) !== false)
+            list($this->_sFilter1Value, $this->_sFilter2Value, $sFilter) = explode($this->_sParamsDivider, $sFilter);
+
+        $sSelectClause = $sJoinClause = '';
+        if($this->_oModule->_oConfig->isLastActiveSorting()) {
+            $sSelectClause = ', `ts`.`date` AS `last_active`';
+            $sJoinClause = ' LEFT JOIN `sys_sessions` AS `ts` ON `ts`.`id` = (SELECT `id` FROM `sys_sessions` WHERE `user_id`=`ta`.`id` ORDER BY `date` DESC LIMIT 1)';
+        }
+
+        $this->_aOptions['source'] = bx_replace_markers($this->_aOptions['source'], [
+            'select' => $sSelectClause,
+            'join' => $sJoinClause
+        ]);
+
+    	if(!empty($this->_sFilter1Value))
+            switch($this->_sFilter1Value) {
+                case 'unconfirmed':
+                    $sCnfnType = getParam('sys_account_confirmation_type');
+                    switch($sCnfnType) {
+                        case 'email':
+                            $this->_aOptions['source'] .= " AND `ta`.`email_confirmed`='0'";
+                            break;
+                        case 'phone':
+                            $this->_aOptions['source'] .= " AND `ta`.`phone_confirmed`='0'";
+                            break;
+                        case 'email_and_phone':
+                            $this->_aOptions['source'] .= " AND (`ta`.`email_confirmed`='0' OR `ta`.`phone_confirmed`='0')";
+                            break;
+                        case 'email_or_phone':
+                            $this->_aOptions['source'] .= " AND (`ta`.`email_confirmed`='0' AND `ta`.`phone_confirmed`='0')";
+                            break;
+                    }
+                    break;
+
+                case 'locked':
+                    $this->_aOptions['source'] .= " AND `ta`.`locked` = 1";
+                    break;
+                
+                case 'without_profile':
+                    $this->_aOptions['source'] .= " AND `ta`.`id` NOT IN (SELECT `account_id` FROM `sys_profiles` WHERE `type` <> 'system')";
+                    break;
+
+                default:
+        	        $this->_aOptions['source'] .= $this->_oModule->_oDb->prepareAsString(" AND `tp`.`status`=?", $this->_sFilter1Value);
+            }
+
+        if(!empty($this->_sFilter2Value))
+        	$this->_aOptions['source'] .= $this->_oModule->_oDb->prepareAsString(" AND `ta`.`role` & " . BX_DOL_ROLE_ADMIN ." = " . BX_DOL_ROLE_ADMIN);
+
+        $this->_aOptions['source'] .= " AND `ta`.`email` <> '' ";
+        
+        return parent::_getDataSqlInner($sFilter, $sOrderField, $sOrderDir, $iStart, $iPerPage);
+    }
+    
+    protected function _getFilterControls()
+    {
+        parent::_getFilterControls();
+
+        return  $this->_getFilterSelectOne($this->_sFilter1Name, $this->_sFilter1Value, $this->_aFilter1Values) . $this->_getFilterSelectOne($this->_sFilter2Name, $this->_sFilter2Value, $this->_aFilter2Values) . $this->_getSearchInput();
+    }
+    
+    public function getCode($isDisplayHeader = true)
+    {
+        return $this->_oModule->_oTemplate->getJsCode('main', array(
+        	'aHtmlIds' => $this->_oModule->_oConfig->getHtmlIds()
+        )) . parent::getCode($isDisplayHeader);
+    }
+
+    public function performActionActivate()
+    {
+    	$this->_performActionEnable(true);
+    }
+
+	public function performActionSuspend()
+    {
+    	$this->_performActionEnable(false);
+    }
+
+    public function performActionEditEmail()
+    {
+        $CNF = &$this->_oModule->_oConfig->CNF;
+
+        $aIds = bx_get('ids');
+        if(!$aIds || !is_array($aIds)) {
+            $iId = (int)bx_get('id');
+            if(!$iId)
+                return echoJson(array());
+
+            $aIds = array($iId);
+        }
+
+        $iId = $aIds[0];
+        $oAccount = BxDolAccount::getInstance($iId);
+        if(!$oAccount)
+            return echoJson(array());
+
+        $aAccount = $oAccount->getInfo();
+        $sAction = 'edit_email';
+
+        $oForm = BxDolForm::getObjectInstance($CNF['OBJECT_FORM_ACCOUNT'], $CNF['OBJECT_FORM_ACCOUNT_DISPLAY_SETTINGS_EMAIL']);
+        $oForm->aFormAttrs['action'] = BX_DOL_URL_ROOT . 'grid.php?o=' . $this->_sObject . '&a=' . $sAction . '&id=' . $iId;
+        $oForm->initChecker($aAccount);
+
+        if($oForm->isSubmittedAndValid()) {
+            $sEmail = $oForm->getCleanValue('email');
+            if(strcmp($aAccount['email'], $sEmail) !== 0 && BxDolAccountQuery::getInstance()->getIdByEmail($sEmail))
+                return echoJson(array('msg' => _t('_sys_form_account_input_email_uniq_error_loggedin')));
+
+            if($oForm->update($aAccount['id'])) {
+                bx_alert('account', 'edited', $aAccount['id'], BxDolAccount::getInstance()->id(), array('display' => $CNF['OBJECT_FORM_ACCOUNT_DISPLAY_SETTINGS_EMAIL']));
+
+                $aRes = array('grid' => $this->getCode(false), 'blink' => $iId);
+                
+                $oAccount->doAudit($iId, '_sys_audit_action_account_email_changed', array('old_email' => $oAccount->getEmail(), 'new_email' => $sEmail));
+            }
+            else
+                $aRes = array('msg' => _t('_sys_txt_error_account_update'));
+
+            echoJson($aRes);
+        }
+        else {
+            $sContent = BxTemplStudioFunctions::getInstance()->popupBox('bx-account-edit-email-popup', _t('_bx_accounts_form_display_account_settings_email_popup'), $this->_oModule->_oTemplate->parseHtmlByName('edit_email.html', array(
+                'form_id' => $oForm->id,
+                'form' => $oForm->getCode(true),
+                'object' => $this->_sObject,
+                'action' => $sAction
+            )));
+
+            echoJson(array('popup' => array('html' => $sContent, 'options' => array('closeOnOuterClick' => false))));
+        }
+    }
+	
+    public function performActionSendMessage()
+    {
+        $sAction = 'send_message';
+		
+        $CNF = &$this->_oModule->_oConfig->CNF;
+
+        $aIds = bx_get('ids');
+        if(!$aIds || !is_array($aIds))
+            return echoJson(array());
+		
+        $oForm = BxDolForm::getObjectInstance('bx_accounts_account', 'bx_accounts_send_test');
+        if (!$oForm)
+            return '';
+		
+        $oForm->aFormAttrs['action'] = BX_DOL_URL_ROOT . 'grid.php?' . bx_encode_url_params($_GET, array('_r'));
+        $oForm->initChecker();
+        if($oForm->isSubmittedAndValid()) {
+            $aIdsAffected = array();
+                foreach($aIds as $iId) {
+                    if(sendMail(BxDolAccount::getInstance()->getEmail($iId), $oForm->getCleanValue('message_subject'), $oForm->getCleanValue('message_text'), 0, [], BX_EMAIL_MASS, 'text', false, ['From' => "=?UTF-8?B?" . base64_encode(getParam('site_title')) . "?= <" . getParam('site_email_notify') . ">"], false)){
+                        $aIdsAffected[] = $iId;
+                    }	
+                }
+
+            echoJson(count($aIdsAffected) > 0 ? array('grid' => $this->getCode(false), 'blink' => $aIdsAffected) : array('msg' => _t($CNF['T']['grid_action_err_perform'])));
+        }
+        else {
+            $sContent = BxTemplStudioFunctions::getInstance()->popupBox('bx-account-send-message', _t('_bx_accounts_form_display_account_send_message'), $this->_oModule->_oTemplate->parseHtmlByName('manage_item.html', array(
+                'form_id' => $oForm->id,
+                'form' => $oForm->getCode(true),
+                'object' => $this->_sObject,
+                'action' => $sAction
+            )));
+            echoJson(array('popup' => array('html' => $sContent, 'options' => array('closeOnOuterClick' => false))));
+        }
+    }
+    
+    public function performActionAdd()
+    {
+      
+        $CNF = &$this->_oModule->_oConfig->CNF;
+        $sAction = 'add';
+        $oForm = BxDolForm::getObjectInstance($CNF['OBJECT_FORM_ACCOUNT'], $CNF['OBJECT_FORM_ACCOUNT_DISPLAY_CREATE']);
+        $oForm->aFormAttrs['action'] = BX_DOL_URL_ROOT . 'grid.php?o=' . $this->_sObject . '&a=' . $sAction;
+        $oForm->initChecker();
+
+        if($oForm->isSubmittedAndValid()) {
+           
+            $iAccountId = $oForm->insert();
+            
+            if (!$iAccountId) {
+                $aRes = array('msg' => _t('_sys_txt_error_account_insert'));
+            }
+            else{
+                $aRes = array('grid' => $this->getCode(false), 'blink' => $iAccountId);
+                
+                $oBxTemplAccountForms = new BxTemplAccountForms();
+                
+                $iProfileId = $oBxTemplAccountForms->onAccountCreated($iAccountId, $oForm->isSetPendingApproval(), BX_PROFILE_ACTION_MANUAL, false);
+
+                // perform action
+                BxDolAccount::isAllowedCreate ($iProfileId, true);
+
+                // check
+                $aModulesProfile = bx_srv('system', 'get_modules_by_type', ['profile']);
+
+                if(count($aModulesProfile) == 1)
+                    $sProfileModule = reset($aModulesProfile)['name'];
+                else if(($sDefaultProfileType = getParam('sys_account_default_profile_type')) !== '') 
+                    $sProfileModule = $sDefaultProfileType;
+
+                if(!empty($sProfileModule) && getParam('sys_account_auto_profile_creation') == 'on') {
+                    $aProfileFields = bx_srv($sProfileModule, 'prepare_fields', [[
+                        'author' => $iProfileId,
+                        'name' => BxDolAccount::getInstance($iAccountId)->getDisplayName(),
+                    ]]);
+
+                    $a = bx_srv($sProfileModule, 'entity_add_forcedly', [$iProfileId, $aProfileFields]);
+                    if(isset($a['content']['profile_id']) && (int)$a['content']['profile_id'] > 0)
+                        BxDolAcl::getInstance()->setMembership((int)$a['content']['profile_id'], MEMBERSHIP_ID_STANDARD);
+
+                    echoJson($aRes);
+                }
+            }
+        }
+        else {
+            $sContent = BxTemplStudioFunctions::getInstance()->popupBox('bx-account-edit-email-popup', _t('_bx_accounts_form_display_account_create_popup'), $this->_oModule->_oTemplate->parseHtmlByName('create.html', array(
+                'form_id' => $oForm->id,
+                'form' => $oForm->getCode(true),
+                'object' => $this->_sObject,
+                'action' => $sAction
+            )));
+
+            echoJson(array('popup' => array('html' => $sContent, 'options' => array('closeOnOuterClick' => false))));
+        }
+    }
+
+    public function performActionExport()
+    {
+        $CNF = &$this->_oModule->_oConfig->CNF;
+
+        $aFields = explode(',', getParam($CNF['PARAM_EXPORT_FIELDS']));
+        if(empty($aFields) || !is_array($aFields))
+            return echoJson(array());
+
+        
+        $aTitles = $aConverters = array();
+        foreach($aFields as $sField) {
+            $aTitles[$sField] = _t('_bx_accnt_txt_field_' . $sField);
+            
+            $sConverter = '_exportFieldConverter' . bx_gen_method_name($sField);
+            if(method_exists($this, $sConverter))
+                $aConverters[$sField] = $sConverter;
+        }
+
+        $sMethod = '_exportTo' . bx_gen_method_name(getParam($CNF['PARAM_EXPORT_TO']));
+        if(!method_exists($this, $sMethod))
+            return echoJson(array());
+
+        $aIds = bx_get('ids');
+        if(!$aIds || !is_array($aIds))
+            $aIds = $this->_oModule->_oDb->getAccountIds();
+
+        if(($aFile = $this->$sMethod($aIds, $aFields, $aTitles, $aConverters)) === false)
+            return echoJson(array());
+
+        list($sFilePath, $sFileUrl) = $aFile;
+
+        $oStorage = BxDolStorage::getObjectInstance($CNF['OBJECT_STORAGE_FILES']);
+        if($oStorage && ($iFileId = $oStorage->storeFileFromPath($sFilePath, true, bx_get_logged_profile_id())) !== false) {
+            $sFileUrl = $oStorage->getFileUrlById($iFileId);
+
+            @unlink($sFilePath);
+        }
+
+        return echoJson(array('redirect' => $sFileUrl));
+    }
+    
+    public function performActionConfirm()
+    {
+        $CNF = &$this->_oModule->_oConfig->CNF;
+
+    	$aIds = bx_get('ids');
+        if(!$aIds || !is_array($aIds))
+            return echoJson(array());
+
+        $oAccount = BxDolAccount::getInstance();
+
+        $iAffected = 0;
+        $aIdsAffected = array();
+        foreach($aIds as $iId){
+            if (BxDolAccount::isNeedConfirmEmail()) {
+                if($oAccount->updateEmailConfirmed(true, true, $iId)) {
+                    $aIdsAffected[] = $iId;
+                    $iAffected++;  
+                }
+            }
+
+            if (BxDolAccount::isNeedConfirmPhone()){
+                if($oAccount->updatePhoneConfirmed(true, $iId)) {
+                    $aIdsAffected[] = $iId;
+                    $iAffected++;  
+                }
+            }
+        }
+		echoJson($iAffected ? array('grid' => $this->getCode(false), 'blink' => $aIdsAffected) : array('msg' => _t($CNF['T']['grid_action_err_perform'])));
+    }
+    
+    public function performActionResendCemail()
+    {
+        $CNF = &$this->_oModule->_oConfig->CNF;
+
+        $aIds = bx_get('ids');
+        if(!$aIds || !is_array($aIds))
+            return echoJson(array());
+
+        $oAccount = BxDolAccount::getInstance();
+
+        $iAffected = 0;
+        $aIdsAffected = array();
+        foreach($aIds as $iId)
+            if($oAccount->sendConfirmationEmail($iId)) {
+                $aIdsAffected[] = $iId;
+                $iAffected++;  
+            }
+
+        echoJson($iAffected ? array('grid' => $this->getCode(false), 'blink' => $aIdsAffected) : array('msg' => _t($CNF['T']['grid_action_err_perform'])));
+    }
+
+    public function performActionResetPassword()
+    {
+    	$aIds = bx_get('ids');
+        if(!$aIds || !is_array($aIds))
+            return echoJson(array());
+
+        $iId = $aIds[0];
+        $oAccount = BxDolAccount::getInstance($iId);
+        if(!$oAccount)
+            return echoJson(array());
+
+        $aRes = array();
+        $sPwd = genRndPwd();
+        if (BxDolAccount::getInstance()->updatePassword($sPwd, $iId)){
+            $sPopupId = $this->_oModule->_oConfig->getHtmlIds('password_popup');
+            $sPopupTitle = _t('_bx_accounts_form_display_account_settings_password_popup');
+            $sPopupContent = $this->_oModule->_oTemplate->parseHtmlByName('reset_password.html', array(
+                'js_object' => $this->_oModule->_oConfig->getJsObject('main'),
+                'html_id_text' => $this->_oModule->_oConfig->getHtmlIds('password_text'),
+                'html_id_button' => $this->_oModule->_oConfig->getHtmlIds('password_button'),
+                'password' => $sPwd,
+            ));
+
+            $aRes = array('popup' => BxTemplStudioFunctions::getInstance()->popupBox($sPopupId, $sPopupTitle, $sPopupContent));
+        }
+        else 
+            $aRes = array('msg' => _t('_bx_accnt_grid_action_err_perform'));
+
+        return echoJson($aRes);
+    }
+
+    public function performActionResendRemail()
+    {
+        $CNF = &$this->_oModule->_oConfig->CNF;
+
+        $aIds = bx_get('ids');
+        if(!$aIds || !is_array($aIds))
+            return echoJson(array());
+
+        $oAccount = BxDolAccount::getInstance();
+
+        $iAffected = 0;
+        $aIdsAffected = array();
+        foreach($aIds as $iId)
+            if($oAccount->sendResetPasswordEmail($iId)) {
+                $aIdsAffected[] = $iId;
+                $iAffected++;  
+            }
+
+        echoJson($iAffected ? array('grid' => $this->getCode(false), 'blink' => $aIdsAffected) : array('msg' => _t($CNF['T']['grid_action_err_perform'])));
+    }
+
+    public function performActionUnlockAccount()
+    {
+    	$aIds = bx_get('ids');
+        if(!$aIds || !is_array($aIds)) {
+            echoJson(array());
+            return;
+        }
+
+        $oAccountQuery = BxDolAccountQuery::getInstance();
+        foreach($aIds as $iId) {
+            $oAccount = BxDolAccount::getInstance($iId);
+            if(!$oAccount)
+                continue;
+
+            if ($oAccount->isLocked()){
+                $oAccountQuery->unlockAccount($iId);
+            }
+            
+            $oAccount->doAudit($iId, '_sys_audit_action_account_unlock');
+        }
+
+        return echoJson(array('grid' => $this->getCode(false), 'blink' => $aIds));
+    }
+
+    public function performActionSetOperatorRole()
+    {
+    	$aIds = bx_get('ids');
+        if(!$aIds || !is_array($aIds))
+            return echoJson(array());
+        
+        $oUtils = BxDolStudioRolesUtils::getInstance();
+
+        $aRoles = $oUtils->getRoles();
+        if(empty($aRoles) || !is_array($aRoles))
+            return echoJson(array('msg' => _t('_sys_txt_error_occured')));
+
+        $iId = (int)array_shift($aIds);
+
+        $sPopupName = str_replace('_', '-', $this->MODULE) . '-set-role-popup';
+        $sPopupContent = $this->_oModule->_oTemplate->getPopupSetRole($aRoles, $iId, $oUtils->getRole($iId));
+
+        return echoJson(array('popup' => array(
+            'html' => BxTemplFunctions::getInstance()->transBox($sPopupName, $sPopupContent),
+            'options' => array(
+                'closeOnOuterClick' => true,
+                'removeOnClose' => true
+            )
+        )));
+    }
+
+    public function performActionSetOperatorRoleSubmit()
+    {
+        $aIds = bx_get('ids');
+        if(!$aIds || !is_array($aIds))
+            return echoJson(array());
+
+        $iId = (int)array_shift($aIds);
+        if(!$iId)
+            return echoJson(array('msg' => _t('_sys_txt_error_occured')));
+
+        if(!BxDolStudioRolesUtils::getInstance()->setRole($iId, bx_process_input(bx_get('role'), BX_DATA_INT)))
+            return echoJson(array('msg' => _t('_error occured')));
+
+        echoJson(array('grid' => $this->getCode(false), 'blink' => $iId));
+    }
+
+    public function performActionMakeOperator()
+    {
+    	$this->_performActionChangeRole(3);
+    }
+
+    public function performActionUnmakeOperator()
+    {
+    	$this->_performActionChangeRole(1);
+    }
+
+    protected function _performActionChangeRole($iRole)
+    {
+        $CNF = &$this->_oModule->_oConfig->CNF;
+
+        $aIds = bx_get('ids');
+        if(!$aIds || !is_array($aIds))
+            return echoJson(array());
+
+        $oAccount = BxDolAccount::getInstance();
+        $oUtils = BxDolStudioRolesUtils::getInstance();
+
+        $iAffected = 0;
+        $aIdsAffected = array();
+        foreach($aIds as $iId) {
+            if(!$this->_oModule->_oDb->updateAccount(array('role' => $iRole), array('id' => $iId))) 
+                continue;
+
+            $oUtils->setRole($iId, $iRole == 3 ? BX_DOL_STUDIO_ROLE_OPERATOR : 0);
+
+            $oAccount->doAudit($iId, '_sys_audit_action_account_change_role_to_' . $iRole);
+
+            $aIdsAffected[] = $iId;
+            $iAffected++;
+        }
+
+        echoJson($iAffected ? array('grid' => $this->getCode(false), 'blink' => $aIdsAffected) : array('msg' => _t($CNF['T']['grid_action_err_perform'])));
+    }
+
+    protected function _performActionEnable($isChecked)
+    {
+    	$CNF = &$this->_oModule->_oConfig->CNF;
+
+    	$aIds = bx_get('ids');
+        if(!$aIds || !is_array($aIds)) {
+            echoJson(array());
+            return;
+        }
+        $iAffected = 0;
+        $aIdsAffected = array();
+        foreach($aIds as $iId)
+        	if($this->_enable($iId, $isChecked)) {
+        		$aIdsAffected[] = $iId;
+        		$iAffected++;
+                
+                $oAccount = BxDolAccount::getInstance($iId);
+                $oAccount->doAudit($iId, '_sys_audit_action_account_' . $isChecked ? 'activated' : 'suspended');
+        	}
+
+        echoJson($iAffected ? array('grid' => $this->getCode(false), 'blink' => $aIdsAffected) : array('msg' => _t($CNF['T']['grid_action_err_perform'])));
+    }
+
+    protected function _enable($mixedId, $isChecked)
+    {
+    	$oProfile = BxDolProfile::getInstanceAccountProfile($mixedId);
+    	if(!$oProfile)
+    	    return false;
+
+        $iAction = BX_PROFILE_ACTION_MANUAL;
+        $sMethod = $isChecked ? 'activate' : 'suspend';
+        if(!$oProfile->$sMethod($iAction, 0 ,false))
+            return false;
+
+        sendMailTemplate('t_ChangeStatusAccount' . ucfirst($sMethod), $oProfile->getAccountObject()->id(), $oProfile->id(), array(), BX_EMAIL_SYSTEM);
+        
+        $aProfiles = $oProfile->getAccountObject()->getProfiles();
+        foreach($aProfiles as $aProfile){
+            BxDolProfile::getInstance($aProfile['id'])->$sMethod($iAction, 0 ,false);
+        }
+        
+    	return true;
+    }
+
+    //--- Layout methods ---//
+    protected function _getActionEditEmail($sType, $sKey, $a, $isSmall = false, $isDisabled = false, $aRow = array())
+    {
+        return '';
+    }
+    
+    protected function _getCellEmailConfirmed($mixedValue, $sKey, $aField, $aRow)
+    {
+    	$mixedValue = (int)$mixedValue == 1 ? '_Yes' : '_No';
+        return parent::_getCellDefault(_t($mixedValue), $sKey, $aField, $aRow);
+    }
+
+    protected function _getActionResetPassword($sType, $sKey, $a, $isSmall = false, $isDisabled = false, $aRow = array())
+    {
+        return '';
+    }
+
+    protected function _getActionResendRemail($sType, $sKey, $a, $isSmall = false, $isDisabled = false, $aRow = array())
+    {
+        if($sType == 'single')
+            return '';
+
+        return parent::_getActionDefault ($sType, $sKey, $a, $isSmall, $isDisabled, $aRow);
+    }
+
+    protected function _getActionUnlockAccount($sType, $sKey, $a, $isSmall = false, $isDisabled = false, $aRow = array())
+    {
+        return '';
+    }
+
+    protected function _getCellName($mixedValue, $sKey, $aField, $aRow)
+    {
+        $oAccount = BxDolAccount::getInstance($aRow['id']);
+        if ($oAccount)
+            $s = ($aRow['locked'] == 1 ? $this->_oTemplate->parseIcon("lock col-red1") . ' ' : '') . $oAccount->getDisplayName();
+        return parent::_getCellDefault ($s, $sKey, $aField, $aRow);
+    }
+    
+	protected function _getCellProfiles($mixedValue, $sKey, $aField, $aRow)
+    {
+        $s = $this->_oModule->_oTemplate->getProfilesByAccount($aRow);
+
+        return parent::_getCellDefault ($s, $sKey, $aField, $aRow);
+    }
+
+    protected function _getCellLogged($mixedValue, $sKey, $aField, $aRow)
+    {
+        $mixedValue = !empty($mixedValue) ? bx_time_js($mixedValue) : _t('_sys_not_available');
+
+        return parent::_getCellDefault($mixedValue, $sKey, $aField, $aRow);
+    } 
+
+    protected function _getCellLastActive($mixedValue, $sKey, $aField, $aRow)
+    {
+        if(!isset($aRow['last_active']))
+            $mixedValue = (new BxDolSessionQuery())->getLastActivityAccount($aRow['id']);
+
+        $iTs = max((int)$mixedValue, (int)$aRow['active']);
+        return parent::_getCellDefault($iTs ? bx_time_js($iTs) : _t('_sys_not_available'), $sKey, $aField, $aRow);
+    }
+
+    protected function _getCellAdded($mixedValue, $sKey, $aField, $aRow)
+    {
+        $mixedValue = !empty($mixedValue) ? bx_time_js($mixedValue) : _t('_sys_not_available');
+
+        return parent::_getCellDefault($mixedValue, $sKey, $aField, $aRow);
+    } 
+    
+    protected function _getCellIsConfirmed($mixedValue, $sKey, $aField, $aRow)
+    {
+        $oAccount = BxDolAccount::getInstance($aRow['id']);
+        $s = "";
+        if ($oAccount)
+            $s = $oAccount->getCurrentConfirmationStatusValue();
+        return parent::_getCellDefault(_t('_bx_accnt_grid_confirmation_status_' . $s), $sKey, $aField, $aRow);
+    }
+
+    protected function _getOrderFields($bTranslatable = false)
+    {
+        $aResult = parent::_getOrderFields($bTranslatable);
+
+        if(!$bTranslatable && $this->_oModule->_oConfig->isLastActiveSorting())
+            $aResult[] = 'last_active';
+
+        return $aResult;
+    }
+    
+    protected function _isCheckboxDisabled($aRow)
+    {
+        return false;
+    }
+
+    protected function _getContentInfo($iId)
+    {
+    	return BxDolAccountQuery::getInstance()->getInfoById($iId);
+    }
+
+    protected function _doDelete($iId, $aParams = [])
+    {
+        return ($oAccount = BxDolAccount::getInstance($iId)) !== false && $oAccount->delete(isset($aParams['with_content']) && $aParams['with_content'] === true, true);
+    }
+
+    protected function _addJsCss()
+    {
+        parent::_addJsCss();
+        
+        $this->_oTemplate->addJs(array(
+            'main.css'
+        ));
+
+        $this->_oTemplate->addJs(array(
+        	'jquery.form.min.js',
+            'clipboard.min.js',
+            'main.js'
+        ));
+    }
+
+    protected function _isVisibleGrid($a)
+    {
+        if(isAdmin())
+            return true;
+        
+        return parent::_isVisibleGrid($a);
+    }
+
+    protected function _exportToCsv($aIds, $aFields, $aTitles, $aConverters)
+    {
+        $sFileDiv = ';';
+        $sFileName = 'accounts-' . date('Y.m.d') . '.csv';
+        $sFilePath = BX_DIRECTORY_PATH_TMP . $sFileName;
+        $oFileHandler = fopen($sFilePath, 'w');
+        if(!$oFileHandler)
+            return false;
+
+        fputcsv($oFileHandler, $aTitles, $sFileDiv);
+
+        $aFieldsFlip = array_flip($aFields);
+        $oAccountQuery = BxDolAccountQuery::getInstance();
+
+        foreach($aIds as $iId) {
+            $aAccount = $oAccountQuery->getInfoById($iId);
+            if(empty($aAccount) || !is_array($aAccount))
+                continue;
+            
+            $aConvert = array_intersect_key($aConverters, $aAccount);
+            foreach($aConvert as $sField => $sMethod)
+                $aAccount[$sField] = $this->$sMethod($aAccount[$sField]);
+
+            fputcsv($oFileHandler, array_intersect_key($aAccount, $aFieldsFlip), $sFileDiv);
+        }
+
+        fclose($oFileHandler);
+
+        return array($sFilePath, BX_DOL_URL_ROOT . 'tmp/' . $sFileName);
+    }
+
+    protected function _exportToXml($aIds, $aFields, $aTitles, $aConverters)
+    {
+        $sFileName = 'accounts-' . date('Y.m.d') . '.xml';
+        $sFilePath = BX_DIRECTORY_PATH_TMP . $sFileName;
+        $oFileHandler = fopen($sFilePath, 'w');
+        if(!$oFileHandler)
+            return false;
+
+        $aFieldsFlip = array_flip($aFields);
+        $oAccountQuery = BxDolAccountQuery::getInstance();
+
+        $oXML = xmlwriter_open_memory();
+        xmlwriter_set_indent($oXML, 1);
+        xmlwriter_set_indent_string($oXML, '  ');
+        
+        xmlwriter_start_document($oXML, '1.0', 'UTF-8');
+        xmlwriter_start_element($oXML, 'accounts');
+
+        foreach($aIds as $iId) {
+            $aAccount = $oAccountQuery->getInfoById($iId);
+            if(empty($aAccount) || !is_array($aAccount))
+                continue;
+
+            $aConvert = array_intersect_key($aConverters, $aAccount);
+            foreach($aConvert as $sField => $sMethod)
+                $aAccount[$sField] = $this->$sMethod($aAccount[$sField]);
+
+            xmlwriter_start_element($oXML, 'account');
+            $aAccountFields = array_intersect_key($aAccount, $aFieldsFlip);
+            foreach($aAccountFields as $sAccountField => $sAccountValue) {
+                xmlwriter_start_element($oXML, $sAccountField);
+                xmlwriter_write_cdata($oXML, $sAccountValue);
+                xmlwriter_end_element($oXML);
+            }
+            xmlwriter_end_element($oXML);
+        }
+
+        xmlwriter_end_document($oXML);
+
+        fwrite($oFileHandler, xmlwriter_output_memory($oXML));
+        fclose($oFileHandler);
+
+        return array($sFilePath, BX_DOL_URL_ROOT . 'tmp/' . $sFileName);
+    }
+
+    protected function _exportFieldConverterAdded($mixedValue)
+    {
+        return $this->__exportFieldConverterDate($mixedValue);
+    }
+
+    protected function _exportFieldConverterChanged($mixedValue)
+    {
+        return $this->__exportFieldConverterDate($mixedValue);
+    }
+
+    protected function _exportFieldConverterLogged($mixedValue)
+    {
+        return $this->__exportFieldConverterDateTime($mixedValue);
+    }
+
+    protected function _exportFieldConverterLangId($mixedValue)
+    {
+        return $mixedValue ? BxDolLanguages::getInstance()->getLangName($mixedValue) : '';
+    }
+
+    protected function __exportFieldConverterDate($mixedValue)
+    {
+        return date('Y.m.d', $mixedValue);
+    }
+
+    protected function __exportFieldConverterDateTime($mixedValue)
+    {
+        return date('Y.m.d H:i', $mixedValue);
+    }
+}
+
+/** @} */

+ 71 - 0
modules/boonex/accounts/updates/15.0.2_15.0.3/source/install/config.php

@@ -0,0 +1,71 @@
+<?php
+/**
+ * Copyright (c) UNA, Inc - https://una.io
+ * MIT License - https://opensource.org/licenses/MIT
+ *
+ * @defgroup    Accounts Accounts
+ * @ingroup     UnaModules
+ *
+ * @{
+ */
+
+$aConfig = array(
+    /**
+     * Main Section.
+     */
+    'type' => BX_DOL_MODULE_TYPE_MODULE,
+    'name' => 'bx_accounts',
+    'title' => 'Accounts Manager',
+    'note' => 'Accounts, logins, user accounts',
+    'version' => '15.0.3',
+    'vendor' => 'UNA INC',
+    'help_url' => 'http://feed.una.io/?section={module_name}',
+
+    'compatible_with' => array(
+        '15.0.0-RC3'
+    ),
+
+    /**
+     * 'home_dir' and 'home_uri' - should be unique. Don't use spaces in 'home_uri' and the other special chars.
+     */
+    'home_dir' => 'boonex/accounts/',
+    'home_uri' => 'accounts',
+
+    'db_prefix' => 'bx_accnt_',
+    'class_prefix' => 'BxAccnt',
+
+    /**
+     * Category for language keys.
+     */
+    'language_category' => 'Accounts',
+
+    /**
+     * Installation/Uninstallation Section.
+     */
+    'install' => array(
+        'execute_sql' => 1,
+        'update_languages' => 1,
+        'clear_db_cache' => 1,
+    ),
+    'uninstall' => array (
+        'execute_sql' => 1,
+        'update_languages' => 1,
+        'clear_db_cache' => 1,
+    ),
+    'enable' => array(
+        'execute_sql' => 1,
+        'clear_db_cache' => 1,
+    ),
+    'disable' => array (
+        'execute_sql' => 1,
+        'clear_db_cache' => 1,
+    ),
+
+    /**
+     * Dependencies Section
+     */
+    'dependencies' => array(),
+
+);
+
+/** @} */

+ 51 - 0
modules/boonex/acl/updates/15.0.4_15.0.5/install/config.php

@@ -0,0 +1,51 @@
+<?php
+/**
+ * Copyright (c) UNA, Inc - https://una.io
+ * MIT License - https://opensource.org/licenses/MIT
+ */
+
+$aConfig = array(
+    /**
+     * Main Section.
+     */
+    'title' => 'Paid Levels',
+    'version_from' => '15.0.4',
+    'version_to' => '15.0.5',
+    'vendor' => 'UNA INC',
+
+    'compatible_with' => array(
+        '15.0.0-RC3'
+    ),
+
+    /**
+     * 'home_dir' and 'home_uri' - should be unique. Don't use spaces in 'home_uri' and the other special chars.
+     */
+    'home_dir' => 'boonex/acl/updates/update_15.0.4_15.0.5/',
+    'home_uri' => 'acl_update_1504_1505',
+
+    'module_dir' => 'boonex/acl/',
+    'module_uri' => 'acl',
+
+    'db_prefix' => 'bx_acl_',
+    'class_prefix' => 'BxAcl',
+
+    /**
+     * Installation/Uninstallation Section.
+     */
+    'install' => array(
+        'execute_sql' => 0,
+        'update_files' => 1,
+        'update_languages' => 0,
+        'clear_db_cache' => 0,
+    ),
+
+    /**
+     * Category for language keys.
+     */
+    'language_category' => 'Paid Levels',
+
+    /**
+     * Files Section
+     */
+    'delete_files' => array(),
+);

+ 28 - 0
modules/boonex/acl/updates/15.0.4_15.0.5/install/installer.php

@@ -0,0 +1,28 @@
+<?php
+/**
+ * Copyright (c) UNA, Inc - https://una.io
+ * MIT License - https://opensource.org/licenses/MIT
+ */
+
+class BxAclUpdater extends BxDolStudioUpdater
+{
+    function __construct($aConfig)
+    {
+        parent::__construct($aConfig);
+    }
+    protected function actionUpdateFiles($bInstall = true)
+    {
+        $iResult = parent::actionUpdateFiles($bInstall);
+
+        if($iResult == BX_DOL_STUDIO_INSTALLER_SUCCESS) {
+            $aModuleConfig = BxDolInstallerUtils::getModuleConfig(BX_DIRECTORY_PATH_MODULES . $this->_aConfig['module_dir'] . 'install/config.php');
+            if(isset($aModuleConfig['note']))
+                $this->oDb->query("UPDATE `sys_modules` SET `note`=:note WHERE `uri`=:uri", [
+                    'note' => $aModuleConfig['note'],
+                    'uri' => $this->_aConfig['module_uri']
+                ]);
+        }
+
+        return $iResult;
+    }
+}

+ 266 - 0
modules/boonex/acl/updates/15.0.4_15.0.5/source/classes/BxAclGridView.php

@@ -0,0 +1,266 @@
+<?php defined('BX_DOL') or die('hack attempt');
+/**
+ * Copyright (c) UNA, Inc - https://una.io
+ * MIT License - https://opensource.org/licenses/MIT
+ *
+ * @defgroup    PaidLevels Paid Levels
+ * @ingroup     UnaModules
+ * 
+ * @{
+ */
+
+require_once('BxAclGridLevels.php');
+
+class BxAclGridView extends BxAclGridLevels
+{
+    /**
+     * Array with check_sum => JS_code pairs of all JS codes 
+     * which should be added to the page.
+     */
+    protected $_aJsCodes;
+
+    protected $_iLogged;
+    protected $_iOwner;
+    protected $_oPayment;
+    protected $_bTypeSingle;
+    protected $_bTypeRecurring;
+
+    public function __construct ($aOptions, $oTemplate = false)
+    {
+        parent::__construct ($aOptions, $oTemplate);
+
+        $this->_aJsCodes = array();
+
+        $this->_iLogged = bx_get_logged_profile_id();
+        $this->_iOwner = $this->_oModule->_oConfig->getOwner();
+        $this->_oPayment = BxDolPayments::getInstance();
+        $this->_bTypeSingle = $this->_oPayment->isAcceptingPayments($this->_iOwner, BX_PAYMENT_TYPE_SINGLE);
+        $this->_bTypeRecurring = !$this->_oPayment->isCreditsOnly() && $this->_oPayment->isAcceptingPayments($this->_iOwner, BX_PAYMENT_TYPE_RECURRING);
+    }
+
+    public function performActionChoose()
+    {
+        if(!$this->_iLogged)
+            return $this->_bIsApi ? [] : echoJson([]);
+
+        $aIds = $this->_getIds();
+        if($aIds === false)
+            return $this->_bIsApi ? [] : echoJson([]);
+
+        $aItem = $this->_oModule->_oDb->getPrices(['type' => 'by_id', 'value' => $aIds[0]]);
+        if(!is_array($aItem) || empty($aItem) || (float)$aItem['price'] != 0)
+            return $this->_bIsApi ? [] : echoJson([]);
+
+        $oAcl = BxDolAcl::getInstance();
+        $iUserId = bx_get_logged_profile_id();
+        $aMembershipInfo = $oAcl->getMemberMembershipInfo($iUserId);
+
+        $aResult = [];
+        if($oAcl->setMembership($iUserId, $aItem['level_id'], ['period' => $aItem['period'], 'period_unit' => $aItem['period_unit']], true)) {
+            $this->_oModule->cancelSubscription($iUserId, $aMembershipInfo);
+
+            $aResult = ['grid' => $this->getCode(false), 'blink' => $aItem['id'], 'msg' => _t('_bx_acl_msg_performed')];
+        }
+        else
+            $aResult = ['msg' => _t('_bx_acl_err_cannot_perform')];
+
+        return $this->_bIsApi ? [] : echoJson($aResult);
+    }
+
+    public function performActionBuy()
+    {
+        if(!$this->_iLogged)
+            return $this->_bIsApi ? [] : echoJson([]);
+
+        if(!$this->_bIsApi)
+            return echoJson([]);
+        
+        $aIds = $this->_getIds();
+        if($aIds === false || !is_array($aIds))
+            return [];
+
+        if(($sDescriptor = array_shift($aIds))) {
+            $aDescriptor = $this->_oPayment->getActiveObject()->_oConfig->descriptorS2A($sDescriptor);
+            if($aDescriptor && is_array($aDescriptor) && (int)$aDescriptor[0] == $this->_iOwner) {
+                $aResult = $this->_oPayment->addToCart($this->_iOwner, $this->MODULE, (int)$aDescriptor[2], 1, true);
+                if(isset($aResult['code']) && (int)$aResult['code'] != 0)
+                    return [bx_api_get_msg($aResult['message'])];
+            }
+        }
+
+        return [];
+    }
+
+    public function performActionSubscribe()
+    {
+        if(!$this->_iLogged)
+            return $this->_bIsApi ? [] : echoJson([]);
+
+        if(!$this->_bIsApi)
+            return echoJson([]);
+        
+        $aIds = $this->_getIds();
+        if($aIds === false)
+            return [];
+
+        //TODO: Payment Provider selector should be realized.
+        $aResult = $this->_oPayment->subscribeWithAddons($this->_iOwner, 'stripe_v3', $this->MODULE, array_shift($aIds), 1, true);
+        if(isset($aResult['code']) && (int)$aResult['code'] != 0)
+            return [bx_api_get_msg($aResult['message'])];
+
+        return [];
+    }
+
+    public function getCode($isDisplayHeader = true)
+    {
+    	return parent::getCode($isDisplayHeader) . $this->getJsCode();
+    }
+
+    public function getJsCode()
+    {
+        if(empty($this->_aJsCodes) || !is_array($this->_aJsCodes))
+            return '';
+
+        return implode('', $this->_aJsCodes);
+    }
+
+    public function decodeDataAPI($aData)
+    {
+        $aResults = parent::decodeDataAPI($aData);
+        foreach($aResults as $iKey => $aResult)
+            if(($iMemLevelId = $aData[$iKey]['level_id'] ?? false) !== false)
+                 $aResults[$iKey]['level_id'] = $iMemLevelId;
+
+        return $aResults;
+    }
+    protected function _getCellLevelIcon($mixedValue, $sKey, $aField, $aRow)
+    {
+        $mixedValue = $this->_oModule->_oTemplate->displayLevelIcon($mixedValue);
+    	return parent::_getCellDefault($mixedValue, $sKey, $aField, $aRow);
+    }
+
+    protected function _getCellLevelName($mixedValue, $sKey, $aField, $aRow)
+    {
+        if(!$this->_bIsApi)
+            $mixedValue = $this->_oModule->_oTemplate->parseLink('javascript:void(0)', $mixedValue, [
+                'onclick' => $this->_oModule->_oConfig->getJsObject('main') . '.viewActions(this, ' . $aRow['level_id'] . ')'
+            ]);
+
+    	return parent::_getCellDefault($mixedValue, $sKey, $aField, $aRow);
+    }
+
+    protected function _getActionChoose ($sType, $sKey, $a, $isSmall = false, $isDisabled = false, $aRow = array())
+    {
+        if((float)$aRow['price'] != 0 || BxDolAcl::getInstance()->isMemberLevelInSet(array($aRow['level_id'])))
+            return $this->_bIsApi ? [] : '';
+
+        if($this->_bIsApi)
+            return array_merge($a, ['name' => $sKey, 'type' => 'callback', 'on_callback' => 'hide']);
+
+        if(!$this->_iLogged)
+            $a['attr'] = array_merge($a['attr'], [
+                'onclick' => "window.open('" . $this->_getLoginUrl() . "','_self');"
+            ]);
+
+        return  parent::_getActionDefault($sType, $sKey, $a, false, $isDisabled, $aRow);
+    }
+
+    protected function _getActionBuy ($sType, $sKey, $a, $isSmall = false, $isDisabled = false, $aRow = array())
+    {
+        $CNF = &$this->_oModule->_oConfig->CNF;
+
+        if((float)$aRow['price'] == 0 || !$this->_bTypeSingle || ($this->_bTypeRecurring && getParam($CNF['PARAM_RECURRING_PRIORITIZE']) == 'on' && !$this->_isLifetime($aRow)))
+            return $this->_bIsApi ? [] : '';
+
+        if($this->_bIsApi)
+            return array_merge($a, [
+                'name' => $sKey, 
+                'type' => 'callback', 
+                'on_callback' => 'redirect',
+                'redirect_url' => bx_api_get_relative_url($this->_oPayment->getCartUrl($this->_iOwner)),
+                'payment_type' => BX_PAYMENT_TYPE_SINGLE,
+                'seller_id' => $this->_iOwner,
+                'items' => [$this->_oPayment->getCartItemDescriptor($this->_iOwner, $this->_oModule->_oConfig->getId(), $aRow['id'], 1)],
+            ]);
+        
+        if($this->_iLogged) {
+            $aJs = $this->_oPayment->getAddToCartJs($this->_iOwner, $this->MODULE, $aRow['id'], 1, true);
+            if(!empty($aJs) && is_array($aJs)) {
+                list($sJsCode, $sJsMethod) = $aJs;
+
+                $sJsCodeCheckSum = md5($sJsCode);
+                if(!isset($this->_aJsCodes[$sJsCodeCheckSum]))
+                    $this->_aJsCodes[$sJsCodeCheckSum] = $sJsCode;
+
+                $a['attr'] = array(
+                    'title' => bx_html_attribute(_t('_bx_acl_grid_action_buy_title')),
+                    'onclick' => $sJsMethod
+                );
+            }
+        }
+        else
+            $a['attr'] = array_merge($a['attr'], [
+                'onclick' => "window.open('" . $this->_getLoginUrl() . "','_self');"
+            ]);
+
+        return  parent::_getActionDefault($sType, $sKey, $a, false, $isDisabled, $aRow);
+    }
+
+    protected function _getActionSubscribe ($sType, $sKey, $a, $isSmall = false, $isDisabled = false, $aRow = array())
+    {
+        if((float)$aRow['price'] == 0 || !$this->_bTypeRecurring || ($this->_bTypeSingle && $this->_isLifetime($aRow)))
+            return $this->_bIsApi ? [] : '';
+
+        if($this->_bIsApi)
+            return array_merge($a, [
+                'name' => $sKey, 
+                'type' => 'object', 
+                'object_name' => 'stripe_v3',
+                'payment_type' => BX_PAYMENT_TYPE_RECURRING,
+                'seller_id' => $this->_iOwner,
+                'items' => [$this->_oPayment->getCartItemDescriptor($this->_iOwner, $this->_oModule->_oConfig->getId(), $aRow['id'], 1)],
+            ]);
+
+        if($this->_iLogged) {
+            $aJs = $this->_oPayment->getSubscribeJs($this->_iOwner, '', $this->MODULE, $aRow['id'], 1);
+            if(!empty($aJs) && is_array($aJs)) {
+                list($sJsCode, $sJsMethod) = $aJs;
+
+                $sJsCodeCheckSum = md5($sJsCode);
+                if(!isset($this->_aJsCodes[$sJsCodeCheckSum]))
+                    $this->_aJsCodes[$sJsCodeCheckSum] = $sJsCode;
+
+                $a['attr'] = array(
+                    'title' => bx_html_attribute(_t('_bx_acl_grid_action_subscribe_title')),
+                    'onclick' => $sJsMethod
+                );
+            }
+        }
+        else
+            $a['attr'] = array_merge($a['attr'], [
+                'onclick' => "window.open('" . $this->_getLoginUrl() . "','_self');"
+            ]);
+
+    	return parent::_getActionDefault($sType, $sKey, $a, false, $isDisabled, $aRow);
+    }
+
+    protected function _getDataSql($sFilter, $sOrderField, $sOrderDir, $iStart, $iPerPage)
+    {
+        if($this->_iLogged && ($oLogged = BxDolProfile::getInstance($this->_iLogged)) !== false)
+            $this->_aOptions['source'] .= "AND `tl`.`UnavailableTo` NOT LIKE " . $this->_oModule->_oDb->escape('%|' . $oLogged->getModule() . '|%') . " ";
+
+        return parent::_getDataSql($sFilter, $sOrderField, $sOrderDir, $iStart, $iPerPage);;
+    }
+
+    protected function _isLifetime($aRow)
+    {
+        return empty($aRow['period']) && empty($aRow['period_unit']);
+    }
+
+    protected function _getLoginUrl()
+    {
+        return bx_absolute_url(BxDolPermalinks::getInstance()->permalink('page.php?i=login'));
+    }
+}
+
+/** @} */

+ 725 - 0
modules/boonex/acl/updates/15.0.4_15.0.5/source/classes/BxAclModule.php

@@ -0,0 +1,725 @@
+<?php defined('BX_DOL') or die('hack attempt');
+/**
+ * Copyright (c) UNA, Inc - https://una.io
+ * MIT License - https://opensource.org/licenses/MIT
+ *
+ * @defgroup    PaidLevels Paid Levels
+ * @ingroup     UnaModules
+ *
+ * @{
+ */
+
+bx_import('BxDolAcl');
+
+define('BX_ACL_LICENSE_TYPE_SINGLE', 'single'); //--- one-time payment license
+define('BX_ACL_LICENSE_TYPE_RECURRING', 'recurring'); //--- recurring payment license
+
+class BxAclModule extends BxBaseModGeneralModule
+{
+    /**
+     * Constructor
+     */
+    function __construct($aModule)
+    {
+        parent::__construct($aModule);
+
+        $this->_oConfig->init($this->_oDb);
+    }
+
+    /**
+     * ACTION METHODS
+     */
+    public function actionCheckName()
+    {
+        $CNF = &$this->_oConfig->CNF;
+
+    	$sName = bx_process_input(bx_get('name'));
+    	if(empty($sName))
+            return echoJson(array());
+
+        $sResult = '';
+
+        $iId = (int)bx_get('id');
+        if(!empty($iId)) {
+            $aPrice = $this->_oDb->getPrices(array('type' => 'by_id', 'value' => $iId)); 
+            if(strcmp($sName, $aPrice[$CNF['FIELD_NAME']]) == 0) 
+                $sResult = $sName;
+        }
+
+    	echoJson(array(
+            'name' => !empty($sResult) ? $sResult : $this->_oConfig->getPriceName($sName)
+    	));
+    }
+
+    public function actionGetActionsInPopup($iLevelId)
+    {
+        return echoJson([
+            'code' => 0,
+            'popup' => [
+                'html' => $this->_oTemplate->displayActionsPopup($iLevelId), 
+                'options' => ['closeOnOuterClick' => true, 'removeOnClose' => true]
+            ],
+        ]);
+    }
+
+    public function actionGetActions($iLevelId)
+    {
+        if(($iGetStart = bx_get('start')) !== false)
+            $iStart = (int)$iGetStart;
+        if(($iGetPerPage = bx_get('per_page')) !== false)
+            $iLimit = (int)$iGetPerPage;
+        
+        return echoJson([
+            'code' => 0,
+            'level_id' => $iLevelId,
+            'content' => $this->_oTemplate->displayActions($iLevelId, $iStart, $iLimit),
+            'eval' => $this->_oConfig->getJsObject('main').'.onGetActions(oData)'
+        ]);
+    }
+
+    /**
+     * SERVICE METHODS
+     */
+
+    public function serviceGetSafeServices()
+    {
+        return array (
+            'GetViewUrl' => '',
+            'GetBlockView' => '',
+            'GetMembershipActions' => '',
+        );
+    }
+
+	/**
+     * @page service Service Calls
+     * @section bx_acl Paid Levels
+     * @subsection bx_acl-other Other
+     * @subsubsection bx_acl-get_view_url get_view_url
+     * 
+     * @code bx_srv('bx_acl', 'get_view_url', [...]); @endcode
+     * 
+     * Get page URL with membership levels list.
+     *
+     * @return string with page URL.
+     * 
+     * @see BxAclModule::serviceGetViewUrl
+     */
+    /** 
+     * @ref bx_acl-get_view_url "get_view_url"
+     */
+	public function serviceGetViewUrl()
+    {
+        $CNF = &$this->_oConfig->CNF;
+
+    	return  bx_absolute_url(BxDolPermalinks::getInstance()->permalink($CNF['URL_VIEW']));
+    }
+
+    /**
+     * @page service Service Calls
+     * @section bx_acl Paid Levels
+     * @subsection bx_acl-page_blocks Page Blocks
+     * @subsubsection bx_acl-get_block_view get_block_view
+     * 
+     * @code bx_srv('bx_acl', 'get_block_view', [...]); @endcode
+     * 
+     * Get page block with a list of available ACL levels to purchase or get for Free. List is represented as table.
+     *
+     * @return HTML string with a list of ACL levels to display on the site, all necessary CSS and JS files are automatically added to the HEAD section of the site HTML. On error empty string is returned.
+     * 
+     * @see BxAclModule::serviceGetBlockView
+     */
+    /** 
+     * @ref bx_acl-get_block_view "get_block_view"
+     */
+    public function serviceGetBlockView()
+    {
+        if(!$this->_oConfig->isShowToUnauthenticated())
+            bx_require_authentication(false, false, $this->serviceGetViewUrl());
+
+        $sGrid = $this->_oConfig->getGridObject('view');
+        $oGrid = BxDolGrid::getObjectInstance($sGrid);
+        if(!$oGrid)
+            return $this->_bIsApi ? [] : '';
+
+        if($this->_bIsApi)
+            return [
+                bx_api_get_block('pricing', array_merge_recursive($oGrid->getCodeAPI(true), ['settings' => ['unit' => 'cards']]))
+            ];
+
+        $this->_oTemplate->addCss(['view.css']);
+        $this->_oTemplate->addJs(['main.js']);
+        return [
+            'content' => $this->_oTemplate->getJsCode('main') . $oGrid->getCode()
+        ];
+    }
+
+    public function serviceGetBlockActions($iLevelId, $iStart = 0, $iLimit = 0)
+    {
+        if(($iGetStart = bx_get('start')) !== false)
+            $iStart = (int)$iGetStart;
+        if(($iGetPerPage = bx_get('per_page')) !== false)
+            $iLimit = (int)$iGetPerPage;
+
+        $mixedResult = $this->_oTemplate->displayActionsBlock($iLevelId, $iStart, $iLimit);
+
+        return !$this->_bIsApi ? $mixedResult : [
+            bx_api_get_block('acl_level_actions', $mixedResult)
+        ];
+    }
+
+    /**
+     * @page service Service Calls
+     * @section bx_acl Paid Levels
+     * @subsection bx_acl-page_blocks Page Blocks
+     * @subsubsection bx_acl-get_membership_actions get_membership_actions
+     * 
+     * @code bx_srv('bx_acl', 'get_membership_actions', [...]); @endcode
+     * 
+     * Get page block with a list of current membership level's actions.
+     *
+     * @return HTML string with a list of actions to display on the site, all necessary CSS and JS files are automatically added to the HEAD section of the site HTML. On error empty string is returned.
+     * 
+     * @see BxAclModule::serviceGetMembershipActions
+     */
+    /** 
+     * @ref bx_acl-get_membership_actions "get_membership_actions"
+     */
+    public function serviceGetMembershipActions($iProfileId)
+    {
+        if($iProfileId != $this->getUserId())
+            return '';
+
+        return $this->_oTemplate->displayMembershipActions($iProfileId);
+    }
+
+    /**
+     * @page service Service Calls
+     * @section bx_acl Paid Levels
+     * @subsection bx_acl-payments Payments
+     * @subsubsection bx_acl-get_payment_data get_payment_data
+     * 
+     * @code bx_srv('bx_acl', 'get_payment_data', [...]); @endcode
+     * 
+     * Get an array with module's description. Is needed for payments processing module.
+     * 
+     * @return an array with module's description.
+     * 
+     * @see BxAclModule::serviceGetPaymentData
+     */
+    /** 
+     * @ref bx_acl-get_payment_data "get_payment_data"
+     */
+	public function serviceGetPaymentData()
+    {
+        return $this->_aModule;
+    }
+
+    /**
+     * @page service Service Calls
+     * @section bx_acl Paid Levels
+     * @subsection bx_acl-payments Payments
+     * @subsubsection bx_acl-get_cart_item get_cart_item
+     * 
+     * @code bx_srv('bx_acl', 'get_cart_item', [...]); @endcode
+     * 
+     * Get an array with level's description. Is used in Shopping Cart in payments processing module.
+     * 
+     * @param $mixedItemId level's ID or Unique Name.
+     * @return an array with level's description. Empty array is returned if something is wrong.
+     * 
+     * @see BxAclModule::serviceGetCartItem
+     */
+    /** 
+     * @ref bx_acl-get_cart_item "get_cart_item"
+     */
+    public function serviceGetCartItem($mixedItemId)
+    {
+        $CNF = &$this->_oConfig->CNF;
+
+        if(!$mixedItemId)
+            return array();
+
+        if(is_numeric($mixedItemId))
+            $aItem = $this->_oDb->getPrices(array('type' => 'by_id_full', 'value' => (int)$mixedItemId));
+        else 
+            $aItem = $this->_oDb->getPrices(array('type' => 'by_name_full', 'value' => $mixedItemId));
+
+        if(empty($aItem) || !is_array($aItem))
+            return array();
+
+        $sTitle = '';
+        if((int)$aItem['period'] == 0)
+            $sTitle = _t('_bx_acl_txt_cart_item_title_lifetime', _t($aItem['level_name']));
+        else
+            $sTitle = _t('_bx_acl_txt_cart_item_title', _t($aItem['level_name']), $aItem['period'], $aItem['period_unit']);
+
+        $sDescription = _t($aItem['level_description']);
+        if(empty($sDescription))
+            $sDescription = _t('_bx_acl_txt_cart_item_description', getParam('site_title'));
+
+        return array (
+            'id' => $aItem['id'],
+            'author_id' => $this->_oConfig->getOwner(),
+            'name' => $aItem['name'],
+            'title' => $sTitle,
+            'description' => $sDescription,
+            'url' => bx_absolute_url(BxDolPermalinks::getInstance()->permalink($CNF['URL_VIEW'])),
+            'price_single' => $aItem['price'],
+            'price_recurring' => $aItem['price'],
+            'period_recurring' => $aItem['period'],
+            'period_unit_recurring' => $aItem['period_unit'],
+            'trial_recurring' => $aItem['trial'],
+            'added' => $aItem['added'],
+        );
+    }
+
+    /**
+     * @page service Service Calls
+     * @section bx_acl Paid Levels
+     * @subsection bx_acl-payments Payments
+     * @subsubsection bx_acl-get_cart_items get_cart_items
+     * 
+     * @code bx_srv('bx_acl', 'get_cart_items', [...]); @endcode
+     * 
+     * Get an array with levels' descriptions by seller. Is used in Manual Order Processing in payments processing module.
+     * 
+     * @param $iSellerId seller ID.
+     * @return an array with levels' descriptions. Empty array is returned if something is wrong or seller doesn't have any paid level.
+     * 
+     * @see BxAclModule::serviceGetCartItems
+     */
+    /** 
+     * @ref bx_acl-get_cart_items "get_cart_items"
+     */
+    public function serviceGetCartItems($iSellerId)
+    {
+    	$CNF = &$this->_oConfig->CNF;
+
+    	$iSellerIdSetting = $this->_oConfig->getOwner();
+    	if(empty($iSellerId) || ($iSellerId != $iSellerIdSetting && !isAdmin()))
+    	    return array();
+
+        $aItems = $this->_oDb->getPrices(array('type' => 'all_full'));
+        $sUrl = bx_absolute_url(BxDolPermalinks::getInstance()->permalink($CNF['URL_VIEW']));
+
+        $aResult = array();
+        foreach($aItems as $aItem) {
+            $sTitle = '';
+            if((int)$aItem['period'] == 0)
+                $sTitle = _t('_bx_acl_txt_cart_item_title_lifetime', _t($aItem['level_name']));
+            else
+                $sTitle = _t('_bx_acl_txt_cart_item_title', _t($aItem['level_name']), $aItem['period'], $aItem['period_unit']);
+
+            $sDescription = _t($aItem['level_description']);
+            if(empty($sDescription))
+                $sDescription = _t('_bx_acl_txt_cart_item_description', getParam('site_title'));
+            
+            $aResult[] = array(
+                'id' => $aItem['id'],
+                'author_id' => $iSellerIdSetting,
+                'name' => $aItem['name'],
+                'title' => $sTitle,
+                'description' => $sDescription,
+                'url' => $sUrl,
+                'price_single' => $aItem['price'],
+                'price_recurring' => $aItem['price'],
+                'period_recurring' => $aItem['period'],
+                'period_unit_recurring' => $aItem['period_unit'],
+                'trial_recurring' => $aItem['trial']
+           );
+        }
+
+        return $aResult;
+    }
+
+    /**
+     * @page service Service Calls
+     * @section bx_acl Paid Levels
+     * @subsection bx_acl-payments Payments
+     * @subsubsection bx_acl-register_cart_item register_cart_item
+     * 
+     * @code bx_srv('bx_acl', 'register_cart_item', [...]); @endcode
+     * 
+     * Register a processed single time payment inside the Paid Levels module. Is called with payment processing module after the payment was registered there.
+     * 
+     * @param $iClientId client ID.
+     * @param $iSellerId seller ID
+     * @param $iItemId item ID.
+     * @param $iItemCount the number of purchased items.
+     * @param $sOrder order number received from payment provider (PayPal, Stripe, etc)
+     * @param $sLicense license number genereted with payment processing module for internal usage
+     * @return an array with purchased prodict's description. Empty array is returned if something is wrong.
+     * 
+     * @see BxAclModule::serviceRegisterCartItem
+     */
+    /** 
+     * @ref bx_acl-register_cart_item "register_cart_item"
+     */
+    public function serviceRegisterCartItem($iClientId, $iSellerId, $iItemId, $iItemCount, $sOrder, $sLicense)
+    {
+        return $this->_serviceRegisterItem($iClientId, $iSellerId, $iItemId, $iItemCount, $sOrder, $sLicense, BX_ACL_LICENSE_TYPE_SINGLE);
+    }
+
+    /**
+     * @page service Service Calls
+     * @section bx_acl Paid Levels
+     * @subsection bx_acl-payments Payments
+     * @subsubsection bx_acl-register_subscription_item register_subscription_item
+     * 
+     * @code bx_srv('bx_acl', 'register_subscription_item', [...]); @endcode
+     * 
+     * Register a processed subscription (recurring payment) inside the Paid Levels module. Is called with payment processing module after the subscription was registered there.
+     * 
+     * @param $iClientId client ID.
+     * @param $iSellerId seller ID
+     * @param $iItemId item ID.
+     * @param $iItemCount the number of purchased items.
+     * @param $sOrder order number received from payment provider (PayPal, Stripe, etc)
+     * @param $sLicense license number genereted with payment processing module for internal usage
+     * @return an array with subscribed prodict's description. Empty array is returned if something is wrong.
+     * 
+     * @see BxAclModule::serviceRegisterSubscriptionItem
+     */
+    /** 
+     * @ref bx_acl-register_subscription_item "register_subscription_item"
+     */
+    public function serviceRegisterSubscriptionItem($iClientId, $iSellerId, $iItemId, $iItemCount, $sOrder, $sLicense)
+    {
+		return $this->_serviceRegisterItem($iClientId, $iSellerId, $iItemId, $iItemCount, $sOrder, $sLicense, BX_ACL_LICENSE_TYPE_RECURRING);
+    }
+
+    /**
+     * @page service Service Calls
+     * @section bx_acl Paid Levels
+     * @subsection bx_acl-payments Payments
+     * @subsubsection bx_acl-reregister_cart_item reregister_cart_item
+     * 
+     * @code bx_srv('bx_acl', 'reregister_cart_item', [...]); @endcode
+     * 
+     * Reregister a single time payment inside the Paid Levels module. Is called with payment processing module after the payment was reregistered there.
+     * 
+     * @param $iClientId client ID.
+     * @param $iSellerId seller ID
+     * @param $iItemIdOld old item ID.
+     * @param $iItemIdNew new item ID.
+     * @param $sOrder order number received from payment provider (PayPal, Stripe, etc)
+     * @return an array with purchased prodict's description. Empty array is returned if something is wrong.
+     * 
+     * @see BxAclModule::serviceReregisterCartItem
+     */
+    /** 
+     * @ref bx_acl-reregister_cart_item "reregister_cart_item"
+     */
+    public function serviceReregisterCartItem($iClientId, $iSellerId, $iItemIdOld, $iItemIdNew, $sOrder)
+    {
+        return $this->_serviceReregisterItem($iClientId, $iSellerId, $iItemIdOld, $iItemIdNew, $sOrder, BX_ACL_LICENSE_TYPE_SINGLE);
+    }
+
+    /**
+     * @page service Service Calls
+     * @section bx_acl Paid Levels
+     * @subsection bx_acl-payments Payments
+     * @subsubsection bx_acl-reregister_subscription_item reregister_subscription_item
+     * 
+     * @code bx_srv('bx_acl', 'reregister_subscription_item', [...]); @endcode
+     * 
+     * Reregister a subscription (recurring payment) inside the Paid Levels module. Is called with payment processing module after the subscription was reregistered there.
+     * 
+     * @param $iClientId client ID.
+     * @param $iSellerId seller ID
+     * @param $iItemIdOld old item ID.
+     * @param $iItemIdNew new item ID.
+     * @param $sOrder order number received from payment provider (PayPal, Stripe, etc)
+     * @return an array with subscribed prodict's description. Empty array is returned if something is wrong.
+     * 
+     * @see BxAclModule::serviceReregisterSubscriptionItem
+     */
+    /** 
+     * @ref bx_acl-reregister_subscription_item "reregister_subscription_item"
+     */
+    public function serviceReregisterSubscriptionItem($iClientId, $iSellerId, $iItemIdOld, $iItemIdNew, $sOrder)
+    {
+		return $this->_serviceReregisterItem($iClientId, $iSellerId, $iItemIdOld, $iItemIdNew, $sOrder, BX_ACL_LICENSE_TYPE_RECURRING);
+    }
+
+    /**
+     * @page service Service Calls
+     * @section bx_acl Paid Levels
+     * @subsection bx_acl-payments Payments
+     * @subsubsection bx_acl-unregister_cart_item unregister_cart_item
+     * 
+     * @code bx_srv('bx_acl', 'unregister_cart_item', [...]); @endcode
+     * 
+     * Unregister an earlier processed single time payment inside the Paid Levels module. Is called with payment processing module after the payment was unregistered there.
+     * 
+     * @param $iClientId client ID.
+     * @param $iSellerId seller ID
+     * @param $iItemId item ID.
+     * @param $iItemCount the number of purchased items.
+     * @param $sOrder order number received from payment provider (PayPal, Stripe, etc)
+     * @param $sLicense license number genereted with payment processing module for internal usage
+     * @return boolean value determining where the payment was unregistered or not.
+     * 
+     * @see BxAclModule::serviceUnregisterCartItem
+     */
+    /** 
+     * @ref bx_acl-unregister_cart_item "unregister_cart_item"
+     */
+    public function serviceUnregisterCartItem($iClientId, $iSellerId, $iItemId, $iItemCount, $sOrder, $sLicense)
+    {
+        return $this->_serviceUnregisterItem($iClientId, $iSellerId, $iItemId, $iItemCount, $sOrder, $sLicense, BX_ACL_LICENSE_TYPE_SINGLE);
+    }
+
+    /**
+     * @page service Service Calls
+     * @section bx_acl Paid Levels
+     * @subsection bx_acl-payments Payments
+     * @subsubsection bx_acl-unregister_subscription_item unregister_subscription_item
+     * 
+     * @code bx_srv('bx_acl', 'unregister_subscription_item', [...]); @endcode
+     * 
+     * Unregister an earlier processed subscription (recurring payment) inside the Paid Levels module. Is called with payment processing module after the subscription was unregistered there.
+     * 
+     * @param $iClientId client ID.
+     * @param $iSellerId seller ID
+     * @param $iItemId item ID.
+     * @param $iItemCount the number of purchased items.
+     * @param $sOrder order number received from payment provider (PayPal, Stripe, etc)
+     * @param $sLicense license number genereted with payment processing module for internal usage
+     * @return boolean value determining where the subscription was unregistered or not.
+     * 
+     * @see BxAclModule::serviceUnregisterSubscriptionItem
+     */
+    /** 
+     * @ref bx_acl-unregister_subscription_item "unregister_subscription_item"
+     */
+    public function serviceUnregisterSubscriptionItem($iClientId, $iSellerId, $iItemId, $iItemCount, $sOrder, $sLicense)
+    {
+    	return $this->_serviceUnregisterItem($iClientId, $iSellerId, $iItemId, $iItemCount, $sOrder, $sLicense, BX_ACL_LICENSE_TYPE_RECURRING); 
+    }
+
+    /**
+     * @page service Service Calls
+     * @section bx_acl Paid Levels
+     * @subsection bx_acl-payments Payments
+     * @subsubsection bx_acl-cancel_subscription_item cancel_subscription_item
+     * 
+     * @code bx_srv('bx_acl', 'cancel_subscription_item', [...]); @endcode
+     * 
+     * Cancel an earlier processed subscription (recurring payment) inside the Paid Levels module. Is called with payment processing module after the subscription was canceled there.
+     * Reset membership to STANDARD if subscribed membership was still in Trial mode (hasn't been paid yet).
+     * 
+     * @param $iClientId client ID.
+     * @param $iSellerId seller ID
+     * @param $iItemId item ID.
+     * @param $iItemCount the number of purchased items.
+     * @param $sOrder order number received from payment provider (PayPal, Stripe, etc)
+     * @return boolean value determining where the subscription was canceled or not.
+     * 
+     * @see BxAclModule::serviceCancelSubscriptionItem
+     */
+    /** 
+     * @ref bx_acl-cancel_subscription_item "cancel_subscription_item"
+     */
+    public function serviceCancelSubscriptionItem($iClientId, $iSellerId, $iItemId, $iItemCount, $sOrder)
+    {
+        $iItemTrial = 0;
+        $aItemInfo = $this->_oDb->getPrices(['type' => 'by_id', 'value' => $iItemId]);
+        if(empty($aItemInfo) || !is_array($aItemInfo) || !($iItemTrial = (int)$aItemInfo['trial']))
+            return false;
+
+        $oAcl = BxDolAcl::getInstance();
+
+        $aMembershipInfo = $oAcl->getMemberMembershipInfo($iClientId);
+        if(time() - $aMembershipInfo['date_starts'] > $iItemTrial * 86400)
+            return true;
+
+        return $oAcl->setMembership($iClientId, MEMBERSHIP_ID_STANDARD);
+    }
+
+    /**
+     * @page service Service Calls
+     * @section bx_acl Paid Levels
+     * @subsection bx_acl-other Other
+     * @subsubsection bx_acl-get_prices get_prices
+     * 
+     * @code bx_srv('bx_acl', 'get_prices', [...]); @endcode
+     * 
+     * Get array of available price options for membership levels
+     *
+     * @param $iLevelId membership level id
+     * @param $bFreeUnlimitedOnly get unlimited free pricing options only
+     * @return array of levels, or empty array of there are no any pricing options avaiulable
+     * 
+     * @see BxAclModule::serviceGetPrices
+     */
+    /** 
+     * @ref bx_acl-get_prices "get_prices"
+     */
+	public function serviceGetPrices($iLevelId = 0, $bFreeUnlimitedOnly = false)
+    {
+        $aParams = array(
+            'type' => $iLevelId ? 'by_level_id' : 'all_full',
+            'value' => $iLevelId,
+        );
+        $aPrices = $this->_oDb->getPrices($aParams, false);        
+
+        return array_filter($aPrices, function ($r) use ($bFreeUnlimitedOnly) {
+            return $bFreeUnlimitedOnly ? !$r['price'] && !$r['period'] : true;
+        });
+    }
+    
+    /**
+     * @page service Service Calls
+     * @section bx_acl Paid Levels
+     * @subsection bx_acl-other Other
+     * @subsubsection bx_acl-get_products_names get_products_names
+     * 
+     * @code bx_srv('bx_acl', 'get_products_names', [...]); @endcode
+     * 
+     * Get an array of products names.
+     * 
+     * @param $iVendorId return products of 1 vendor only
+     * @param $iLimit limit the result artay
+     * @return an array of products names
+     * 
+     * @see BxAclModule::serviceGetProductsNames
+     */
+    /** 
+     * @ref bx_acl-get_products_names "get_products_names"
+     */
+	public function serviceGetProductsNames($iVendorId = 0, $iLimit = 1000)
+    {
+    	return $this->_oDb->getProductsNames($iVendorId, $iLimit);
+    }
+
+    protected function _serviceRegisterItem($iClientId, $iSellerId, $iItemId, $iItemCount, $sOrder, $sLicense, $sType)
+    {
+        $CNF = &$this->_oConfig->CNF;
+
+    	$aItem = $this->serviceGetCartItem($iItemId);
+        if(empty($aItem) || !is_array($aItem))
+            return [];
+
+        $oAcl = BxDolAcl::getInstance();
+
+        $aItemInfo = $this->_oDb->getPrices(['type' => 'by_id', 'value' => $iItemId]);
+        $aMembershipInfo = $oAcl->getMemberMembershipInfo($iClientId);
+
+        $aPeriod = ['period' => (int)$aItemInfo['period'], 'period_unit' => $aItemInfo['period_unit']];
+        if($sType == BX_ACL_LICENSE_TYPE_RECURRING && (int)$aItemInfo['trial'] > 0 && (int)$aItemInfo['level_id'] != (int)$aMembershipInfo['id'])
+            $aPeriod = ['period' => (int)$aItemInfo['trial'], 'period_unit' => 'day', 'period_trial' => true];
+
+        $iReserve = (int)getParam($CNF['PARAM_RECURRING_RESERVE']);
+        if(!empty($iReserve))
+            $aPeriod['period_reserve'] = $iReserve;
+
+        $bImmediate = (int)$aItemInfo['immediate'] != 0;
+        if(!$oAcl->setMembership($iClientId, $aItemInfo['level_id'], $aPeriod, $bImmediate, $sLicense))
+            return [];
+
+        $this->_oDb->insertLicense([
+            'profile_id' => $iClientId,
+            'price_id' => $iItemId,
+            'type' => $sType,
+            'order' => $sOrder,
+            'license' => $sLicense,
+            'added' => time()
+        ]);
+
+        if($bImmediate)
+            $this->cancelSubscription($iClientId, $aMembershipInfo, $sOrder);
+
+        return $aItem;
+    }
+
+    protected function _serviceReregisterItem($iClientId, $iSellerId, $iItemIdOld, $iItemIdNew, $sOrder, $sType)
+    {
+        $aItemNew = $this->serviceGetCartItem($iItemIdNew);
+        if(empty($aItemNew) || !is_array($aItemNew))
+			return array();
+
+        /*
+         * Note. Membership level cannot be reregistered immediately.
+         * it will be automatically changed in the end of current period.
+         */
+    	return $aItemNew;
+    }
+
+    protected function _serviceUnregisterItem($iClientId, $iSellerId, $iItemId, $iItemCount, $sOrder, $sLicense, $sType)
+    {
+    	$aItemInfo = $this->_oDb->getPrices(array('type' => 'by_id', 'value' => $iItemId));
+    	if(empty($aItemInfo) || !is_array($aItemInfo))
+			return false;
+
+    	return BxDolAcl::getInstance()->unsetMembership($iClientId, $aItemInfo['level_id'], $sLicense);
+    }
+
+    /**
+     * COMMON METHODS
+     */
+	public function getUserId()
+    {
+        return isLogged() ? bx_get_logged_profile_id() : 0;
+    }
+
+    public function getUserInfo($iUserId = 0)
+    {
+        $oProfile = BxDolProfile::getInstance($iUserId);
+        if (!$oProfile)
+            $oProfile = BxDolProfileUndefined::getInstance();
+
+        return array(
+            $oProfile->getDisplayName(),
+            $oProfile->getUrl(),
+            $oProfile->getThumb(),
+            $oProfile->getUnit()
+        );
+    }
+
+    public function cancelSubscription($iUserId, $aMembership = [], $sOrder = '')
+    {
+        $CNF = &$this->_oConfig->CNF;
+
+        if(!$aMembership || !is_array($aMembership))
+            $aMembership = BxDolAcl::getInstance()->getMemberMembershipInfo($iUserId);
+
+        $sLicense = '';
+        if(!$aMembership || !is_array($aMembership) || !($sLicense = $aMembership['transaction_id'] ?? false))
+            return;
+
+        $oPayments = BxDolPayments::getInstance();
+
+        $aLicense = $this->_oDb->getLicenses([
+            'type' => 'by_license', 
+            'license' => $sLicense
+        ]);
+
+        $sOrderDb = '';
+        if(!$aLicense || !is_array($aLicense)) {
+            $aOrders = $oPayments->getOrdersInfo(['license' => $sLicense]);
+            if($aOrders && is_array($aOrders)) {
+                $aOrder = reset($aOrders);
+
+                $aPendings = $oPayments->getPendingOrdersInfo(['id' => $aOrder['pending_id']]);
+                if($aPendings && is_array($aPendings)) {
+                    $aPending = reset($aPendings);
+                    if($aPending['type'] == BX_ACL_LICENSE_TYPE_RECURRING)
+                        $sOrderDb = $aPending['order'];
+                }
+            }    
+        }
+        else if($aLicense['type'] == BX_ACL_LICENSE_TYPE_RECURRING)
+            $sOrderDb = $aLicense['order'];
+
+        if($sOrderDb && (!$sOrder || $sOrder != $sOrderDb) && (($aResult = $oPayments->cancelSubscription($sOrderDb)) === false || (int)$aResult['code'] != 0))
+            sendMailTemplate($CNF['ETEMPLATE_SBS_CANCEL_REQUIRED'], 0, $iUserId, [
+                'level' => _t($aMembership['name']),
+                'order' => $sOrderDb,
+                'date_starts' => $this->_oConfig->formatDate($aMembership['date_starts']),
+                'date_expires' => $this->_oConfig->formatDate($aMembership['date_expires']),
+            ], BX_EMAIL_NOTIFY, true);
+    }
+}
+
+/** @} */

+ 173 - 0
modules/boonex/acl/updates/15.0.4_15.0.5/source/classes/BxAclTemplate.php

@@ -0,0 +1,173 @@
+<?php defined('BX_DOL') or die('hack attempt');
+/**
+ * Copyright (c) UNA, Inc - https://una.io
+ * MIT License - https://opensource.org/licenses/MIT
+ *
+ * @defgroup    PaidLevels Paid Levels
+ * @ingroup     UnaModules
+ *
+ * @{
+ */
+
+class BxAclTemplate extends BxBaseModGeneralTemplate
+{
+    function __construct(&$oConfig, &$oDb)
+    {
+        parent::__construct($oConfig, $oDb);
+    }
+    
+    public function displayActionsBlock($iLevelId, $iStart = 0, $iLimit = 0)
+    {
+        $aLevel = [];
+        BxDolAclQuery::getInstance()->getLevels(['type' => 'by_id', 'value' => $iLevelId], $aLevel, false);
+        if(empty($aLevel) || !is_array($aLevel))
+            return $this->_bIsApi ? [] : '';
+
+        $mixedResult = $this->displayActions($iLevelId, $iStart, $iLimit);
+        if($this->_bIsApi)
+            return $mixedResult;
+
+        $this->addCss(['main.css']);
+        $this->addJs(['main.js']);
+        return $this->parseHtmlByName('actions_block.html', [
+            'level' => _t('_bx_acl_txt_x_actions', _t($aLevel['name'])),
+            'actions' => $mixedResult,
+            'js_code' => $this->getJsCode('main')
+        ]);
+    }
+
+    public function displayActionsPopup($iLevelId)
+    {
+        $CNF = &$this->_oConfig->CNF;
+
+        return BxTemplFunctions::getInstance()->transBox($this->_oConfig->getHtmlIds('actions_popup') . $iLevelId, $this->parseHtmlByName('actions_popup.html', [
+            'actions' => $this->displayActions($iLevelId, 0, $CNF['PARAM_ACTIONS_LIMIT_POPUP'])
+        ]));
+    }
+
+    public function displayActions($iLevelId, $iStart = 0, $iLimit = 0)
+    {
+        $CNF = &$this->_oConfig->CNF;
+
+        if(!$iLimit)
+            $iLimit = !$this->_bIsApi ? $CNF['PARAM_ACTIONS_LIMIT_BLOCK'] : 9999;       
+
+        $aActions = [];
+        BxDolAclQuery::getInstance()->getActions(['type' => 'by_level_id', 'value' => $iLevelId, 'start' => $iStart, 'limit' => $iLimit + 1], $aActions, false);
+        if(empty($aActions) || !is_array($aActions))
+            return $this->_bIsApi ? [] : '';
+
+        $sTxtNo = _t('_No');
+
+        $aTmplVarsActions = [];
+        foreach($aActions as $aAction) {
+            $sTxtLimitCount = $sTxtNo;
+            if((bool)$aAction['countable'] && ($iCount = (int)$aAction['allowed_count']) > 0) {
+                if(($iPeriod = (int)$aAction['allowed_period_len']) > 0)
+                    $sTxtLimitCount = _t('_bx_acl_txt_n_times_m_hours', $iCount, $iPeriod);
+                else
+                    $sTxtLimitCount = _t('_bx_acl_txt_n_times', $iCount);
+            }
+
+            $sTxtLimitTime = $sTxtNo;
+            $bAfter = ($sK = 'allowed_period_start') && $aAction[$sK] && ($iAfter = strtotime($aAction[$sK])) != 0;
+            $bUntil = ($sK = 'allowed_period_end') && $aAction[$sK] && ($iUntil = strtotime($aAction[$sK])) != 0;
+            if($bAfter || $bUntil) {
+                $sAfter = $bAfter ? bx_time_js($iAfter, BX_FORMAT_DATE_TIME, true) : '';
+                $sUntil = $bUntil ? bx_time_js($iUntil, BX_FORMAT_DATE_TIME, true) : '';
+
+                if($bAfter && $bUntil)
+                    $sTxtLimitTime = _t('_bx_acl_txt_after_x_until_y', $sAfter, $sUntil);
+                else if($bAfter)
+                    $sTxtLimitTime = _t('_bx_acl_txt_after_x', $sAfter);
+                else if($bUntil)
+                    $sTxtLimitTime = _t('_bx_acl_txt_until_x', $sUntil);
+            }
+
+            $aTmplVarsActions[] = [
+                'action' => _t($aAction['title']),
+                'limit_count' => $sTxtLimitCount,
+                'limit_time' => $sTxtLimitTime
+            ];
+        }
+
+        if($this->_bIsApi)
+            return $aTmplVarsActions;
+
+        $oPaginate = new BxTemplPaginate([
+            'start' => $iStart,
+            'per_page' => $iLimit,
+            'on_change_page' => $this->_oConfig->getJsObject('main') . ".getActions(this, " . $iLevelId . ", {start}, {per_page})"
+        ]);
+        $oPaginate->setNumFromDataArray($aTmplVarsActions);
+        $sPaginate = $oPaginate->getSimplePaginate();
+
+        return $this->parseHtmlByName('actions_list.html', [
+            'html_id_actions' => $this->_oConfig->getHtmlIds('actions_list') . $iLevelId,
+            'bx_repeat:actions' => $aTmplVarsActions,
+            'bx_if:show_paginate' => [
+                'condition' => !empty($sPaginate),
+                'content' => [
+                    'paginate' => $sPaginate
+                ]
+            ]
+        ]);
+    }
+
+    public function displayEmptyOwner()
+    {
+    	return MsgBox(_t('_bx_acl_msg_empty_owner'));
+    }
+
+    public function displayMembershipActions($iProfileId)
+    {
+        $this->addCss(array('pm_actions.css'));
+    	return $this->parseHtmlByName('pm_actions.html', array(
+            'url_upgrade' => BX_DOL_URL_ROOT . BxDolPermalinks::getInstance()->permalink($this->_oConfig->CNF['URL_VIEW'])
+    	));
+    }
+
+    public function displayLevelIcon($mixedValue)
+    {
+        list($sIcon, $sIconUrl, $sIconA, $sIconHtml) = BxTemplFunctions::getInstance()->getIcon($mixedValue);
+
+        $bTmplVarsIcon = $bTmplVarsImage = $bTmplVarsIconHtml = false;
+        $aTmplVarsIcon = $aTmplVarsImage = $aTmplVarsIconHtml = [];
+        if(($bTmplVarsIcon = $sIcon !== false))
+            $aTmplVarsIcon = [
+                'name' => $sIcon
+            ];
+        else if(($bTmplVarsImage = $sIconUrl !== false))
+            $aTmplVarsImage = [
+                'src' => $sIconUrl,
+            ];
+        else if(($bTmplVarsIconHtml = $sIconHtml !== false))
+            $aTmplVarsIconHtml = [
+                'icon_html' => $sIconHtml
+            ];
+
+        return $this->parseHtmlByName('level_icon.html', [
+            'bx_if:show_image' => [
+                'condition' => $bTmplVarsImage,
+                'content' => $aTmplVarsImage
+            ],
+            'bx_if:show_icon' => [
+                'condition' => $bTmplVarsIcon,
+                'content' => $aTmplVarsIcon
+            ],
+            'bx_if:show_icon_html' => [
+                'condition' => $bTmplVarsIconHtml,
+                'content' => $aTmplVarsIconHtml 
+            ]
+        ]);
+    }
+
+    public function getJsCode($sType, $aParams = [], $bWrap = true)
+    {
+        return parent::getJsCode($sType, array_merge([
+            'aHtmlIds' => $this->_oConfig->getHtmlIds()
+        ], $aParams), $bWrap);
+    }
+}
+
+/** @} */

+ 70 - 0
modules/boonex/acl/updates/15.0.4_15.0.5/source/install/config.php

@@ -0,0 +1,70 @@
+<?php
+/**
+ * Copyright (c) UNA, Inc - https://una.io
+ * MIT License - https://opensource.org/licenses/MIT
+ *
+ * @defgroup    PaidLevels Paid Levels
+ * @ingroup     UnaModules
+ *
+ * @{
+ */
+
+$aConfig = array(
+    /**
+     * Main Section.
+     */
+    'type' => BX_DOL_MODULE_TYPE_MODULE,
+    'name' => 'bx_acl',
+    'title' => 'Paid Levels',
+    'note' => 'Paid levels, membership levels, access',
+    'version' => '15.0.5',
+    'vendor' => 'UNA INC',
+    'help_url' => 'http://feed.una.io/?section={module_name}',
+
+    'compatible_with' => array(
+        '15.0.0-RC3'
+    ),
+
+    /**
+     * 'home_dir' and 'home_uri' - should be unique. Don't use spaces in 'home_uri' and the other special chars.
+     */
+    'home_dir' => 'boonex/acl/',
+    'home_uri' => 'acl',
+
+    'db_prefix' => 'bx_acl_',
+    'class_prefix' => 'BxAcl',
+
+    /**
+     * Category for language keys.
+     */
+    'language_category' => 'Paid Levels',
+
+    /**
+     * Installation/Uninstallation Section.
+     */
+    'install' => array(
+        'execute_sql' => 1,
+        'update_languages' => 1,
+        'clear_db_cache' => 1,
+    ),
+    'uninstall' => array (
+        'execute_sql' => 1,
+        'update_languages' => 1,
+        'clear_db_cache' => 1,
+    ),
+    'enable' => array(
+        'execute_sql' => 1,
+        'clear_db_cache' => 1,
+    ),
+    'disable' => array (
+        'execute_sql' => 1,
+        'clear_db_cache' => 1,
+    ),
+
+    /**
+     * Dependencies Section
+     */
+    'dependencies' => array(),
+);
+
+/** @} */

+ 18 - 0
modules/boonex/acl/updates/15.0.4_15.0.5/source/template/css/view.css

@@ -0,0 +1,18 @@
+/*--- View Levels Styles ---*/
+div.bx-acl-level-icon {
+	display: inline-block;
+
+	background-color: rgba(255, 255, 255, 1.0);
+
+	text-align: center;
+	overflow: hidden;
+}
+
+div.bx-acl-level-icon svg {
+	display: inline-block;
+}
+
+div.bx-acl-level-icon .sys-icon {
+	line-height: 32px;
+	font-size: 24px;
+}

+ 11 - 0
modules/boonex/acl/updates/15.0.4_15.0.5/source/template/level_icon.html

@@ -0,0 +1,11 @@
+<div class="bx-acl-level-icon bx-def-border bx-def-round-corners bx-def-icon-size">
+    <bx_if:show_image>
+        <img class="bx-def-icon-size" src="__src__" />
+    </bx_if:show_image>
+    <bx_if:show_icon>
+        <i class="sys-icon __name__"></i>
+    </bx_if:show_icon>
+    <bx_if:show_icon_html>
+        __icon_html__
+    </bx_if:show_icon_html>
+</div>

+ 51 - 0
modules/boonex/ads/updates/15.0.5_15.0.6/install/config.php

@@ -0,0 +1,51 @@
+<?php
+/**
+ * Copyright (c) UNA, Inc - https://una.io
+ * MIT License - https://opensource.org/licenses/MIT
+ */
+
+$aConfig = array(
+    /**
+     * Main Section.
+     */
+    'title' => 'Ads',
+    'version_from' => '15.0.5',
+    'version_to' => '15.0.6',
+    'vendor' => 'UNA INC',
+
+    'compatible_with' => array(
+        '15.0.0-RC3'
+    ),
+
+    /**
+     * 'home_dir' and 'home_uri' - should be unique. Don't use spaces in 'home_uri' and the other special chars.
+     */
+    'home_dir' => 'boonex/ads/updates/update_15.0.5_15.0.6/',
+    'home_uri' => 'ads_update_1505_1506',
+
+    'module_dir' => 'boonex/ads/',
+    'module_uri' => 'ads',
+
+    'db_prefix' => 'bx_ads_',
+    'class_prefix' => 'BxAds',
+
+    /**
+     * Installation/Uninstallation Section.
+     */
+    'install' => array(
+        'execute_sql' => 0,
+        'update_files' => 1,
+        'update_languages' => 0,
+        'clear_db_cache' => 0,
+    ),
+
+    /**
+     * Category for language keys.
+     */
+    'language_category' => 'Ads',
+
+    /**
+     * Files Section
+     */
+    'delete_files' => array(),
+);

+ 28 - 0
modules/boonex/ads/updates/15.0.5_15.0.6/install/installer.php

@@ -0,0 +1,28 @@
+<?php
+/**
+ * Copyright (c) UNA, Inc - https://una.io
+ * MIT License - https://opensource.org/licenses/MIT
+ */
+
+class BxAdsUpdater extends BxDolStudioUpdater
+{
+    function __construct($aConfig)
+    {
+        parent::__construct($aConfig);
+    }
+    protected function actionUpdateFiles($bInstall = true)
+    {
+        $iResult = parent::actionUpdateFiles($bInstall);
+
+        if($iResult == BX_DOL_STUDIO_INSTALLER_SUCCESS) {
+            $aModuleConfig = BxDolInstallerUtils::getModuleConfig(BX_DIRECTORY_PATH_MODULES . $this->_aConfig['module_dir'] . 'install/config.php');
+            if(isset($aModuleConfig['note']))
+                $this->oDb->query("UPDATE `sys_modules` SET `note`=:note WHERE `uri`=:uri", [
+                    'note' => $aModuleConfig['note'],
+                    'uri' => $this->_aConfig['module_uri']
+                ]);
+        }
+
+        return $iResult;
+    }
+}

+ 414 - 0
modules/boonex/ads/updates/15.0.5_15.0.6/source/classes/BxAdsTemplate.php

@@ -0,0 +1,414 @@
+<?php defined('BX_DOL') or die('hack attempt');
+/**
+ * Copyright (c) UNA, Inc - https://una.io
+ * MIT License - https://opensource.org/licenses/MIT
+ *
+ * @defgroup    Ads Ads
+ * @ingroup     UnaModules
+ *
+ * @{
+ */
+
+/*
+ * Module representation.
+ */
+class BxAdsTemplate extends BxBaseModTextTemplate
+{
+    function __construct(&$oConfig, &$oDb)
+    {
+        $this->MODULE = 'bx_ads';
+
+        parent::__construct($oConfig, $oDb);
+
+        $this->aMethodsToCallAddJsCss[] = 'categories';
+    }
+
+    public function entryPromotionGrowth($aContentInfo)
+    {
+        $CNF = &$this->_oConfig->CNF;
+
+        if(($mixedResult = $this->_oModule->checkAllowedEdit($aContentInfo)) !== CHECK_ACTION_RESULT_ALLOWED)
+            return MsgBox($mixedResult);
+
+        $sDateFrom = date('Y-m-d', $aContentInfo[$CNF['FIELD_ADDED']]);
+        $sDateTo = date('Y-m-d', time());
+
+        $aForm = [
+            'form_attrs' => [
+                'id' => 'bx_chart_controls',
+                'action' => ''
+            ],
+            'inputs' => [
+                'object' => [
+                    'type' => 'select',
+                    'name' => 'object',
+                    'caption' => _t('_sys_chart_growth_object'),
+                    'info' => '',
+                    'value' => '',
+                    'values' => [],
+                    'required' => '0',
+                    'attrs' => [
+                        'id' => 'bx_chart_growth_objects',
+                        'onchange' => 'oBxDolChartGrowth.loadData()'
+                    ],
+                ],
+                'date_from' => [
+                    'type' => 'datepicker',
+                    'name' => 'date_from',
+                    'caption' => _t('_sys_chart_growth_date_from'),
+                    'info' => '',
+                    'value' => $sDateFrom,
+                    'values' => [],
+                    'required' => '0',
+                    'attrs' => [
+                        'id' => 'bx_chart_growth_date_from',
+                        'onchange' => 'oBxDolChartGrowth.loadData()'
+                    ],
+                ],
+                'date_to' => [
+                    'type' => 'datepicker',
+                    'name' => 'date_to',
+                    'caption' => _t('_sys_chart_growth_date_to'),
+                    'info' => '',
+                    'value' => $sDateTo,
+                    'values' => [],
+                    'required' => '0',
+                    'attrs' => [
+                        'id' => 'bx_chart_growth_date_to',
+                        'onchange' => 'oBxDolChartGrowth.loadData()'
+                    ],
+                ]
+            ]
+        ];
+
+        foreach($CNF['OBJECT_PROMOTION_CHARTS'] as $sChart) {
+            $aChart = BxDolChartQuery::getChartObject($sChart);
+            if(!$aChart)
+                continue;
+
+            $aForm['inputs']['object']['values'][] = ['key' => $sChart, 'value' => _t($aChart['title'])];
+        }
+
+        $oForm = new BxTemplFormView($aForm);
+
+        $oTemplate = BxDolTemplate::getInstance();
+        $oTemplate->addJs(['chart.umd.min.js', 'chartjs-adapter-moment.min.js', 'BxDolChartGrowth.js']);
+        $oTemplate->addCss(['chart.css']);
+
+        if($this->_bIsApi){
+            return [bx_api_get_block('chart', ['type' => 'line', 'title' => 'Promotion for ' . $aContentInfo[$CNF['FIELD_TITLE']], 'form' =>  $oForm->getCodeApi(), 'endpoint' => 'system/get_data_by_interval/TemplChartServices', 'params' =>['object' => $aForm['inputs']['object']['values'][0]['key'], 'date_from' => $sDateFrom, 'date_to' => $sDateTo, 'content_id' => $aContentInfo[$CNF['FIELD_ID']]]])];
+        }
+        
+        return $oTemplate->parseHtmlByName('chart_growth.html', [
+            'controls' => $oForm->getCode(),
+            'date_from' => $sDateFrom,
+            'date_to' => $sDateTo,
+            'request_params' => json_encode([
+                'content_id' => $aContentInfo[$CNF['FIELD_ID']]
+            ]),
+        ]);
+    }
+    
+    public function entryPromotionSummary($aContentInfo)
+    {
+        $CNF = &$this->_oConfig->CNF;
+
+        if(($mixedResult = $this->_oModule->checkAllowedEdit($aContentInfo)) !== CHECK_ACTION_RESULT_ALLOWED)
+            return MsgBox($mixedResult);
+
+        $aTmplVarsDataLabels = $aTmplVarsDataSet = [];
+        foreach(['impressions', 'clicks'] as $sItem) {
+            $sTitle = bx_html_attribute(_t($CNF['T']['chart_label_' . $sItem]));
+            $iValue = (int)$aContentInfo[$sItem];
+
+            $aTmplVarsDataLabels[] = $sTitle . ' - ' . $iValue;
+            $aTmplVarsDataSet['data'][] = $iValue;
+            $aTmplVarsDataSet['backgroundColor'][] = $this->_getChartColor();
+        }
+
+        if($this->_bIsApi){
+            return [bx_api_get_block('chart', ['type' => 'pie', 'title' => 'Summary', 'labels' => $aTmplVarsDataLabels, 'data' => $aTmplVarsDataSet])];
+        }
+
+        $this->addJs(['chart.umd.min.js']);
+        $this->addCss(['chart.css']);
+        return $this->parseHtmlByName('chart_stats.html', [
+            'chart_id' => $this->MODULE . '_chart_summary',
+            'chart_data' => json_encode([
+                'labels' => $aTmplVarsDataLabels,
+                'datasets' => [$aTmplVarsDataSet]
+            ])
+        ]);
+    }
+
+    public function entryPromotionRoi($aContentInfo)
+    {
+        $CNF = &$this->_oConfig->CNF;
+
+        if(($mixedResult = $this->_oModule->checkAllowedEdit($aContentInfo)) !== CHECK_ACTION_RESULT_ALLOWED)
+            return MsgBox($mixedResult);
+
+        $aTmplVarsDataLabels = $aTmplVarsDataSet = [];
+        
+        //--- Get income local.
+        $sTitleLocal = bx_html_attribute(_t($CNF['T']['chart_label_roi_local']));
+        $iValueLocal = (int)$this->_oDb->getLicense(['type' => 'entry_id_income', 'entry_id' => $aContentInfo[$CNF['FIELD_ID']]]);
+
+        $aTmplVarsDataLabels[] = $sTitleLocal . ' - ' . $iValueLocal;
+        $aTmplVarsDataSet['data'][] = $iValueLocal;
+        $aTmplVarsDataSet['backgroundColor'][] = $this->_getChartColor();
+
+        //--- Get income external (from source)
+        $iValueSource = 0;
+        if($this->_oConfig->isSources()) {
+            $oSource = $this->_oModule->getObjectSource($aContentInfo[$CNF['FIELD_SOURCE_TYPE']], $aContentInfo[$CNF['FIELD_AUTHOR']]);
+            if($oSource !== false && !empty($aContentInfo[$CNF['FIELD_SOURCE']])) {
+                $aOrders = $oSource->getOrders([
+                    'sample' => 'sales_by_product_id', 
+                    'product_id' => $aContentInfo[$CNF['FIELD_SOURCE']], 
+                    'date_from' => $aContentInfo[$CNF['FIELD_ADDED']]
+                ]);
+
+                $sTitleSource = bx_html_attribute(_t($CNF['T']['chart_label_roi_source']));
+                foreach($aOrders as $aOrder)
+                    $iValueSource += $aOrder['amount'];
+
+                $aTmplVarsDataLabels[] = $sTitleSource . ' - ' . $iValueSource;
+                $aTmplVarsDataSet['data'][] = $iValueSource;
+                $aTmplVarsDataSet['backgroundColor'][] = $this->_getChartColor();
+            }
+        }
+
+        //--- Get investments
+        $sTitleInvestment = bx_html_attribute(_t($CNF['T']['chart_label_roi_investment']));
+        $iValueInvestment = (int)$this->_oDb->getPromotionLicense(['type' => 'entry_id_outcome', 'entry_id' => $aContentInfo[$CNF['FIELD_ID']]]);
+
+        $aTmplVarsDataLabels[] = $sTitleInvestment . ' - ' . $iValueInvestment;
+        $aTmplVarsDataSet['data'][] = $iValueInvestment;
+        $aTmplVarsDataSet['backgroundColor'][] = $this->_getChartColor();
+
+        $fRoi = 0;
+        if($iValueInvestment > 0)
+            $fRoi = 100 * ($iValueLocal + $iValueSource - $iValueInvestment)/$iValueInvestment;
+
+        if($this->_bIsApi) {
+            return [bx_api_get_block('chart', ['type' => 'pie', 'title' => 'ROI', 'labels' => $aTmplVarsDataLabels, 'data' => $aTmplVarsDataSet, 'text' => 'ROI: ' . $fRoi . '%'])];
+        }
+
+        $this->addJs(['chart.umd.min.js']);
+        $this->addCss(['chart.css']);
+        return $this->parseHtmlByName('entry-roi.html', [
+            'chart' => $this->parseHtmlByName('chart_stats.html', [
+                'chart_id' => $this->MODULE . '_chart_roi',
+                'chart_data' => json_encode([
+                    'labels' => $aTmplVarsDataLabels,
+                    'datasets' => [$aTmplVarsDataSet]
+                ])
+            ]),
+            'roi' => round($fRoi, 1)
+        ]);
+    }
+
+    public function entryOfferAccepted($iUserId, $aContent, $aOffer)
+    {
+        $CNF = &$this->_oConfig->CNF;
+
+        $oPayments = BxDolPayments::getInstance();
+        $iContentAuthorId = (int)$aContent[$CNF['FIELD_AUTHOR']];
+
+        $sJsCodePay = $sJsMethodPay = '';
+        $aJsPay = $oPayments->getAddToCartJs($iContentAuthorId, $this->MODULE, $aContent[$CNF['FIELD_ID']], $aOffer[$CNF['FIELD_OFR_QUANTITY']], true);
+        if(!empty($aJsPay) && is_array($aJsPay))
+            list($sJsCodePay, $sJsMethodPay) = $aJsPay;
+
+        return $this->parseHtmlByName('entry-offer-accepted.html', array(
+            'js_object' => $this->_oConfig->getJsObject('entry'),
+            'id' => $aOffer['id'],
+            'amount' => _t_format_currency_ext($aOffer['amount'], [
+                'sign' => $oPayments->getCurrencySign($iContentAuthorId)
+            ]),
+            'quantity' => _t('_bx_ads_txt_n_items', $aOffer['quantity']),
+            'bx_if:show_pay' => array(
+                'condition' => !empty($sJsMethodPay),
+                'content' => array(
+                    'js_method_pay' => $sJsMethodPay,
+                )
+            ),
+            'js_code' => $sJsCodePay
+        ));
+    }
+
+    public function entryBreadcrumb($aContentInfo, $aTmplVarsItems = array())
+    {
+    	$CNF = &$this->_oConfig->CNF;
+
+        $oPermalink = BxDolPermalinks::getInstance();
+
+        $aTmplVarsItems = array();
+        $this->_entryBreadcrumb($aContentInfo[$CNF['FIELD_CATEGORY']], $oPermalink, $aTmplVarsItems);
+        $aTmplVarsItems = array_reverse($aTmplVarsItems);
+        
+        $aTmplVarsItems[] = array(
+            'url' => bx_absolute_url($oPermalink->permalink('page.php?i=' . $CNF['URI_VIEW_ENTRY'] . '&id=' . $aContentInfo[$CNF['FIELD_ID']])),
+            'title' => bx_process_output($aContentInfo[$CNF['FIELD_TITLE']])
+        );
+
+    	return parent::entryBreadcrumb($aContentInfo, $aTmplVarsItems);
+    }
+
+    public function categoriesList($aParams = array())
+    {
+        $CNF = &$this->_oConfig->CNF;
+
+        $sResult = $this->_categoriesList(0, array(
+            'url' => bx_absolute_url(BxDolPermalinks::getInstance()->permalink($CNF['URL_CATEGORIES'], array('category' => '')))
+        ));
+
+        if(empty($sResult) && isset($aParams['show_empty']) && $aParams['show_empty'] === true)
+            $sResult = MsgBox(_t('_Empty'));
+
+        return $sResult;
+    }
+
+    public function getEntryLink($aEntry)
+    {
+        $CNF = &$this->_oConfig->CNF;
+
+        return $this->getLink('entry-link', array(
+            'href' => bx_absolute_url(BxDolPermalinks::getInstance()->permalink('page.php?i=' . $CNF['URI_VIEW_ENTRY'], array(
+                'id' => $aEntry[$CNF['FIELD_ID']]
+            ))),
+            'title' => bx_html_attribute($aEntry[$CNF['FIELD_TITLE']]),
+            'content' => $aEntry[$CNF['FIELD_TITLE']]
+        ));
+    }
+
+    protected function getUnit($aData, $aParams = [])
+    {
+        $CNF = &$this->_oConfig->CNF;
+
+        $aResult = parent::getUnit($aData, $aParams);
+        
+        if($this->_oConfig->isSources() && !empty($aData[$CNF['FIELD_URL']])) {
+            $aResult = array_merge($aResult, [
+                'content_url' => $aData[$CNF['FIELD_URL']],
+            ]);
+
+            if($aResult['bx_if:thumb']['condition'])
+                $aResult['bx_if:thumb']['content'] = array_merge($aResult['bx_if:thumb']['content'], [
+                    'content_url' => $aData[$CNF['FIELD_URL']],
+                ]);
+        }
+
+        if($this->_oConfig->isPromotion()) {
+            $sJsObject = $this->_oConfig->getJsObject('main');
+
+            $sContentOnclick = 'return ' . $sJsObject . '.registerClick(this, ' . $aData[$CNF['FIELD_ID']] . ')';
+
+            $aResult = array_merge($aResult, [
+                'bx_if:show_onclick' => [
+                    'condition' => true,
+                    'content' => [
+                        'content_onclick' => $sContentOnclick
+                    ]
+                ],
+                'unit_content_after' => $this->_wrapInTagJsCode($sJsObject . '.registerTracker(' . $aData[$CNF['FIELD_ID']] . ');'),
+            ]);
+
+            if($aResult['bx_if:thumb']['condition'])
+                $aResult['bx_if:thumb']['content'] = array_merge($aResult['bx_if:thumb']['content'], [
+                    'bx_if:show_thumb_onclick' => [
+                        'condition' => true,
+                        'content' => [
+                            'content_onclick' => $sContentOnclick
+                        ]
+                    ],
+                ]);
+        }
+
+        return $aResult;
+    }
+
+    /**
+     * Use Gallery image for both because currently there is no Unit types with small thumbnails.
+     */
+    protected function getUnitThumbAndGallery ($aData)
+    {
+        list($sPhotoThumb, $sPhotoGallery) = parent::getUnitThumbAndGallery($aData);
+
+        return array($sPhotoGallery, $sPhotoGallery);
+    }
+
+    protected function _categoriesList($iParentId, $aParams = array())
+    {
+        $aCategories = $this->_oDb->getCategories(array('type' => 'parent_id', 'parent_id' => $iParentId));
+
+        $aTmplVars = array();
+        foreach($aCategories as $aCategory) {
+            $iItems = (int)$aCategory['items'];
+
+            $sSibcategories = $this->_categoriesList($aCategory['id'], $aParams);
+            if($iItems == 0 && empty($sSibcategories))
+                continue;
+
+            $aTmplVars[] = array(
+                'url' => $aParams['url'] . $aCategory['id'],
+                'title' => _t($aCategory['title']),
+                'bx_if:show_icon' => array(
+                    'condition' => !empty($aCategory['icon']),
+                    'content' => array(
+                        'icon' => $aCategory['icon'],
+                    )
+                ),
+                'bx_if:show_counter' => array(
+                    'condition' => $iItems != 0,
+                    'content' => array(
+                        'items' => $iItems,
+                    )
+                ),
+                'bx_if:show_subcategories' => array(
+                    'condition' => !empty($sSibcategories),
+                    'content' => array(
+                        'subcategories' => $sSibcategories
+                    )
+                )
+            );
+        }
+
+        if(empty($aTmplVars))
+            return '';
+
+        return $this->parseHtmlByName('categories.html', array(
+            'bx_repeat:categories' => $aTmplVars
+        ));
+    }
+
+    /**
+     * Random chart slice colour as #rrggbb.
+     */
+    protected function _getChartColor()
+    {
+        return sprintf('#%06x', random_int(0x000000, 0xFFFFFF));
+    }
+
+    protected function _entryBreadcrumb($iCategory, &$oPermalink, &$aTmplVarsItems)
+    {
+        $CNF = &$this->_oConfig->CNF;
+
+        $aCategory = $this->_oDb->getCategories(array('type' => 'id', 'id' => $iCategory));
+        if(empty($aCategory) || !is_array($aCategory))
+            return;
+
+        $aTmplVarsItems[] = array(
+            'url' => bx_absolute_url($oPermalink->permalink($CNF['URL_CATEGORIES'], array($CNF['GET_PARAM_CATEGORY'] => $aCategory['id']))),
+            'title' => bx_process_output(_t($aCategory['title']))
+        );
+
+        if(empty($aCategory['parent_id']))
+            return;
+
+        $this->_entryBreadcrumb((int)$aCategory['parent_id'], $oPermalink, $aTmplVarsItems);
+    }
+}
+
+/** @} */

+ 151 - 0
modules/boonex/ads/updates/15.0.5_15.0.6/source/install/config.php

@@ -0,0 +1,151 @@
+<?php
+/**
+ * Copyright (c) UNA, Inc - https://una.io
+ * MIT License - https://opensource.org/licenses/MIT
+ *
+ * @defgroup    Ads Ads
+ * @ingroup     UnaModules
+ *
+ * @{
+ */
+
+$aConfig = array(
+
+    /**
+     * Main Section.
+     */
+    'type' => BX_DOL_MODULE_TYPE_MODULE,
+    'name' => 'bx_ads',
+    'title' => 'Ads',
+    'note' => 'Ads, advertisements, classifieds',
+    'version' => '15.0.6',
+    'vendor' => 'UNA INC',
+    'help_url' => 'http://feed.una.io/?section={module_name}',
+
+    'compatible_with' => array(
+        '15.0.0-RC3'
+    ),
+
+    /**
+     * 'home_dir' and 'home_uri' - should be unique. Don't use spaces in 'home_uri' and the other special chars.
+     */
+    'home_dir' => 'boonex/ads/',
+    'home_uri' => 'ads',
+
+    'db_prefix' => 'bx_ads_',
+    'class_prefix' => 'BxAds',
+
+    /**
+     * Category for language keys.
+     */
+    'language_category' => 'Ads',
+
+    /**
+     * List of page triggers.
+     */
+    'page_triggers' => array (
+    	'trigger_page_profile_view_entry',
+    ),
+    
+    /**
+     * Menu triggers.
+     */
+    'menu_triggers' => array(
+        'trigger_profile_view_submenu',
+        'trigger_group_view_submenu',
+    ),
+
+    /**
+     * Storage objects to automatically delete files from upon module uninstallation.
+     * Note. Don't add storage objects used in transcoder objects.
+     */
+    'storages' => array(
+        'bx_ads_covers',
+    	'bx_ads_photos',
+        'bx_ads_videos',
+        'bx_ads_files'
+    ),
+
+    /**
+     * Transcoders.
+     */
+    'transcoders' => array(
+        'bx_ads_preview',
+        'bx_ads_gallery',
+        'bx_ads_cover',
+
+        'bx_ads_preview_photos',
+        'bx_ads_gallery_photos',
+        'bx_ads_view_photos',
+
+        'bx_ads_videos_poster',
+        'bx_ads_videos_poster_preview',
+        'bx_ads_videos_mp4',
+        'bx_ads_videos_mp4_hd',
+
+        'bx_ads_preview_files',
+        'bx_ads_gallery_files'
+    ),
+
+    /**
+     * Extended Search Forms.
+     */
+    'esearches' => array(
+        'bx_ads',
+    	'bx_ads_cmts'
+    ),
+
+    /**
+     * Installation/Uninstallation Section.
+     */
+    'install' => array(
+        'execute_sql' => 1,
+        'update_languages' => 1,
+        'clear_db_cache' => 1,
+    ),
+    'uninstall' => array (
+    	'process_esearches' => 1,
+        'execute_sql' => 1,
+        'update_languages' => 1,
+        'update_relations' => 1,
+        'clear_db_cache' => 1,
+    ),
+    'enable' => array(
+        'execute_sql' => 1,
+        'update_relations' => 1,
+        'clear_db_cache' => 1,
+    ),
+    'enable_success' => array(
+        'process_menu_triggers' => 1,
+        'process_page_triggers' => 1,
+    	'process_esearches' => 1,
+        'register_transcoders' => 1,
+        'clear_db_cache' => 1,
+    ),
+    'disable' => array (
+        'execute_sql' => 1,
+        'update_relations' => 1,
+        'unregister_transcoders' => 1,
+        'clear_db_cache' => 1,
+    ),
+    'disable_failed' => array (
+        'register_transcoders' => 1,
+        'clear_db_cache' => 1,
+    ),
+
+    /**
+     * Dependencies Section
+     */
+    'dependencies' => array(),
+
+    /**
+     * Relations Section
+     */
+    'relations' => array(
+    	'bx_timeline',
+    	'bx_notifications'
+    ),
+
+);
+
+/** @} */

+ 51 - 0
modules/boonex/albums/updates/15.0.4_15.0.5/install/config.php

@@ -0,0 +1,51 @@
+<?php
+/**
+ * Copyright (c) UNA, Inc - https://una.io
+ * MIT License - https://opensource.org/licenses/MIT
+ */
+
+$aConfig = array(
+    /**
+     * Main Section.
+     */
+    'title' => 'Albums',
+    'version_from' => '15.0.4',
+    'version_to' => '15.0.5',
+    'vendor' => 'UNA INC',
+
+    'compatible_with' => array(
+        '15.0.0-RC3'
+    ),
+
+    /**
+     * 'home_dir' and 'home_uri' - should be unique. Don't use spaces in 'home_uri' and the other special chars.
+     */
+    'home_dir' => 'boonex/albums/updates/update_15.0.4_15.0.5/',
+    'home_uri' => 'albums_update_1504_1505',
+
+    'module_dir' => 'boonex/albums/',
+    'module_uri' => 'albums',
+
+    'db_prefix' => 'bx_albums_',
+    'class_prefix' => 'BxAlbums',
+
+    /**
+     * Installation/Uninstallation Section.
+     */
+    'install' => array(
+        'execute_sql' => 0,
+        'update_files' => 1,
+        'update_languages' => 0,
+        'clear_db_cache' => 0,
+    ),
+
+    /**
+     * Category for language keys.
+     */
+    'language_category' => 'Albums',
+
+    /**
+     * Files Section
+     */
+    'delete_files' => array(),
+);

+ 28 - 0
modules/boonex/albums/updates/15.0.4_15.0.5/install/installer.php

@@ -0,0 +1,28 @@
+<?php
+/**
+ * Copyright (c) UNA, Inc - https://una.io
+ * MIT License - https://opensource.org/licenses/MIT
+ */
+
+class BxAlbumsUpdater extends BxDolStudioUpdater
+{
+    function __construct($aConfig)
+    {
+        parent::__construct($aConfig);
+    }
+    protected function actionUpdateFiles($bInstall = true)
+    {
+        $iResult = parent::actionUpdateFiles($bInstall);
+
+        if($iResult == BX_DOL_STUDIO_INSTALLER_SUCCESS) {
+            $aModuleConfig = BxDolInstallerUtils::getModuleConfig(BX_DIRECTORY_PATH_MODULES . $this->_aConfig['module_dir'] . 'install/config.php');
+            if(isset($aModuleConfig['note']))
+                $this->oDb->query("UPDATE `sys_modules` SET `note`=:note WHERE `uri`=:uri", [
+                    'note' => $aModuleConfig['note'],
+                    'uri' => $this->_aConfig['module_uri']
+                ]);
+        }
+
+        return $iResult;
+    }
+}

+ 143 - 0
modules/boonex/albums/updates/15.0.4_15.0.5/source/install/config.php

@@ -0,0 +1,143 @@
+<?php
+/**
+ * Copyright (c) UNA, Inc - https://una.io
+ * MIT License - https://opensource.org/licenses/MIT
+ *
+ * @defgroup    Albums Albums
+ * @ingroup     UnaModules
+ *
+ * @{
+ */
+
+$aConfig = array(
+
+    /**
+     * Main Section.
+     */
+    'type' => BX_DOL_MODULE_TYPE_MODULE,
+    'name' => 'bx_albums',
+    'title' => 'Albums',
+    'note' => 'Albums, media collections',
+    'version' => '15.0.5',
+    'vendor' => 'UNA INC',
+    'help_url' => 'http://feed.una.io/?section={module_name}',
+
+    'compatible_with' => array(
+        '15.0.0-RC3'
+    ),
+
+    /**
+     * 'home_dir' and 'home_uri' - should be unique. Don't use spaces in 'home_uri' and the other special chars.
+     */
+    'home_dir' => 'boonex/albums/',
+    'home_uri' => 'albums',
+
+    'db_prefix' => 'bx_albums_',
+    'class_prefix' => 'BxAlbums',
+
+    /**
+     * Category for language keys.
+     */
+    'language_category' => 'Albums',
+
+    /**
+     * List of page triggers.
+     */
+    'page_triggers' => array (
+    	'trigger_page_profile_view_entry',
+    ),
+
+    /**
+     * Menu triggers.
+     */
+    'menu_triggers' => array(
+    	'trigger_profile_view_submenu',
+        'trigger_group_view_submenu',
+    ),
+
+	/**
+     * Storage objects to automatically delete files from upon module uninstallation.
+     * Note. Don't add storage objects used in transcoder objects.
+     */
+    'storages' => array(
+    	'bx_albums_files'
+    ),
+
+    /**
+     * Transcoders.
+     */
+    'transcoders' => array(
+        'bx_albums_preview',
+        'bx_albums_browse',
+        'bx_albums_big',
+        'bx_albums_video_poster_browse',
+        'bx_albums_video_poster_preview',
+        'bx_albums_video_poster_big',
+        'bx_albums_video_mp4',
+        'bx_albums_video_mp4_hd',
+    ),
+
+    /**
+     * Extended Search Forms.
+     */
+    'esearches' => array(
+        'bx_albums',
+    	'bx_albums_media',
+    	'bx_albums_cmts',
+        'bx_albums_media_cmts'
+    ),
+
+    /**
+     * Installation/Uninstallation Section.
+     */
+    'install' => array(
+        'execute_sql' => 1,
+        'update_languages' => 1,
+        'clear_db_cache' => 1,
+    ),
+    'uninstall' => array (
+    	'process_esearches' => 1,
+        'execute_sql' => 1,
+        'update_languages' => 1,
+    	'update_relations' => 1,
+        'clear_db_cache' => 1,
+    ),
+    'enable' => array(
+        'execute_sql' => 1,
+    	'update_relations' => 1,
+        'clear_db_cache' => 1,
+    ),
+    'enable_success' => array(
+        'process_menu_triggers' => 1,
+        'process_page_triggers' => 1,
+    	'process_esearches' => 1,
+    	'register_transcoders' => 1,
+        'clear_db_cache' => 1,
+    ),
+    'disable' => array (
+        'execute_sql' => 1,
+    	'update_relations' => 1,
+    	'unregister_transcoders' => 1,
+        'clear_db_cache' => 1,
+    ),
+    'disable_failed' => array (
+    	'register_transcoders' => 1,
+    	'clear_db_cache' => 1,
+    ),
+
+    /**
+     * Dependencies Section
+     */
+    'dependencies' => array(),
+
+    /**
+     * Connections Section
+     */
+    'relations' => array(
+    	'bx_timeline',
+    	'bx_notifications'
+    ),
+
+);
+
+/** @} */

+ 51 - 0
modules/boonex/analytics/updates/15.0.1_15.0.2/install/config.php

@@ -0,0 +1,51 @@
+<?php
+/**
+ * Copyright (c) UNA, Inc - https://una.io
+ * MIT License - https://opensource.org/licenses/MIT
+ */
+
+$aConfig = array(
+    /**
+     * Main Section.
+     */
+    'title' => 'Analytics',
+    'version_from' => '15.0.1',
+    'version_to' => '15.0.2',
+    'vendor' => 'UNA INC',
+
+    'compatible_with' => array(
+        '15.0.0-RC3'
+    ),
+
+    /**
+     * 'home_dir' and 'home_uri' - should be unique. Don't use spaces in 'home_uri' and the other special chars.
+     */
+    'home_dir' => 'boonex/analytics/updates/update_15.0.1_15.0.2/',
+    'home_uri' => 'analytics_update_1501_1502',
+
+    'module_dir' => 'boonex/analytics/',
+    'module_uri' => 'analytics',
+
+    'db_prefix' => 'bx_analytics_',
+    'class_prefix' => 'BxAnalytics',
+
+    /**
+     * Installation/Uninstallation Section.
+     */
+    'install' => array(
+        'execute_sql' => 1,
+        'update_files' => 1,
+        'update_languages' => 1,
+        'clear_db_cache' => 1,
+    ),
+
+    /**
+     * Category for language keys.
+     */
+    'language_category' => 'Analytics',
+
+    /**
+     * Files Section
+     */
+    'delete_files' => array(),
+);

+ 43 - 0
modules/boonex/analytics/updates/15.0.1_15.0.2/install/installer.php

@@ -0,0 +1,43 @@
+<?php
+/**
+ * Copyright (c) UNA, Inc - https://una.io
+ * MIT License - https://opensource.org/licenses/MIT
+ */
+
+class BxAnalyticsUpdater extends BxDolStudioUpdater
+{
+    function __construct($aConfig)
+    {
+        parent::__construct($aConfig);
+    }
+
+    public function actionExecuteSql($sOperation)
+    {
+        $iResult = parent::actionExecuteSql($sOperation);
+
+        // content alerts are subscribed per module upon its enable; the already
+        // enabled ones are subscribed here, after the alerts were added by the SQL
+        if($sOperation == 'install' && $iResult == BX_DOL_STUDIO_INSTALLER_SUCCESS) {
+            $aModule = BxDolModuleQuery::getInstance()->getModuleByUri($this->_aConfig['module_uri']);
+            if(!empty($aModule) && (int)$aModule['enabled'] == 1)
+                BxDolService::call($aModule['name'], 'sync_actions');
+        }
+
+        return $iResult;
+    }
+    protected function actionUpdateFiles($bInstall = true)
+    {
+        $iResult = parent::actionUpdateFiles($bInstall);
+
+        if($iResult == BX_DOL_STUDIO_INSTALLER_SUCCESS) {
+            $aModuleConfig = BxDolInstallerUtils::getModuleConfig(BX_DIRECTORY_PATH_MODULES . $this->_aConfig['module_dir'] . 'install/config.php');
+            if(isset($aModuleConfig['note']))
+                $this->oDb->query("UPDATE `sys_modules` SET `note`=:note WHERE `uri`=:uri", [
+                    'note' => $aModuleConfig['note'],
+                    'uri' => $this->_aConfig['module_uri']
+                ]);
+        }
+
+        return $iResult;
+    }
+}

+ 688 - 0
modules/boonex/analytics/updates/15.0.1_15.0.2/install/langs/en.xml

@@ -0,0 +1,688 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources name="en" flag="gb" title="English">
+	<string_add name="_bx_analytics_adm_stg_cpt_vitals_retention_days"><![CDATA[Keep performance data, days]]></string_add>
+	<string_add name="_bx_analytics_adm_stg_cpt_vitals_sample"><![CDATA[Page loads measured, %]]></string_add>
+	<string_add name="_bx_analytics_adm_stg_cpt_category_content"><![CDATA[Content reports]]></string_add>
+	<string_add name="_bx_analytics_adm_stg_cpt_category_health"><![CDATA[Health]]></string_add>
+	<string_add name="_bx_analytics_adm_stg_cpt_category_privacy"><![CDATA[Privacy and data]]></string_add>
+	<string_add name="_bx_analytics_adm_stg_cpt_category_system"><![CDATA[System]]></string_add>
+	<string_add name="_bx_analytics_adm_stg_cpt_category_tracking"><![CDATA[Tracking]]></string_add>
+	<string_add name="_bx_analytics_adm_stg_inf_errors_ignore"><![CDATA[Error messages not to record, one per line. * stands for any characters, and case does not matter.]]></string_add>
+	<string_add name="_bx_analytics_adm_stg_inf_exclude_ips"><![CDATA[Addresses or CIDR ranges, separated by commas or spaces.]]></string_add>
+	<string_add name="_bx_analytics_adm_stg_inf_exclude_levels"><![CDATA[For example, administrators, so your own visits don't count.]]></string_add>
+	<string_add name="_bx_analytics_adm_stg_inf_exclude_paths"><![CDATA[Path prefixes, or patterns with * (for example /page/*-admin), separated by commas or spaces.]]></string_add>
+	<string_add name="_bx_analytics_adm_stg_inf_geo_source"><![CDATA[The country, region and city that Cloudflare, Vercel or CloudFront add to each request. A CDN sets its own headers and passes the others through, so a visitor could send another CDN's: choose the CDN your site and NEO run behind.]]></string_add>
+	<string_add name="_bx_analytics_adm_stg_inf_identity"><![CDATA[Cookieless: a hash of the address and browser that changes daily, with nothing stored on the device. A guest who comes back another day counts as a new visitor. Persistent: a random id kept in the browser or app, which allows guest retention but may need consent.]]></string_add>
+	<string_add name="_bx_analytics_adm_stg_inf_ignore_params"><![CDATA[Left out of stored page paths, so one page is not counted as many. Separate with commas; * matches any characters. Campaign tags and ad click ids are always left out.]]></string_add>
+	<string_add name="_bx_analytics_adm_stg_inf_limit_hits"><![CDATA[How many pageviews, events, errors and timings the trackers may send from one address in a minute (an IPv6 address counts by its /64 network). More are dropped until the next minute, and the tracker keeps sending. Raise it when many visitors share one address, as in an office or a school. 0: no limit. Site actions and payments are never limited.]]></string_add>
+	<string_add name="_bx_analytics_adm_stg_inf_limit_visits"><![CDATA[How many visits may start from one address in an hour (an IPv6 address counts by its /64 network). Hits that would start another visit are dropped, so a script cannot fill the reports with visits. Visits already running go on. 0: no limit.]]></string_add>
+	<string_add name="_bx_analytics_adm_stg_inf_link_members"><![CDATA[Record the profile of signed-in members, for member sessions and retention. Deleting a profile unlinks its visits.]]></string_add>
+	<string_add name="_bx_analytics_adm_stg_inf_online_window"><![CDATA[Visitors active within this time count as online.]]></string_add>
+	<string_add name="_bx_analytics_adm_stg_inf_platforms"><![CDATA[Clients whose visits are recorded.]]></string_add>
+	<string_add name="_bx_analytics_adm_stg_inf_respect_dnt"><![CDATA[Skip browsers that send Do Not Track or Global Privacy Control.]]></string_add>
+	<string_add name="_bx_analytics_adm_stg_inf_retention_days"><![CDATA[Older visits and events are deleted every hour.]]></string_add>
+	<string_add name="_bx_analytics_adm_stg_inf_session_timeout"><![CDATA[A visit ends after this long without activity.]]></string_add>
+	<string_add name="_bx_analytics_adm_stg_inf_track_actions"><![CDATA[Sign-ups, logins, new profiles and content, comments, reactions and connections become events, with the client they came from.]]></string_add>
+	<string_add name="_bx_analytics_adm_stg_inf_track_bots"><![CDATA[Search engines, AI crawlers, AI assistants fetching pages, link previews, monitors and scripts. They are counted per day and page, apart from visitors.]]></string_add>
+	<string_add name="_bx_analytics_adm_stg_inf_track_errors"><![CDATA[JavaScript errors, unhandled promise rejections, failed requests and app crashes from every client. Query strings, tokens and emails are removed before anything is stored.]]></string_add>
+	<string_add name="_bx_analytics_adm_stg_inf_track_vitals"><![CDATA[Web vitals of page loads (LCP, INP, CLS, FCP, TTFB), and screen loads and starts in the apps.]]></string_add>
+	<string_add name="_bx_analytics_adm_stg_inf_tracking"><![CDATA[Off stops collecting. Reports keep the data already collected.]]></string_add>
+	<string_add name="_bx_analytics_adm_stg_inf_trusted_proxies"><![CDATA[Addresses or CIDR ranges of proxies and load balancers whose X-Forwarded-For header holds the visitor's address. Private network addresses and the NEO server are always trusted.]]></string_add>
+	<string_add name="_bx_analytics_adm_stg_inf_vitals_retention_days"><![CDATA[Measurements older than this are deleted. It can be shorter than the retention of other data, never longer.]]></string_add>
+	<string_add name="_bx_analytics_adm_stg_inf_vitals_sample"><![CDATA[From 0 to 100. Every measured page view adds a row, so a busy site can measure a part of them.]]></string_add>
+	<string_add name="_bx_analytics_bot_ai_agent"><![CDATA[AI assistant]]></string_add>
+	<string_add name="_bx_analytics_bot_ai_crawler"><![CDATA[AI crawler]]></string_add>
+	<string_add name="_bx_analytics_bot_feed"><![CDATA[Feed reader]]></string_add>
+	<string_add name="_bx_analytics_bot_monitor"><![CDATA[Monitoring]]></string_add>
+	<string_add name="_bx_analytics_bot_other"><![CDATA[Other bot]]></string_add>
+	<string_add name="_bx_analytics_bot_script"><![CDATA[Script]]></string_add>
+	<string_add name="_bx_analytics_bot_search"><![CDATA[Search engine]]></string_add>
+	<string_add name="_bx_analytics_bot_seo"><![CDATA[SEO tool]]></string_add>
+	<string_add name="_bx_analytics_bot_social"><![CDATA[Link preview]]></string_add>
+	<string_add name="_bx_analytics_channel_affiliate"><![CDATA[Affiliates]]></string_add>
+	<string_add name="_bx_analytics_channel_ai"><![CDATA[AI assistants]]></string_add>
+	<string_add name="_bx_analytics_channel_campaign"><![CDATA[Other campaigns]]></string_add>
+	<string_add name="_bx_analytics_channel_direct"><![CDATA[Direct]]></string_add>
+	<string_add name="_bx_analytics_channel_display"><![CDATA[Display ads]]></string_add>
+	<string_add name="_bx_analytics_channel_email"><![CDATA[Email]]></string_add>
+	<string_add name="_bx_analytics_channel_internal"><![CDATA[Internal]]></string_add>
+	<string_add name="_bx_analytics_channel_paid_search"><![CDATA[Paid search]]></string_add>
+	<string_add name="_bx_analytics_channel_paid_social"><![CDATA[Paid social]]></string_add>
+	<string_add name="_bx_analytics_channel_referral"><![CDATA[Referral]]></string_add>
+	<string_add name="_bx_analytics_channel_search"><![CDATA[Organic search]]></string_add>
+	<string_add name="_bx_analytics_channel_social"><![CDATA[Organic social]]></string_add>
+	<string_add name="_bx_analytics_device_bot"><![CDATA[Bot]]></string_add>
+	<string_add name="_bx_analytics_device_console"><![CDATA[Game console]]></string_add>
+	<string_add name="_bx_analytics_device_desktop"><![CDATA[Desktop]]></string_add>
+	<string_add name="_bx_analytics_device_mobile"><![CDATA[Mobile]]></string_add>
+	<string_add name="_bx_analytics_device_tablet"><![CDATA[Tablet]]></string_add>
+	<string_add name="_bx_analytics_device_tv"><![CDATA[TV]]></string_add>
+	<string_add name="_bx_analytics_dim_app"><![CDATA[App]]></string_add>
+	<string_add name="_bx_analytics_dim_app_version"><![CDATA[App version]]></string_add>
+	<string_add name="_bx_analytics_dim_browser"><![CDATA[Browser]]></string_add>
+	<string_add name="_bx_analytics_dim_channel"><![CDATA[Channel]]></string_add>
+	<string_add name="_bx_analytics_dim_city"><![CDATA[City]]></string_add>
+	<string_add name="_bx_analytics_dim_country"><![CDATA[Country]]></string_add>
+	<string_add name="_bx_analytics_dim_device"><![CDATA[Device]]></string_add>
+	<string_add name="_bx_analytics_dim_entry_path"><![CDATA[Entry page]]></string_add>
+	<string_add name="_bx_analytics_dim_exit_path"><![CDATA[Exit page]]></string_add>
+	<string_add name="_bx_analytics_dim_hostname"><![CDATA[Hostname]]></string_add>
+	<string_add name="_bx_analytics_dim_language"><![CDATA[Language]]></string_add>
+	<string_add name="_bx_analytics_dim_os"><![CDATA[Operating system]]></string_add>
+	<string_add name="_bx_analytics_dim_path"><![CDATA[Page]]></string_add>
+	<string_add name="_bx_analytics_dim_platform"><![CDATA[Platform]]></string_add>
+	<string_add name="_bx_analytics_dim_referrer"><![CDATA[Referrer URL]]></string_add>
+	<string_add name="_bx_analytics_dim_referrer_host"><![CDATA[Referrer]]></string_add>
+	<string_add name="_bx_analytics_dim_region"><![CDATA[Region]]></string_add>
+	<string_add name="_bx_analytics_dim_screen"><![CDATA[Screen size]]></string_add>
+	<string_add name="_bx_analytics_dim_title"><![CDATA[Page title]]></string_add>
+	<string_add name="_bx_analytics_dim_utm_campaign"><![CDATA[UTM campaign]]></string_add>
+	<string_add name="_bx_analytics_dim_utm_content"><![CDATA[UTM content]]></string_add>
+	<string_add name="_bx_analytics_dim_utm_medium"><![CDATA[UTM medium]]></string_add>
+	<string_add name="_bx_analytics_dim_utm_source"><![CDATA[UTM source]]></string_add>
+	<string_add name="_bx_analytics_dim_utm_term"><![CDATA[UTM term]]></string_add>
+	<string_add name="_bx_analytics_error_kind_http"><![CDATA[Server error]]></string_add>
+	<string_add name="_bx_analytics_error_kind_js"><![CDATA[JavaScript error]]></string_add>
+	<string_add name="_bx_analytics_error_kind_native"><![CDATA[App error]]></string_add>
+	<string_add name="_bx_analytics_error_kind_overflow"><![CDATA[Other]]></string_add>
+	<string_add name="_bx_analytics_error_kind_promise"><![CDATA[Unhandled rejection]]></string_add>
+	<string_add name="_bx_analytics_error_kind_react"><![CDATA[Render error]]></string_add>
+	<string_add name="_bx_analytics_error_kind_resource"><![CDATA[Failed to load]]></string_add>
+	<string_add name="_bx_analytics_error_status_ignored"><![CDATA[Ignored]]></string_add>
+	<string_add name="_bx_analytics_error_status_open"><![CDATA[Open]]></string_add>
+	<string_add name="_bx_analytics_error_status_resolved"><![CDATA[Resolved]]></string_add>
+	<string_add name="_bx_analytics_event_type_event"><![CDATA[Custom event]]></string_add>
+	<string_add name="_bx_analytics_event_type_pageview"><![CDATA[Pageview]]></string_add>
+	<string_add name="_bx_analytics_event_type_una"><![CDATA[Site action]]></string_add>
+	<string_add name="_bx_analytics_geo_cloudflare"><![CDATA[Cloudflare headers only]]></string_add>
+	<string_add name="_bx_analytics_geo_cloudfront"><![CDATA[CloudFront headers only]]></string_add>
+	<string_add name="_bx_analytics_geo_headers"><![CDATA[Any CDN's headers, the first one found]]></string_add>
+	<string_add name="_bx_analytics_geo_none"><![CDATA[Off]]></string_add>
+	<string_add name="_bx_analytics_geo_vercel"><![CDATA[Vercel headers only]]></string_add>
+	<string_add name="_bx_analytics_group_bots"><![CDATA[Bot categories]]></string_add>
+	<string_add name="_bx_analytics_group_devices"><![CDATA[Devices]]></string_add>
+	<string_add name="_bx_analytics_group_errors"><![CDATA[Errors by status]]></string_add>
+	<string_add name="_bx_analytics_group_events"><![CDATA[Event types]]></string_add>
+	<string_add name="_bx_analytics_group_locations"><![CDATA[Locations]]></string_add>
+	<string_add name="_bx_analytics_group_map"><![CDATA[Map]]></string_add>
+	<string_add name="_bx_analytics_group_pages"><![CDATA[Pages]]></string_add>
+	<string_add name="_bx_analytics_group_performance"><![CDATA[Group pages by]]></string_add>
+	<string_add name="_bx_analytics_group_platforms"><![CDATA[Platforms]]></string_add>
+	<string_add name="_bx_analytics_group_retention"><![CDATA[Who to count]]></string_add>
+	<string_add name="_bx_analytics_group_revenue"><![CDATA[Revenue by]]></string_add>
+	<string_add name="_bx_analytics_group_sources"><![CDATA[Sources]]></string_add>
+	<string_add name="_bx_analytics_identity_cookieless"><![CDATA[Cookieless (recommended)]]></string_add>
+	<string_add name="_bx_analytics_identity_persistent"><![CDATA[Persistent id on the device]]></string_add>
+	<string_add name="_bx_analytics_page_block_title_bots"><![CDATA[Bots and agents]]></string_add>
+	<string_add name="_bx_analytics_page_block_title_credits"><![CDATA[Credits]]></string_add>
+	<string_add name="_bx_analytics_page_block_title_errors"><![CDATA[Errors]]></string_add>
+	<string_add name="_bx_analytics_page_block_title_events"><![CDATA[Events]]></string_add>
+	<string_add name="_bx_analytics_page_block_title_filters"><![CDATA[Filters]]></string_add>
+	<string_add name="_bx_analytics_page_block_title_funnels"><![CDATA[Funnels]]></string_add>
+	<string_add name="_bx_analytics_page_block_title_goals"><![CDATA[Goals]]></string_add>
+	<string_add name="_bx_analytics_page_block_title_journeys"><![CDATA[Journeys]]></string_add>
+	<string_add name="_bx_analytics_page_block_title_map"><![CDATA[Visitors by location]]></string_add>
+	<string_add name="_bx_analytics_page_block_title_overview"><![CDATA[Overview]]></string_add>
+	<string_add name="_bx_analytics_page_block_title_performance"><![CDATA[Performance]]></string_add>
+	<string_add name="_bx_analytics_page_block_title_query"><![CDATA[Query]]></string_add>
+	<string_add name="_bx_analytics_page_block_title_realtime"><![CDATA[Right now]]></string_add>
+	<string_add name="_bx_analytics_page_block_title_retention"><![CDATA[Retention]]></string_add>
+	<string_add name="_bx_analytics_page_block_title_revenue"><![CDATA[Revenue]]></string_add>
+	<string_add name="_bx_analytics_page_block_title_sales"><![CDATA[Sales and sellers]]></string_add>
+	<string_add name="_bx_analytics_page_block_title_sessions"><![CDATA[Sessions]]></string_add>
+	<string_add name="_bx_analytics_page_block_title_std_dash_traffic"><![CDATA[Traffic]]></string_add>
+	<string_add name="_bx_analytics_page_block_title_subscriptions"><![CDATA[Subscriptions]]></string_add>
+	<string_add name="_bx_analytics_page_block_title_system_bots"><![CDATA[Analytics: bots and agents]]></string_add>
+	<string_add name="_bx_analytics_page_block_title_system_credits"><![CDATA[Analytics: credits]]></string_add>
+	<string_add name="_bx_analytics_page_block_title_system_errors"><![CDATA[Analytics: errors]]></string_add>
+	<string_add name="_bx_analytics_page_block_title_system_events"><![CDATA[Analytics: events]]></string_add>
+	<string_add name="_bx_analytics_page_block_title_system_filters"><![CDATA[Analytics: filters]]></string_add>
+	<string_add name="_bx_analytics_page_block_title_system_funnels"><![CDATA[Analytics: funnels]]></string_add>
+	<string_add name="_bx_analytics_page_block_title_system_goals"><![CDATA[Analytics: goals]]></string_add>
+	<string_add name="_bx_analytics_page_block_title_system_journeys"><![CDATA[Analytics: journeys]]></string_add>
+	<string_add name="_bx_analytics_page_block_title_system_map"><![CDATA[Analytics: map]]></string_add>
+	<string_add name="_bx_analytics_page_block_title_system_overview"><![CDATA[Analytics: overview]]></string_add>
+	<string_add name="_bx_analytics_page_block_title_system_performance"><![CDATA[Analytics: performance]]></string_add>
+	<string_add name="_bx_analytics_page_block_title_system_query"><![CDATA[Analytics: query]]></string_add>
+	<string_add name="_bx_analytics_page_block_title_system_realtime"><![CDATA[Analytics: realtime]]></string_add>
+	<string_add name="_bx_analytics_page_block_title_system_retention"><![CDATA[Analytics: retention]]></string_add>
+	<string_add name="_bx_analytics_page_block_title_system_revenue"><![CDATA[Analytics: revenue]]></string_add>
+	<string_add name="_bx_analytics_page_block_title_system_sales"><![CDATA[Analytics: sales and sellers]]></string_add>
+	<string_add name="_bx_analytics_page_block_title_system_sessions"><![CDATA[Analytics: sessions]]></string_add>
+	<string_add name="_bx_analytics_page_block_title_system_subscriptions"><![CDATA[Analytics: subscriptions]]></string_add>
+	<string_add name="_bx_analytics_page_block_title_system_table_pages"><![CDATA[Analytics: pages table]]></string_add>
+	<string_add name="_bx_analytics_page_block_title_system_table_sources"><![CDATA[Analytics: sources table]]></string_add>
+	<string_add name="_bx_analytics_page_block_title_system_top_devices"><![CDATA[Analytics: devices]]></string_add>
+	<string_add name="_bx_analytics_page_block_title_system_top_locations"><![CDATA[Analytics: locations]]></string_add>
+	<string_add name="_bx_analytics_page_block_title_system_top_pages"><![CDATA[Analytics: pages]]></string_add>
+	<string_add name="_bx_analytics_page_block_title_system_top_platforms"><![CDATA[Analytics: platforms]]></string_add>
+	<string_add name="_bx_analytics_page_block_title_system_top_sources"><![CDATA[Analytics: sources]]></string_add>
+	<string_add name="_bx_analytics_page_block_title_system_users"><![CDATA[Analytics: users]]></string_add>
+	<string_add name="_bx_analytics_page_block_title_table_pages"><![CDATA[Pages]]></string_add>
+	<string_add name="_bx_analytics_page_block_title_table_sources"><![CDATA[Sources]]></string_add>
+	<string_add name="_bx_analytics_page_block_title_top_devices"><![CDATA[Devices]]></string_add>
+	<string_add name="_bx_analytics_page_block_title_top_locations"><![CDATA[Locations]]></string_add>
+	<string_add name="_bx_analytics_page_block_title_top_pages"><![CDATA[Pages]]></string_add>
+	<string_add name="_bx_analytics_page_block_title_top_platforms"><![CDATA[Platforms]]></string_add>
+	<string_add name="_bx_analytics_page_block_title_top_sources"><![CDATA[Sources]]></string_add>
+	<string_add name="_bx_analytics_page_block_title_users"><![CDATA[Users]]></string_add>
+	<string_add name="_bx_analytics_adm_stg_cpt_online_window"><![CDATA[Online window, minutes]]></string_add>
+	<string_add name="_bx_analytics_adm_stg_cpt_platforms"><![CDATA[Collect from]]></string_add>
+	<string_add name="_bx_analytics_adm_stg_cpt_respect_dnt"><![CDATA[Respect Do Not Track]]></string_add>
+	<string_add name="_bx_analytics_adm_stg_cpt_retention_days"><![CDATA[Keep data, days]]></string_add>
+	<string_add name="_bx_analytics_adm_stg_cpt_session_timeout"><![CDATA[Session timeout, minutes]]></string_add>
+	<string_add name="_bx_analytics_adm_stg_cpt_track_actions"><![CDATA[Record site actions]]></string_add>
+	<string_add name="_bx_analytics_adm_stg_cpt_track_bots"><![CDATA[Count bots and agents]]></string_add>
+	<string_add name="_bx_analytics_adm_stg_cpt_track_errors"><![CDATA[Record errors]]></string_add>
+	<string_add name="_bx_analytics_adm_stg_cpt_track_vitals"><![CDATA[Measure performance]]></string_add>
+	<string_add name="_bx_analytics_adm_stg_cpt_tracking"><![CDATA[Collect visits and events]]></string_add>
+	<string_add name="_bx_analytics_adm_stg_cpt_trusted_proxies"><![CDATA[Trusted proxies]]></string_add>
+	<string_add name="_bx_analytics_adm_stg_cpt_limit_hits"><![CDATA[Hits per minute from one address]]></string_add>
+	<string_add name="_bx_analytics_adm_stg_cpt_limit_visits"><![CDATA[New visits per hour from one address]]></string_add>
+	<string_add name="_bx_analytics_adm_stg_cpt_link_members"><![CDATA[Link visits to members]]></string_add>
+	<string_add name="_bx_analytics_adm_stg_cpt_errors_ignore"><![CDATA[Ignored errors]]></string_add>
+	<string_add name="_bx_analytics_adm_stg_cpt_exclude_ips"><![CDATA[Excluded IP addresses]]></string_add>
+	<string_add name="_bx_analytics_adm_stg_cpt_exclude_levels"><![CDATA[Don't track these levels]]></string_add>
+	<string_add name="_bx_analytics_adm_stg_cpt_exclude_paths"><![CDATA[Excluded paths]]></string_add>
+	<string_add name="_bx_analytics_adm_stg_cpt_geo_source"><![CDATA[Visitor location]]></string_add>
+	<string_add name="_bx_analytics_adm_stg_cpt_identity"><![CDATA[Guest visitor ids]]></string_add>
+	<string_add name="_bx_analytics_adm_stg_cpt_ignore_params"><![CDATA[Query parameters to ignore]]></string_add>
+	<string_add name="_bx_analytics_page_title_sys_revenue"><![CDATA[Analytics: revenue]]></string_add>
+	<string_add name="_bx_analytics_page_title_revenue"><![CDATA[Revenue]]></string_add>
+	<string_add name="_bx_analytics_platform_api"><![CDATA[API]]></string_add>
+	<string_add name="_bx_analytics_platform_core"><![CDATA[Server]]></string_add>
+	<string_add name="_bx_analytics_platform_core_web"><![CDATA[Web (classic)]]></string_add>
+	<string_add name="_bx_analytics_platform_next_web"><![CDATA[Web (NEO)]]></string_add>
+	<string_add name="_bx_analytics_platform_rn_android"><![CDATA[Android app]]></string_add>
+	<string_add name="_bx_analytics_platform_rn_ios"><![CDATA[iOS app]]></string_add>
+	<string_add name="_bx_analytics_range_12m"><![CDATA[Last 12 months]]></string_add>
+	<string_add name="_bx_analytics_range_24h"><![CDATA[Last 24 hours]]></string_add>
+	<string_add name="_bx_analytics_range_30d"><![CDATA[Last 30 days]]></string_add>
+	<string_add name="_bx_analytics_range_7d"><![CDATA[Last 7 days]]></string_add>
+	<string_add name="_bx_analytics_range_90d"><![CDATA[Last 90 days]]></string_add>
+	<string_add name="_bx_analytics_range_today"><![CDATA[Today]]></string_add>
+	<string_add name="_bx_analytics_range_yesterday"><![CDATA[Yesterday]]></string_add>
+	<string_add name="_bx_analytics_rating_good"><![CDATA[Good]]></string_add>
+	<string_add name="_bx_analytics_rating_needs_improvement"><![CDATA[Needs improvement]]></string_add>
+	<string_add name="_bx_analytics_rating_poor"><![CDATA[Poor]]></string_add>
+	<string_add name="_bx_analytics_report_bots"><![CDATA[Bots and agents]]></string_add>
+	<string_add name="_bx_analytics_report_credits"><![CDATA[Credits]]></string_add>
+	<string_add name="_bx_analytics_report_errors"><![CDATA[Errors]]></string_add>
+	<string_add name="_bx_analytics_report_events"><![CDATA[Events]]></string_add>
+	<string_add name="_bx_analytics_report_filters"><![CDATA[Filter bar]]></string_add>
+	<string_add name="_bx_analytics_report_funnels"><![CDATA[Funnels]]></string_add>
+	<string_add name="_bx_analytics_report_goals"><![CDATA[Goals]]></string_add>
+	<string_add name="_bx_analytics_report_journeys"><![CDATA[Journeys]]></string_add>
+	<string_add name="_bx_analytics_report_map"><![CDATA[Visitors by location]]></string_add>
+	<string_add name="_bx_analytics_report_overview"><![CDATA[Overview]]></string_add>
+	<string_add name="_bx_analytics_report_performance"><![CDATA[Performance]]></string_add>
+	<string_add name="_bx_analytics_report_query"><![CDATA[Query]]></string_add>
+	<string_add name="_bx_analytics_report_realtime"><![CDATA[Right now]]></string_add>
+	<string_add name="_bx_analytics_report_retention"><![CDATA[Retention]]></string_add>
+	<string_add name="_bx_analytics_report_revenue"><![CDATA[Revenue]]></string_add>
+	<string_add name="_bx_analytics_report_sales"><![CDATA[Sales and sellers]]></string_add>
+	<string_add name="_bx_analytics_report_sessions"><![CDATA[Sessions]]></string_add>
+	<string_add name="_bx_analytics_report_subscriptions"><![CDATA[Subscriptions]]></string_add>
+	<string_add name="_bx_analytics_report_table"><![CDATA[Table]]></string_add>
+	<string_add name="_bx_analytics_report_top"><![CDATA[Top list]]></string_add>
+	<string_add name="_bx_analytics_report_users"><![CDATA[Users]]></string_add>
+	<string_add name="_bx_analytics_section_behavior"><![CDATA[Behavior]]></string_add>
+	<string_add name="_bx_analytics_section_community"><![CDATA[Community]]></string_add>
+	<string_add name="_bx_analytics_section_configure"><![CDATA[Configure]]></string_add>
+	<string_add name="_bx_analytics_section_conversion"><![CDATA[Conversion]]></string_add>
+	<string_add name="_bx_analytics_section_explore"><![CDATA[Explore]]></string_add>
+	<string_add name="_bx_analytics_section_health"><![CDATA[Health]]></string_add>
+	<string_add name="_bx_analytics_section_revenue"><![CDATA[Revenue]]></string_add>
+	<string_add name="_bx_analytics_section_traffic"><![CDATA[Traffic]]></string_add>
+	<string_add name="_bx_analytics_item_title_system_revenue"><![CDATA[Revenue]]></string_add>
+	<string_add name="_bx_analytics_item_title_revenue"><![CDATA[Revenue]]></string_add>
+	<string_add name="_bx_analytics_lmi_cpt_api"><![CDATA[API]]></string_add>
+	<string_add name="_bx_analytics_lmi_cpt_audience"><![CDATA[Audience]]></string_add>
+	<string_add name="_bx_analytics_lmi_cpt_bots"><![CDATA[Bots and agents]]></string_add>
+	<string_add name="_bx_analytics_lmi_cpt_content"><![CDATA[Content reports]]></string_add>
+	<string_add name="_bx_analytics_lmi_cpt_credits"><![CDATA[Credits]]></string_add>
+	<string_add name="_bx_analytics_lmi_cpt_dashboards"><![CDATA[Dashboards]]></string_add>
+	<string_add name="_bx_analytics_lmi_cpt_errors"><![CDATA[Errors]]></string_add>
+	<string_add name="_bx_analytics_lmi_cpt_events"><![CDATA[Events]]></string_add>
+	<string_add name="_bx_analytics_lmi_cpt_funnels"><![CDATA[Funnels]]></string_add>
+	<string_add name="_bx_analytics_lmi_cpt_goals"><![CDATA[Goals]]></string_add>
+	<string_add name="_bx_analytics_lmi_cpt_journeys"><![CDATA[Journeys]]></string_add>
+	<string_add name="_bx_analytics_lmi_cpt_map"><![CDATA[Map]]></string_add>
+	<string_add name="_bx_analytics_lmi_cpt_overview"><![CDATA[Overview]]></string_add>
+	<string_add name="_bx_analytics_lmi_cpt_pages"><![CDATA[Pages]]></string_add>
+	<string_add name="_bx_analytics_lmi_cpt_performance"><![CDATA[Performance]]></string_add>
+	<string_add name="_bx_analytics_lmi_cpt_query"><![CDATA[Query]]></string_add>
+	<string_add name="_bx_analytics_lmi_cpt_realtime"><![CDATA[Realtime]]></string_add>
+	<string_add name="_bx_analytics_lmi_cpt_retention"><![CDATA[Retention]]></string_add>
+	<string_add name="_bx_analytics_lmi_cpt_revenue"><![CDATA[Revenue]]></string_add>
+	<string_add name="_bx_analytics_lmi_cpt_sales"><![CDATA[Sales and sellers]]></string_add>
+	<string_add name="_bx_analytics_lmi_cpt_sessions"><![CDATA[Sessions]]></string_add>
+	<string_add name="_bx_analytics_lmi_cpt_setup"><![CDATA[Setup]]></string_add>
+	<string_add name="_bx_analytics_lmi_cpt_sources"><![CDATA[Sources]]></string_add>
+	<string_add name="_bx_analytics_lmi_cpt_subscriptions"><![CDATA[Subscriptions]]></string_add>
+	<string_add name="_bx_analytics_lmi_cpt_users"><![CDATA[Users]]></string_add>
+	<string_add name="_bx_analytics_metric_ai_hits"><![CDATA[AI requests]]></string_add>
+	<string_add name="_bx_analytics_metric_bot_hits"><![CDATA[Requests]]></string_add>
+	<string_add name="_bx_analytics_metric_bot_share"><![CDATA[Share of all requests]]></string_add>
+	<string_add name="_bx_analytics_metric_bots"><![CDATA[Bots]]></string_add>
+	<string_add name="_bx_analytics_metric_bounce_rate"><![CDATA[Bounce rate]]></string_add>
+	<string_add name="_bx_analytics_metric_count"><![CDATA[Count]]></string_add>
+	<string_add name="_bx_analytics_metric_crash_free"><![CDATA[Crash-free app visits]]></string_add>
+	<string_add name="_bx_analytics_metric_credits_bundles"><![CDATA[Bundles sold for money]]></string_add>
+	<string_add name="_bx_analytics_metric_duration"><![CDATA[Session duration]]></string_add>
+	<string_add name="_bx_analytics_metric_engaged"><![CDATA[Time on page]]></string_add>
+	<string_add name="_bx_analytics_metric_entries"><![CDATA[Entries]]></string_add>
+	<string_add name="_bx_analytics_metric_error_sessions"><![CDATA[Visits with errors]]></string_add>
+	<string_add name="_bx_analytics_metric_errors"><![CDATA[Errors]]></string_add>
+	<string_add name="_bx_analytics_metric_exits"><![CDATA[Exits]]></string_add>
+	<string_add name="_bx_analytics_metric_new_errors"><![CDATA[New errors]]></string_add>
+	<string_add name="_bx_analytics_metric_occurrences"><![CDATA[Occurrences]]></string_add>
+	<string_add name="_bx_analytics_metric_pages_per_session"><![CDATA[Pages per session]]></string_add>
+	<string_add name="_bx_analytics_metric_pageviews"><![CDATA[Pageviews]]></string_add>
+	<string_add name="_bx_analytics_metric_per_visitor"><![CDATA[Per visitor]]></string_add>
+	<string_add name="_bx_analytics_metric_revenue_arppu"><![CDATA[Revenue per customer]]></string_add>
+	<string_add name="_bx_analytics_metric_revenue_customers"><![CDATA[Paying customers]]></string_add>
+	<string_add name="_bx_analytics_metric_revenue_gross"><![CDATA[Gross revenue]]></string_add>
+	<string_add name="_bx_analytics_metric_revenue_net"><![CDATA[Net revenue]]></string_add>
+	<string_add name="_bx_analytics_metric_revenue_orders"><![CDATA[Orders]]></string_add>
+	<string_add name="_bx_analytics_metric_revenue_refunds"><![CDATA[Refunds]]></string_add>
+	<string_add name="_bx_analytics_metric_sales_commissions"><![CDATA[Commissions paid]]></string_add>
+	<string_add name="_bx_analytics_metric_sales_site"><![CDATA[Sold by the site]]></string_add>
+	<string_add name="_bx_analytics_metric_sales_units"><![CDATA[Units]]></string_add>
+	<string_add name="_bx_analytics_metric_sales_vendors"><![CDATA[Sold by vendors]]></string_add>
+	<string_add name="_bx_analytics_metric_sbs_active"><![CDATA[Paying subscriptions]]></string_add>
+	<string_add name="_bx_analytics_metric_sbs_cancelled"><![CDATA[Cancelled]]></string_add>
+	<string_add name="_bx_analytics_metric_sbs_churn"><![CDATA[Churn]]></string_add>
+	<string_add name="_bx_analytics_metric_sbs_mrr"><![CDATA[MRR]]></string_add>
+	<string_add name="_bx_analytics_metric_sbs_new"><![CDATA[New]]></string_add>
+	<string_add name="_bx_analytics_metric_sbs_renewed"><![CDATA[Renewed]]></string_add>
+	<string_add name="_bx_analytics_metric_sbs_trials"><![CDATA[Trials]]></string_add>
+	<string_add name="_bx_analytics_metric_sessions"><![CDATA[Sessions]]></string_add>
+	<string_add name="_bx_analytics_metric_visitors"><![CDATA[Unique visitors]]></string_add>
+	<string_add name="_bx_analytics_object_sys_profiles_friends"><![CDATA[Friends]]></string_add>
+	<string_add name="_bx_analytics_object_sys_profiles_relations"><![CDATA[Relations]]></string_add>
+	<string_add name="_bx_analytics_object_sys_profiles_subscriptions"><![CDATA[Follows]]></string_add>
+	<string_add name="_bx_analytics_vital_app_start"><![CDATA[App start]]></string_add>
+	<string_add name="_bx_analytics_vital_cls"><![CDATA[Layout shift (CLS)]]></string_add>
+	<string_add name="_bx_analytics_vital_fcp"><![CDATA[First paint (FCP)]]></string_add>
+	<string_add name="_bx_analytics_vital_inp"><![CDATA[Response to input (INP)]]></string_add>
+	<string_add name="_bx_analytics_vital_lcp"><![CDATA[Largest paint (LCP)]]></string_add>
+	<string_add name="_bx_analytics_vital_screen_load"><![CDATA[Screen load]]></string_add>
+	<string_add name="_bx_analytics_vital_ttfb"><![CDATA[Server response (TTFB)]]></string_add>
+	<string_add name="_bx_analytics_tab_app_versions"><![CDATA[App versions]]></string_add>
+	<string_add name="_bx_analytics_tab_apps"><![CDATA[Apps]]></string_add>
+	<string_add name="_bx_analytics_tab_bots_ai"><![CDATA[AI]]></string_add>
+	<string_add name="_bx_analytics_tab_bots_all"><![CDATA[All]]></string_add>
+	<string_add name="_bx_analytics_tab_bots_monitor"><![CDATA[Monitoring]]></string_add>
+	<string_add name="_bx_analytics_tab_bots_script"><![CDATA[Scripts and other]]></string_add>
+	<string_add name="_bx_analytics_tab_bots_search"><![CDATA[Search engines]]></string_add>
+	<string_add name="_bx_analytics_tab_bots_seo"><![CDATA[SEO tools]]></string_add>
+	<string_add name="_bx_analytics_tab_bots_social"><![CDATA[Link previews]]></string_add>
+	<string_add name="_bx_analytics_tab_browsers"><![CDATA[Browsers]]></string_add>
+	<string_add name="_bx_analytics_tab_channels"><![CDATA[Channels]]></string_add>
+	<string_add name="_bx_analytics_tab_cities"><![CDATA[Cities]]></string_add>
+	<string_add name="_bx_analytics_tab_countries"><![CDATA[Countries]]></string_add>
+	<string_add name="_bx_analytics_tab_devices"><![CDATA[Devices]]></string_add>
+	<string_add name="_bx_analytics_tab_entry"><![CDATA[Entry pages]]></string_add>
+	<string_add name="_bx_analytics_tab_errors_all"><![CDATA[All]]></string_add>
+	<string_add name="_bx_analytics_tab_errors_ignored"><![CDATA[Ignored]]></string_add>
+	<string_add name="_bx_analytics_tab_errors_new"><![CDATA[New]]></string_add>
+	<string_add name="_bx_analytics_tab_errors_open"><![CDATA[Open]]></string_add>
+	<string_add name="_bx_analytics_tab_errors_resolved"><![CDATA[Resolved]]></string_add>
+	<string_add name="_bx_analytics_tab_events_all"><![CDATA[All]]></string_add>
+	<string_add name="_bx_analytics_tab_events_custom"><![CDATA[Custom events]]></string_add>
+	<string_add name="_bx_analytics_tab_events_site"><![CDATA[Site actions]]></string_add>
+	<string_add name="_bx_analytics_tab_exit"><![CDATA[Exit pages]]></string_add>
+	<string_add name="_bx_analytics_tab_hostnames"><![CDATA[Hostnames]]></string_add>
+	<string_add name="_bx_analytics_tab_languages"><![CDATA[Languages]]></string_add>
+	<string_add name="_bx_analytics_tab_map_live"><![CDATA[Live]]></string_add>
+	<string_add name="_bx_analytics_tab_map_period"><![CDATA[This period]]></string_add>
+	<string_add name="_bx_analytics_tab_os"><![CDATA[Operating systems]]></string_add>
+	<string_add name="_bx_analytics_tab_pages"><![CDATA[Pages]]></string_add>
+	<string_add name="_bx_analytics_tab_platforms"><![CDATA[Platforms]]></string_add>
+	<string_add name="_bx_analytics_tab_referrer_urls"><![CDATA[Referrer URLs]]></string_add>
+	<string_add name="_bx_analytics_tab_referrers"><![CDATA[Referrers]]></string_add>
+	<string_add name="_bx_analytics_tab_regions"><![CDATA[Regions]]></string_add>
+	<string_add name="_bx_analytics_tab_retention_members"><![CDATA[Members]]></string_add>
+	<string_add name="_bx_analytics_tab_retention_visitors"><![CDATA[All visitors]]></string_add>
+	<string_add name="_bx_analytics_tab_revenue_campaigns"><![CDATA[Campaign]]></string_add>
+	<string_add name="_bx_analytics_tab_revenue_first_touch"><![CDATA[First visit from]]></string_add>
+	<string_add name="_bx_analytics_tab_revenue_last_touch"><![CDATA[Last visit from]]></string_add>
+	<string_add name="_bx_analytics_tab_revenue_platforms"><![CDATA[Platform]]></string_add>
+	<string_add name="_bx_analytics_tab_revenue_providers"><![CDATA[Payment provider]]></string_add>
+	<string_add name="_bx_analytics_tab_revenue_sources"><![CDATA[Source]]></string_add>
+	<string_add name="_bx_analytics_tab_screens"><![CDATA[Screen sizes]]></string_add>
+	<string_add name="_bx_analytics_tab_titles"><![CDATA[Page titles]]></string_add>
+	<string_add name="_bx_analytics_tab_utm_campaign"><![CDATA[UTM campaign]]></string_add>
+	<string_add name="_bx_analytics_tab_utm_content"><![CDATA[UTM content]]></string_add>
+	<string_add name="_bx_analytics_tab_utm_medium"><![CDATA[UTM medium]]></string_add>
+	<string_add name="_bx_analytics_tab_utm_source"><![CDATA[UTM source]]></string_add>
+	<string_add name="_bx_analytics_tab_utm_term"><![CDATA[UTM term]]></string_add>
+	<string_add name="_bx_analytics_tab_vitals_path"><![CDATA[Paths]]></string_add>
+	<string_add name="_bx_analytics_tab_vitals_route"><![CDATA[Routes]]></string_add>
+	<string_add name="_bx_analytics_txt_error"><![CDATA[Error]]></string_add>
+	<string_add name="_bx_analytics_txt_error_actions"><![CDATA[Change status]]></string_add>
+	<string_add name="_bx_analytics_txt_error_all_time"><![CDATA[All occurrences]]></string_add>
+	<string_add name="_bx_analytics_txt_error_http_status"><![CDATA[HTTP status]]></string_add>
+	<string_add name="_bx_analytics_txt_error_kind"><![CDATA[Kind]]></string_add>
+	<string_add name="_bx_analytics_txt_error_latest"><![CDATA[Latest occurrences]]></string_add>
+	<string_add name="_bx_analytics_txt_error_loading"><![CDATA[Couldn't load this report. Reload the page to try again.]]></string_add>
+	<string_add name="_bx_analytics_txt_error_mark_ignored"><![CDATA[Ignore]]></string_add>
+	<string_add name="_bx_analytics_txt_error_mark_open"><![CDATA[Reopen]]></string_add>
+	<string_add name="_bx_analytics_txt_error_mark_resolved"><![CDATA[Mark resolved]]></string_add>
+	<string_add name="_bx_analytics_txt_error_missing"><![CDATA[This error is no longer stored.]]></string_add>
+	<string_add name="_bx_analytics_txt_error_no_stack"><![CDATA[No stack trace came with this error.]]></string_add>
+	<string_add name="_bx_analytics_txt_error_overflow"><![CDATA[Other errors: more new kinds in an hour than the limit]]></string_add>
+	<string_add name="_bx_analytics_txt_error_report"><![CDATA[Couldn't load this report.]]></string_add>
+	<string_add name="_bx_analytics_txt_error_source"><![CDATA[Where]]></string_add>
+	<string_add name="_bx_analytics_txt_error_stack"><![CDATA[Stack trace]]></string_add>
+	<string_add name="_bx_analytics_txt_error_stack_hidden"><![CDATA[A stack trace can show what a visitor typed or opened, so only those who may see visitor details can read it.]]></string_add>
+	<string_add name="_bx_analytics_txt_error_status"><![CDATA[Status]]></string_add>
+	<string_add name="_bx_analytics_txt_event"><![CDATA[Event]]></string_add>
+	<string_add name="_bx_analytics_txt_item_n"><![CDATA[Item #{0}]]></string_add>
+	<string_add name="_bx_analytics_txt_journey_from"><![CDATA[Where visitors went from {0}]]></string_add>
+	<string_add name="_bx_analytics_txt_journeys_truncated"><![CDATA[This period has too many pageviews to follow them all; the latest visits are shown.]]></string_add>
+	<string_add name="_bx_analytics_txt_key_figures"><![CDATA[Key figures]]></string_add>
+	<string_add name="_bx_analytics_txt_last_seen"><![CDATA[Last seen]]></string_add>
+	<string_add name="_bx_analytics_txt_latest_pageviews"><![CDATA[Latest pageviews]]></string_add>
+	<string_add name="_bx_analytics_txt_left_site"><![CDATA[Left]]></string_add>
+	<string_add name="_bx_analytics_txt_lifetime_value"><![CDATA[Lifetime value]]></string_add>
+	<string_add name="_bx_analytics_txt_location"><![CDATA[Location]]></string_add>
+	<string_add name="_bx_analytics_txt_map_label"><![CDATA[Map of visitors by location]]></string_add>
+	<string_add name="_bx_analytics_txt_map_unknown"><![CDATA[{0} visitors from unknown locations are not on the map.]]></string_add>
+	<string_add name="_bx_analytics_txt_match_hint"><![CDATA[A page path starts with / or *, and * stands for any characters: *view-post* matches the post page on the site and in the apps. An action is an event name such as signup, or name@module for one module, for example content_add@bx_posts.]]></string_add>
+	<string_add name="_bx_analytics_txt_member"><![CDATA[member]]></string_add>
+	<string_add name="_bx_analytics_txt_member_name"><![CDATA[Member]]></string_add>
+	<string_add name="_bx_analytics_txt_metric"><![CDATA[Metric]]></string_add>
+	<string_add name="_bx_analytics_txt_minutes_ago"><![CDATA[{0} min ago]]></string_add>
+	<string_add name="_bx_analytics_txt_ms"><![CDATA[ms]]></string_add>
+	<string_add name="_bx_analytics_txt_n_samples"><![CDATA[{0} measurements]]></string_add>
+	<string_add name="_bx_analytics_txt_n_sessions"><![CDATA[{0} visits]]></string_add>
+	<string_add name="_bx_analytics_txt_name"><![CDATA[Name]]></string_add>
+	<string_add name="_bx_analytics_txt_next"><![CDATA[Next]]></string_add>
+	<string_add name="_bx_analytics_txt_no_bots"><![CDATA[No bots in this period]]></string_add>
+	<string_add name="_bx_analytics_txt_no_cancellations"><![CDATA[No cancellations in this period.]]></string_add>
+	<string_add name="_bx_analytics_txt_no_credit_orders"><![CDATA[No orders paid with credits in this period.]]></string_add>
+	<string_add name="_bx_analytics_txt_no_credits"><![CDATA[No credits spent in this period.]]></string_add>
+	<string_add name="_bx_analytics_txt_no_data"><![CDATA[No data for this period]]></string_add>
+	<string_add name="_bx_analytics_txt_no_data_yet"><![CDATA[No visits recorded yet]]></string_add>
+	<string_add name="_bx_analytics_txt_no_errors"><![CDATA[No errors in this period.]]></string_add>
+	<string_add name="_bx_analytics_txt_no_events"><![CDATA[No events in this period]]></string_add>
+	<string_add name="_bx_analytics_txt_no_funnels"><![CDATA[No funnels yet. A funnel shows how many visits go through a series of steps and where they drop off.]]></string_add>
+	<string_add name="_bx_analytics_txt_no_goals"><![CDATA[No goals yet. A goal counts the visits that reach a page or an action, such as a sign-up or a new post.]]></string_add>
+	<string_add name="_bx_analytics_txt_no_members"><![CDATA[No members visited in this period]]></string_add>
+	<string_add name="_bx_analytics_txt_no_properties"><![CDATA[This event has no properties]]></string_add>
+	<string_add name="_bx_analytics_txt_no_recent"><![CDATA[No pageviews in the last 30 minutes]]></string_add>
+	<string_add name="_bx_analytics_txt_no_revenue"><![CDATA[No revenue in this period.]]></string_add>
+	<string_add name="_bx_analytics_txt_no_subscriptions"><![CDATA[No paying subscriptions.]]></string_add>
+	<string_add name="_bx_analytics_txt_no_vitals"><![CDATA[No measurements in this period.]]></string_add>
+	<string_add name="_bx_analytics_txt_not_available"><![CDATA[n/a]]></string_add>
+	<string_add name="_bx_analytics_txt_not_enough_data"><![CDATA[Not enough data]]></string_add>
+	<string_add name="_bx_analytics_txt_now"><![CDATA[now]]></string_add>
+	<string_add name="_bx_analytics_txt_online_now"><![CDATA[online in the last {0} minutes]]></string_add>
+	<string_add name="_bx_analytics_txt_open_analytics"><![CDATA[Open analytics]]></string_add>
+	<string_add name="_bx_analytics_txt_p75"><![CDATA[75th percentile]]></string_add>
+	<string_add name="_bx_analytics_txt_page_of"><![CDATA[Page {0} of {1}]]></string_add>
+	<string_add name="_bx_analytics_txt_pageviews_30m"><![CDATA[Pageviews per minute over the last 30 minutes]]></string_add>
+	<string_add name="_bx_analytics_txt_pageviews_at"><![CDATA[{0} pageviews at {1}]]></string_add>
+	<string_add name="_bx_analytics_txt_path"><![CDATA[Path]]></string_add>
+	<string_add name="_bx_analytics_txt_pick_next"><![CDATA[Pick a page in step 2 to see where visitors went after it.]]></string_add>
+	<string_add name="_bx_analytics_txt_platform"><![CDATA[Platform]]></string_add>
+	<string_add name="_bx_analytics_txt_prev"><![CDATA[Previous]]></string_add>
+	<string_add name="_bx_analytics_txt_previous_period"><![CDATA[Previous period: {0}]]></string_add>
+	<string_add name="_bx_analytics_txt_properties"><![CDATA[Properties]]></string_add>
+	<string_add name="_bx_analytics_txt_range"><![CDATA[Period]]></string_add>
+	<string_add name="_bx_analytics_txt_rating"><![CDATA[Rating]]></string_add>
+	<string_add name="_bx_analytics_txt_raw_value"><![CDATA[Stored value]]></string_add>
+	<string_add name="_bx_analytics_txt_remove_filter"><![CDATA[Remove filter: {0}]]></string_add>
+	<string_add name="_bx_analytics_txt_retention_cookieless"><![CDATA[Guests get a new id every day in cookieless mode, so they all look like new visitors here. Switch guest ids to persistent in Settings to follow them.]]></string_add>
+	<string_add name="_bx_analytics_txt_revenue_as_recorded"><![CDATA[Amounts in {0} as recorded by the payment module, taxes included where the provider charged them. Orders paid with credits are counted under Credits.]]></string_add>
+	<string_add name="_bx_analytics_txt_revenue_commissions"><![CDATA[Commission invoices]]></string_add>
+	<string_add name="_bx_analytics_txt_revenue_credits_only"><![CDATA[The site takes payments in credits only, so revenue here is empty. See Credits for what credits paid for.]]></string_add>
+	<string_add name="_bx_analytics_txt_revenue_imported"><![CDATA[Imported (platform unknown)]]></string_add>
+	<string_add name="_bx_analytics_txt_revenue_no_campaign"><![CDATA[No campaign]]></string_add>
+	<string_add name="_bx_analytics_txt_revenue_no_payment"><![CDATA[No payment module is active, so no new payments are recorded.]]></string_add>
+	<string_add name="_bx_analytics_txt_revenue_no_rate"><![CDATA[Payments in another currency with no exchange rate, left out of the totals: {0}.]]></string_add>
+	<string_add name="_bx_analytics_txt_revenue_split"><![CDATA[Revenue split]]></string_add>
+	<string_add name="_bx_analytics_txt_revenue_untracked"><![CDATA[No tracked visit]]></string_add>
+	<string_add name="_bx_analytics_txt_route"><![CDATA[Route]]></string_add>
+	<string_add name="_bx_analytics_txt_sales_invoices"><![CDATA[Commission invoices issued in this period]]></string_add>
+	<string_add name="_bx_analytics_txt_sales_items"><![CDATA[Top items]]></string_add>
+	<string_add name="_bx_analytics_txt_sales_seller"><![CDATA[Seller]]></string_add>
+	<string_add name="_bx_analytics_txt_sales_sellers"><![CDATA[Top sellers]]></string_add>
+	<string_add name="_bx_analytics_txt_sales_vendor_count"><![CDATA[{0} vendors]]></string_add>
+	<string_add name="_bx_analytics_txt_samples"><![CDATA[Measurements]]></string_add>
+	<string_add name="_bx_analytics_txt_save"><![CDATA[Save]]></string_add>
+	<string_add name="_bx_analytics_txt_sbs_churn_no_base"><![CDATA[No paying subscriptions at the start of the period]]></string_add>
+	<string_add name="_bx_analytics_txt_sbs_movement"><![CDATA[New, renewed and cancelled]]></string_add>
+	<string_add name="_bx_analytics_txt_sbs_no_reason"><![CDATA[No reason given]]></string_add>
+	<string_add name="_bx_analytics_txt_sbs_plan"><![CDATA[Plan]]></string_add>
+	<string_add name="_bx_analytics_txt_sbs_plans"><![CDATA[Plans]]></string_add>
+	<string_add name="_bx_analytics_txt_sbs_reason"><![CDATA[Reason]]></string_add>
+	<string_add name="_bx_analytics_txt_sbs_reasons"><![CDATA[Why members cancel]]></string_add>
+	<string_add name="_bx_analytics_txt_sbs_trial_mrr"><![CDATA[{0} a month once paid]]></string_add>
+	<string_add name="_bx_analytics_txt_setup_actions_off"><![CDATA[Site actions are not recorded (Settings > Track site actions)]]></string_add>
+	<string_add name="_bx_analytics_txt_setup_actions_on"><![CDATA[Site actions are recorded (Settings > Track site actions)]]></string_add>
+	<string_add name="_bx_analytics_txt_setup_alerts"><![CDATA[Recorded from these alerts and from new content of every content module:]]></string_add>
+	<string_add name="_bx_analytics_txt_setup_api"><![CDATA[Any other client sends hits as JSON to the collect service with an API key (Studio > API). The payload is described in BxAnalyticsCollector.]]></string_add>
+	<string_add name="_bx_analytics_txt_setup_app_version"><![CDATA[Latest app version]]></string_add>
+	<string_add name="_bx_analytics_txt_setup_check"><![CDATA[Check this browser]]></string_add>
+	<string_add name="_bx_analytics_txt_setup_check_hint"><![CDATA[See whether your own visits to the site are recorded, and what the server sees of this browser. The check stores nothing.]]></string_add>
+	<string_add name="_bx_analytics_txt_setup_check_title"><![CDATA[Check this browser]]></string_add>
+	<string_add name="_bx_analytics_txt_setup_col_status"><![CDATA[Status]]></string_add>
+	<string_add name="_bx_analytics_txt_setup_core"><![CDATA[Site actions are recorded on the server, on the platform of the request that did them. Core is for cron and other jobs without a request.]]></string_add>
+	<string_add name="_bx_analytics_txt_setup_core_web"><![CDATA[Automatic: the tracker script is added to every page of the classic site.]]></string_add>
+	<string_add name="_bx_analytics_txt_setup_core_web_exclusions"><![CDATA[Not recorded: bots, members of excluded levels, browsers that ask not to be tracked (when the site respects it), excluded addresses and paths. Studio pages have no tracker.]]></string_add>
+	<string_add name="_bx_analytics_txt_setup_curl"><![CDATA[Example request]]></string_add>
+	<string_add name="_bx_analytics_txt_setup_events"><![CDATA[Custom events]]></string_add>
+	<string_add name="_bx_analytics_txt_setup_events_classic"><![CDATA[Classic site (JavaScript)]]></string_add>
+	<string_add name="_bx_analytics_txt_setup_events_hint"><![CDATA[Names are up to 64 letters, digits, spaces and _ . : / -. Properties are short text, numbers or true and false, up to 2 KB in all.]]></string_add>
+	<string_add name="_bx_analytics_txt_setup_events_neo"><![CDATA[NEO web and apps]]></string_add>
+	<string_add name="_bx_analytics_txt_setup_events_server"><![CDATA[Server (PHP)]]></string_add>
+	<string_add name="_bx_analytics_txt_setup_events_warning"><![CDATA[In server code call track, not collect: collect records the server's own address and user agent as an API client.]]></string_add>
+	<string_add name="_bx_analytics_txt_setup_hits_24h"><![CDATA[Hits, 24 h]]></string_add>
+	<string_add name="_bx_analytics_txt_setup_injection_off"><![CDATA[The tracker is not added to pages: its injection (bx_analytics_tracker) is off]]></string_add>
+	<string_add name="_bx_analytics_txt_setup_injection_on"><![CDATA[The tracker is added to pages]]></string_add>
+	<string_add name="_bx_analytics_txt_setup_install"><![CDATA[What each client needs]]></string_add>
+	<string_add name="_bx_analytics_txt_setup_last_hit"><![CDATA[Last hit]]></string_add>
+	<string_add name="_bx_analytics_txt_setup_neo"><![CDATA[Built in: NEO reads its settings from get_tracker_config and sends hits to the collect service. Their last hits are in the table above.]]></string_add>
+	<string_add name="_bx_analytics_txt_setup_neo_access"><![CDATA[API access by key or by origin is on]]></string_add>
+	<string_add name="_bx_analytics_txt_setup_neo_api"><![CDATA[The API is on]]></string_add>
+	<string_add name="_bx_analytics_txt_setup_neo_title"><![CDATA[NEO web and apps]]></string_add>
+	<string_add name="_bx_analytics_txt_setup_never"><![CDATA[Never]]></string_add>
+	<string_add name="_bx_analytics_txt_setup_none"><![CDATA[None]]></string_add>
+	<string_add name="_bx_analytics_txt_setup_open_settings"><![CDATA[Open settings]]></string_add>
+	<string_add name="_bx_analytics_txt_setup_platform_off"><![CDATA[{0} is not tracked (Settings > Platforms)]]></string_add>
+	<string_add name="_bx_analytics_txt_setup_platform_on"><![CDATA[{0} is tracked]]></string_add>
+	<string_add name="_bx_analytics_txt_setup_sessions_7d"><![CDATA[Visits, 7 days]]></string_add>
+	<string_add name="_bx_analytics_txt_setup_status"><![CDATA[Tracker status]]></string_add>
+	<string_add name="_bx_analytics_txt_setup_status_nodata"><![CDATA[No data]]></string_add>
+	<string_add name="_bx_analytics_txt_setup_status_off"><![CDATA[Off]]></string_add>
+	<string_add name="_bx_analytics_txt_setup_status_quiet"><![CDATA[Quiet]]></string_add>
+	<string_add name="_bx_analytics_txt_setup_status_receiving"><![CDATA[Receiving]]></string_add>
+	<string_add name="_bx_analytics_txt_setup_test"><![CDATA[Send test event]]></string_add>
+	<string_add name="_bx_analytics_txt_setup_test_dropped"><![CDATA[The test event was not stored: this browser isn't tracked. Check this browser to see why.]]></string_add>
+	<string_add name="_bx_analytics_txt_setup_test_stored"><![CDATA[The test event was stored: it is in Events as analytics_setup_test, and the status table is updated.]]></string_add>
+	<string_add name="_bx_analytics_txt_setup_test_title"><![CDATA[Analytics setup test]]></string_add>
+	<string_add name="_bx_analytics_txt_setup_test_warning"><![CDATA[The test event goes through the real collector, so it adds a visit from you to the reports.]]></string_add>
+	<string_add name="_bx_analytics_txt_setup_tracking_off"><![CDATA[Tracking is off: nothing is recorded on any platform.]]></string_add>
+	<string_add name="_bx_analytics_txt_setup_visitors_24h"><![CDATA[Visitors, 24 h]]></string_add>
+	<string_add name="_bx_analytics_txt_share"><![CDATA[Share]]></string_add>
+	<string_add name="_bx_analytics_txt_sort"><![CDATA[Order]]></string_add>
+	<string_add name="_bx_analytics_txt_sort_asc"><![CDATA[Lowest first]]></string_add>
+	<string_add name="_bx_analytics_txt_sort_desc"><![CDATA[Highest first]]></string_add>
+	<string_add name="_bx_analytics_txt_source"><![CDATA[Source]]></string_add>
+	<string_add name="_bx_analytics_txt_start_page"><![CDATA[Start page]]></string_add>
+	<string_add name="_bx_analytics_txt_started"><![CDATA[Started]]></string_add>
+	<string_add name="_bx_analytics_txt_step"><![CDATA[Step {0}]]></string_add>
+	<string_add name="_bx_analytics_txt_step_match"><![CDATA[Step {0}: page path or action name]]></string_add>
+	<string_add name="_bx_analytics_txt_step_type"><![CDATA[Step {0}: what the visitor does]]></string_add>
+	<string_add name="_bx_analytics_txt_threshold_good"><![CDATA[Good up to]]></string_add>
+	<string_add name="_bx_analytics_txt_threshold_poor"><![CDATA[Poor above]]></string_add>
+	<string_add name="_bx_analytics_txt_time"><![CDATA[Time]]></string_add>
+	<string_add name="_bx_analytics_txt_time_spent"><![CDATA[Time spent]]></string_add>
+	<string_add name="_bx_analytics_txt_timeline"><![CDATA[Timeline]]></string_add>
+	<string_add name="_bx_analytics_txt_top_paths"><![CDATA[Most common paths]]></string_add>
+	<string_add name="_bx_analytics_txt_delete"><![CDATA[Delete]]></string_add>
+	<string_add name="_bx_analytics_txt_deleted_member"><![CDATA[Deleted member]]></string_add>
+	<string_add name="_bx_analytics_txt_device"><![CDATA[Devices]]></string_add>
+	<string_add name="_bx_analytics_txt_device_all"><![CDATA[All devices]]></string_add>
+	<string_add name="_bx_analytics_txt_device_desktop"><![CDATA[Computers]]></string_add>
+	<string_add name="_bx_analytics_txt_device_mobile"><![CDATA[Phones and tablets]]></string_add>
+	<string_add name="_bx_analytics_txt_dimension"><![CDATA[Break down by]]></string_add>
+	<string_add name="_bx_analytics_txt_dims_event"><![CDATA[Pages]]></string_add>
+	<string_add name="_bx_analytics_txt_dims_session"><![CDATA[Sessions]]></string_add>
+	<string_add name="_bx_analytics_txt_direct"><![CDATA[Direct / none]]></string_add>
+	<string_add name="_bx_analytics_txt_download_csv"><![CDATA[Download CSV]]></string_add>
+	<string_add name="_bx_analytics_txt_dropped"><![CDATA[{0} dropped off]]></string_add>
+	<string_add name="_bx_analytics_txt_duration"><![CDATA[Duration]]></string_add>
+	<string_add name="_bx_analytics_txt_duration_h"><![CDATA[h]]></string_add>
+	<string_add name="_bx_analytics_txt_duration_m"><![CDATA[m]]></string_add>
+	<string_add name="_bx_analytics_txt_duration_s"><![CDATA[s]]></string_add>
+	<string_add name="_bx_analytics_txt_edit"><![CDATA[Edit]]></string_add>
+	<string_add name="_bx_analytics_txt_edit_funnel"><![CDATA[Edit funnel]]></string_add>
+	<string_add name="_bx_analytics_txt_edit_goal"><![CDATA[Edit goal]]></string_add>
+	<string_add name="_bx_analytics_txt_visitor"><![CDATA[Visitor]]></string_add>
+	<string_add name="_bx_analytics_txt_vital_distribution"><![CDATA[Good {0} · needs improvement {1} · poor {2}]]></string_add>
+	<string_add name="_bx_analytics_txt_vital_thresholds"><![CDATA[Good up to {0}, poor above {1}]]></string_add>
+	<string_add name="_bx_analytics_txt_vitals_metrics"><![CDATA[Metrics: pick one for the chart and the tables]]></string_add>
+	<string_add name="_bx_analytics_txt_vitals_pages"><![CDATA[Pages]]></string_add>
+	<string_add name="_bx_analytics_txt_week_n"><![CDATA[Week {0}]]></string_add>
+	<string_add name="_bx_analytics_txt_trend"><![CDATA[Trend]]></string_add>
+	<string_add name="_bx_analytics_txt_unknown"><![CDATA[(none)]]></string_add>
+	<string_add name="_bx_analytics_txt_crash_free_apps"><![CDATA[iOS and Android apps]]></string_add>
+	<string_add name="_bx_analytics_txt_credits_all_platforms"><![CDATA[Credits figures are for all platforms: the modules that keep credits don't record where a member was.]]></string_add>
+	<string_add name="_bx_analytics_txt_credits_liability"><![CDATA[Worth {0}]]></string_add>
+	<string_add name="_bx_analytics_txt_credits_none"><![CDATA[No installed module keeps credits.]]></string_add>
+	<string_add name="_bx_analytics_txt_credits_not_in_ledger"><![CDATA[Credits were spent on checkouts the revenue ledger doesn't have yet, so they aren't listed by what they paid for. Importing past payments adds them.]]></string_add>
+	<string_add name="_bx_analytics_txt_credits_spenders"><![CDATA[Top spenders]]></string_add>
+	<string_add name="_bx_analytics_txt_credits_spent"><![CDATA[Credits spent]]></string_add>
+	<string_add name="_bx_analytics_txt_credits_spent_on"><![CDATA[What credits paid for]]></string_add>
+	<string_add name="_bx_analytics_txt_credits_value"><![CDATA[Value]]></string_add>
+	<string_add name="_bx_analytics_txt_csv_truncated"><![CDATA[The download has the first {0} of {1} rows.]]></string_add>
+	<string_add name="_bx_analytics_txt_dash_blocks"><![CDATA[Blocks]]></string_add>
+	<string_add name="_bx_analytics_txt_dash_col_actions"><![CDATA[Actions]]></string_add>
+	<string_add name="_bx_analytics_txt_dash_col_page"><![CDATA[Page]]></string_add>
+	<string_add name="_bx_analytics_txt_dash_create"><![CDATA[New analytics page]]></string_add>
+	<string_add name="_bx_analytics_txt_dash_create_btn"><![CDATA[Create page]]></string_add>
+	<string_add name="_bx_analytics_txt_dash_create_failed"><![CDATA[The page could not be created. Enter a title of up to 100 characters and try again.]]></string_add>
+	<string_add name="_bx_analytics_txt_dash_create_hint"><![CDATA[The page gets the filter bar, key figures and top lists, shown on the site and in NEO, and opens in the page builder.]]></string_add>
+	<string_add name="_bx_analytics_txt_dash_disable_warning"><![CDATA[Pages created here belong to Analytics: disabling the module removes them with their blocks.]]></string_add>
+	<string_add name="_bx_analytics_txt_dash_edit"><![CDATA[Edit]]></string_add>
+	<string_add name="_bx_analytics_txt_dash_edit_label"><![CDATA[Edit {0} in the page builder]]></string_add>
+	<string_add name="_bx_analytics_txt_dash_empty"><![CDATA[No site page has analytics blocks.]]></string_add>
+	<string_add name="_bx_analytics_txt_dash_intro"><![CDATA[Site pages with analytics blocks. Add blocks to any page in the page builder, or start a new analytics page here.]]></string_add>
+	<string_add name="_bx_analytics_txt_dash_neo_blocks"><![CDATA[In NEO]]></string_add>
+	<string_add name="_bx_analytics_txt_dash_neo_note"><![CDATA[In NEO counts the blocks NEO and the apps show. The others show on the classic site only.]]></string_add>
+	<string_add name="_bx_analytics_txt_dash_pages"><![CDATA[Pages with analytics blocks]]></string_add>
+	<string_add name="_bx_analytics_txt_dash_title"><![CDATA[Title]]></string_add>
+	<string_add name="_bx_analytics_txt_actions"><![CDATA[Events and actions]]></string_add>
+	<string_add name="_bx_analytics_txt_add_funnel"><![CDATA[Add funnel]]></string_add>
+	<string_add name="_bx_analytics_txt_add_goal"><![CDATA[Add goal]]></string_add>
+	<string_add name="_bx_analytics_txt_all_platforms"><![CDATA[All platforms]]></string_add>
+	<string_add name="_bx_analytics_txt_api_bad_params"><![CDATA[Parameters must be a JSON object, such as {"range": "7d"}.]]></string_add>
+	<string_add name="_bx_analytics_txt_api_col_accepts"><![CDATA[Accepts]]></string_add>
+	<string_add name="_bx_analytics_txt_api_col_param"><![CDATA[Parameter]]></string_add>
+	<string_add name="_bx_analytics_txt_api_common_params"><![CDATA[Filter parameters of every report]]></string_add>
+	<string_add name="_bx_analytics_txt_api_default"><![CDATA[Default: {0}.]]></string_add>
+	<string_add name="_bx_analytics_txt_api_disabled"><![CDATA[The API is off (Studio > API), so other clients can't call get_report. The playground still runs reports here.]]></string_add>
+	<string_add name="_bx_analytics_txt_api_docs"><![CDATA[What the parameters accept]]></string_add>
+	<string_add name="_bx_analytics_txt_api_intro"><![CDATA[Try get_report, the service NEO and other clients read reports from. It runs here as you, with your permissions, and answers with the same JSON as api.php.]]></string_add>
+	<string_add name="_bx_analytics_txt_api_meta"><![CDATA[Status {0}, {1} ms, {2}]]></string_add>
+	<string_add name="_bx_analytics_txt_api_neo"><![CDATA[Request of a NEO element]]></string_add>
+	<string_add name="_bx_analytics_txt_api_no_access"><![CDATA[API access by key and by origin are both off, so api.php refuses every client. The playground still runs reports here.]]></string_add>
+	<string_add name="_bx_analytics_txt_api_no_params"><![CDATA[This report has no parameters of its own.]]></string_add>
+	<string_add name="_bx_analytics_txt_api_params"><![CDATA[Parameters (JSON)]]></string_add>
+	<string_add name="_bx_analytics_txt_api_params_hint"><![CDATA[Filters and the report's own parameters, for example {"range": "30d", "platform": "next-web", "f": {"country": "US"}}.]]></string_add>
+	<string_add name="_bx_analytics_txt_api_report"><![CDATA[Report]]></string_add>
+	<string_add name="_bx_analytics_txt_api_report_params"><![CDATA[Parameters of the {0} report]]></string_add>
+	<string_add name="_bx_analytics_txt_api_response"><![CDATA[Response]]></string_add>
+	<string_add name="_bx_analytics_txt_api_run"><![CDATA[Run]]></string_add>
+	<string_add name="_bx_analytics_txt_api_running"><![CDATA[Running...]]></string_add>
+	<string_add name="_bx_analytics_txt_api_srv"><![CDATA[PHP]]></string_add>
+	<string_add name="_bx_analytics_txt_api_truncated"><![CDATA[Only the first 1 MB is shown.]]></string_add>
+	<string_add name="_bx_analytics_txt_api_type_date"><![CDATA[A date, YYYY-MM-DD, with the custom range.]]></string_add>
+	<string_add name="_bx_analytics_txt_api_type_dims"><![CDATA[Filters as an object of dimension and value. Dimensions: {0}.]]></string_add>
+	<string_add name="_bx_analytics_txt_api_type_enum"><![CDATA[One of: {0}.]]></string_add>
+	<string_add name="_bx_analytics_txt_api_type_enum_by"><![CDATA[Depends on {0}: {1}.]]></string_add>
+	<string_add name="_bx_analytics_txt_api_type_int"><![CDATA[A number, {0} or more.]]></string_add>
+	<string_add name="_bx_analytics_txt_api_type_int_range"><![CDATA[A number from {0} to {1}.]]></string_add>
+	<string_add name="_bx_analytics_txt_api_type_string"><![CDATA[Text.]]></string_add>
+	<string_add name="_bx_analytics_txt_api_url"><![CDATA[api.php URL (needs an API key or a registered origin)]]></string_add>
+	<string_add name="_bx_analytics_txt_back_errors"><![CDATA[All errors]]></string_add>
+	<string_add name="_bx_analytics_txt_back_events"><![CDATA[All events]]></string_add>
+	<string_add name="_bx_analytics_txt_back_members"><![CDATA[All members]]></string_add>
+	<string_add name="_bx_analytics_txt_back_sessions"><![CDATA[All sessions]]></string_add>
+	<string_add name="_bx_analytics_txt_bot"><![CDATA[Bot]]></string_add>
+	<string_add name="_bx_analytics_txt_bot_pages"><![CDATA[Most requested pages]]></string_add>
+	<string_add name="_bx_analytics_txt_cancel"><![CDATA[Cancel]]></string_add>
+	<string_add name="_bx_analytics_txt_chart_bots"><![CDATA[Bot requests per day by category]]></string_add>
+	<string_add name="_bx_analytics_txt_chart_credits"><![CDATA[Credits over time]]></string_add>
+	<string_add name="_bx_analytics_txt_chart_error"><![CDATA[Occurrences of {0} over time]]></string_add>
+	<string_add name="_bx_analytics_txt_chart_event"><![CDATA[{0} over time]]></string_add>
+	<string_add name="_bx_analytics_txt_chart_label"><![CDATA[Chart of the selected figure over the period]]></string_add>
+	<string_add name="_bx_analytics_txt_chart_mrr"><![CDATA[Monthly recurring revenue over time]]></string_add>
+	<string_add name="_bx_analytics_txt_chart_query"><![CDATA[{0} over time for the first {1} rows of the table]]></string_add>
+	<string_add name="_bx_analytics_txt_chart_revenue"><![CDATA[Revenue over time]]></string_add>
+	<string_add name="_bx_analytics_txt_chart_visitors_7d"><![CDATA[Unique visitors over the last 7 days]]></string_add>
+	<string_add name="_bx_analytics_txt_chart_vital"><![CDATA[{0}: 75th percentile over time]]></string_add>
+	<string_add name="_bx_analytics_txt_check_bot"><![CDATA[This browser identifies as a bot, so it is counted with the bots.]]></string_add>
+	<string_add name="_bx_analytics_txt_check_cross_site"><![CDATA[The request came from another site.]]></string_add>
+	<string_add name="_bx_analytics_txt_check_dnt"><![CDATA[This browser asks not to be tracked (Do Not Track or Global Privacy Control), and the site respects it.]]></string_add>
+	<string_add name="_bx_analytics_txt_check_excluded_ip"><![CDATA[Your address is excluded (Settings > Excluded addresses).]]></string_add>
+	<string_add name="_bx_analytics_txt_check_excluded_level"><![CDATA[Your membership level is excluded (Settings > Excluded levels).]]></string_add>
+	<string_add name="_bx_analytics_txt_check_excluded_path"><![CDATA[The home page is on an excluded path (Settings > Excluded paths).]]></string_add>
+	<string_add name="_bx_analytics_txt_check_geo"><![CDATA[Country]]></string_add>
+	<string_add name="_bx_analytics_txt_check_geo_header"><![CDATA[{1}, from the {0} header]]></string_add>
+	<string_add name="_bx_analytics_txt_check_geo_none"><![CDATA[No country header in this request]]></string_add>
+	<string_add name="_bx_analytics_txt_check_geo_off"><![CDATA[Not read: the location source is off]]></string_add>
+	<string_add name="_bx_analytics_txt_check_ip"><![CDATA[Address recorded]]></string_add>
+	<string_add name="_bx_analytics_txt_check_link_members_off"><![CDATA[Visits are recorded but not tied to your account: linking members to visits is off.]]></string_add>
+	<string_add name="_bx_analytics_txt_check_no_hits"><![CDATA[The page address is not one of the site's own.]]></string_add>
+	<string_add name="_bx_analytics_txt_check_not_tracked"><![CDATA[Visits from this browser are not recorded]]></string_add>
+	<string_add name="_bx_analytics_txt_check_platform_off"><![CDATA[The classic site is not among the tracked platforms (Settings > Platforms).]]></string_add>
+	<string_add name="_bx_analytics_txt_check_rate_limited"><![CDATA[Your address has sent more than the limits allow (Settings > Hits per minute, New visits per hour). Its hits are dropped until the next minute, or new visits until the next hour.]]></string_add>
+	<string_add name="_bx_analytics_txt_check_remote_addr"><![CDATA[Connection from (REMOTE_ADDR)]]></string_add>
+	<string_add name="_bx_analytics_txt_check_tracked"><![CDATA[Visits from this browser are recorded]]></string_add>
+	<string_add name="_bx_analytics_txt_check_tracking_off"><![CDATA[Tracking is off.]]></string_add>
+	<string_add name="_bx_analytics_txt_cohort"><![CDATA[First visit, week of]]></string_add>
+	<string_add name="_bx_analytics_txt_conversions"><![CDATA[{0} visits, {1} visitors]]></string_add>
+	<string_add name="_bx_analytics_txt_copied"><![CDATA[Copied]]></string_add>
+	<string_add name="_bx_analytics_txt_copy"><![CDATA[Copy]]></string_add>
+	<string_add name="_bx_analytics_txt_fatal"><![CDATA[Fatal]]></string_add>
+	<string_add name="_bx_analytics_txt_filter_by"><![CDATA[Show only {0}]]></string_add>
+	<string_add name="_bx_analytics_txt_first_seen"><![CDATA[First seen]]></string_add>
+	<string_add name="_bx_analytics_txt_funnel_delete_confirm"><![CDATA[Delete the funnel "{0}"?]]></string_add>
+	<string_add name="_bx_analytics_txt_funnel_hint"><![CDATA[Fill in 2 to 6 steps in the order visitors take them. A visit counts for a step when it reaches that step after the previous one. Each step is a page path (*view-post* for the post page on every platform) or an action (signup, content_add@bx_posts).]]></string_add>
+	<string_add name="_bx_analytics_txt_funnel_invalid"><![CDATA[Enter a name and 2 to 6 steps. Page paths start with / (or *); action names use letters, digits and _ . : / -]]></string_add>
+	<string_add name="_bx_analytics_txt_goal_badge_event"><![CDATA[Action]]></string_add>
+	<string_add name="_bx_analytics_txt_goal_badge_page"><![CDATA[Page]]></string_add>
+	<string_add name="_bx_analytics_txt_goal_delete_confirm"><![CDATA[Delete the goal "{0}"?]]></string_add>
+	<string_add name="_bx_analytics_txt_goal_invalid"><![CDATA[Enter a name and a page path that starts with / (or *), or an action name of letters, digits and _ . : / -]]></string_add>
+	<string_add name="_bx_analytics_txt_goal_limit"><![CDATA[There can be up to {0} goals. Delete one to add another.]]></string_add>
+	<string_add name="_bx_analytics_txt_goal_match"><![CDATA[Page path or action name]]></string_add>
+	<string_add name="_bx_analytics_txt_goal_type"><![CDATA[Completed when a visitor]]></string_add>
+	<string_add name="_bx_analytics_txt_goal_type_event"><![CDATA[Does an action]]></string_add>
+	<string_add name="_bx_analytics_txt_goal_type_page"><![CDATA[Views a page]]></string_add>
+	<string_add name="_bx_analytics_txt_guest"><![CDATA[Guest]]></string_add>
+	<string_add name="_bx_analytics_txt_import_again"><![CDATA[Import again]]></string_add>
+	<string_add name="_bx_analytics_txt_import_confirm"><![CDATA[Read every past payment again? Payments that are already here are skipped.]]></string_add>
+	<string_add name="_bx_analytics_txt_import_continue"><![CDATA[Continue now]]></string_add>
+	<string_add name="_bx_analytics_txt_import_done"><![CDATA[Past payments are imported: {0} in all.]]></string_add>
+	<string_add name="_bx_analytics_txt_import_hint"><![CDATA[The import runs every hour until it is done. Refunded orders can't be imported, because the payment module deletes them, and imported payments have no platform or visit.]]></string_add>
+	<string_add name="_bx_analytics_txt_import_running"><![CDATA[Importing past payments: {0} imported so far.]]></string_add>
+	<string_add name="_bx_analytics_txt_import_title"><![CDATA[Past payments]]></string_add>
+	<string_add name="_bx_analytics_acl_action_manage"><![CDATA[Manage analytics goals and funnels]]></string_add>
+	<string_add name="_bx_analytics_acl_action_view_people"><![CDATA[View members in analytics]]></string_add>
+	<string_add name="_bx_analytics_acl_action_view_revenue"><![CDATA[View analytics revenue]]></string_add>
+	<string_add name="_bx_analytics_action_comment"><![CDATA[Comment]]></string_add>
+	<string_add name="_bx_analytics_action_connection"><![CDATA[Connection]]></string_add>
+	<string_add name="_bx_analytics_action_content_add"><![CDATA[Content created]]></string_add>
+	<string_add name="_bx_analytics_action_login"><![CDATA[Login]]></string_add>
+	<string_add name="_bx_analytics_action_profile_add"><![CDATA[Profile created]]></string_add>
+	<string_add name="_bx_analytics_action_purchase"><![CDATA[Purchase]]></string_add>
+	<string_add name="_bx_analytics_action_renewal"><![CDATA[Renewal]]></string_add>
+	<string_add name="_bx_analytics_action_signup"><![CDATA[Sign-up]]></string_add>
+	<string_add name="_bx_analytics_action_trial_start"><![CDATA[Trial started]]></string_add>
+	<string_add name="_bx_analytics_action_vote"><![CDATA[Reaction]]></string_add>
+</resources>

+ 224 - 0
modules/boonex/analytics/updates/15.0.1_15.0.2/install/sql/enable.sql

@@ -0,0 +1,224 @@
+SET @sName = 'bx_analytics';
+
+-- SETTINGS
+SET @iTypeId = (SELECT `id` FROM `sys_options_types` WHERE `name`=@sName LIMIT 1);
+
+UPDATE `sys_options_categories` SET `caption`='_bx_analytics_adm_stg_cpt_category_content', `order`='4' WHERE `name`='bx_analytics_general';
+
+DELETE FROM `sys_options_categories` WHERE `name`='bx_analytics_tracking';
+INSERT INTO `sys_options_categories` (`type_id`, `name`, `caption`, `hidden`, `order`) VALUES(@iTypeId, 'bx_analytics_tracking', '_bx_analytics_adm_stg_cpt_category_tracking', 0, 1);
+SET @iCategoryId = LAST_INSERT_ID();
+
+DELETE FROM `sys_options` WHERE `name` IN ('bx_analytics_tracking', 'bx_analytics_platforms', 'bx_analytics_session_timeout', 'bx_analytics_online_window', 'bx_analytics_ignore_params', 'bx_analytics_track_bots', 'bx_analytics_track_actions', 'bx_analytics_limit_hits', 'bx_analytics_limit_visits');
+INSERT INTO `sys_options` (`category_id`, `name`, `caption`, `info`, `value`, `type`, `extra`, `check`, `check_params`, `check_error`, `order`) VALUES
+(@iCategoryId, 'bx_analytics_tracking', '_bx_analytics_adm_stg_cpt_tracking', '_bx_analytics_adm_stg_inf_tracking', 'on', 'checkbox', '', '', '', '', 1),
+(@iCategoryId, 'bx_analytics_platforms', '_bx_analytics_adm_stg_cpt_platforms', '_bx_analytics_adm_stg_inf_platforms', 'core-web,next-web,rn-ios,rn-android,api,core', 'list', 'a:2:{s:6:"module";s:12:"bx_analytics";s:6:"method";s:21:"get_options_platforms";}', '', '', '', 2),
+(@iCategoryId, 'bx_analytics_session_timeout', '_bx_analytics_adm_stg_cpt_session_timeout', '_bx_analytics_adm_stg_inf_session_timeout', '30', 'digit', '', '', '', '', 3),
+(@iCategoryId, 'bx_analytics_online_window', '_bx_analytics_adm_stg_cpt_online_window', '_bx_analytics_adm_stg_inf_online_window', '5', 'digit', '', '', '', '', 4),
+(@iCategoryId, 'bx_analytics_ignore_params', '_bx_analytics_adm_stg_cpt_ignore_params', '_bx_analytics_adm_stg_inf_ignore_params', 'range, tz, platform, metric, bucket, from, to, jfrom, f[*], tab_*, page_*, q_*', 'text', '', '', '', '', 5),
+(@iCategoryId, 'bx_analytics_track_bots', '_bx_analytics_adm_stg_cpt_track_bots', '_bx_analytics_adm_stg_inf_track_bots', 'on', 'checkbox', '', '', '', '', 6),
+(@iCategoryId, 'bx_analytics_track_actions', '_bx_analytics_adm_stg_cpt_track_actions', '_bx_analytics_adm_stg_inf_track_actions', 'on', 'checkbox', '', '', '', '', 7),
+(@iCategoryId, 'bx_analytics_limit_hits', '_bx_analytics_adm_stg_cpt_limit_hits', '_bx_analytics_adm_stg_inf_limit_hits', '300', 'digit', '', '', '', '', 8),
+(@iCategoryId, 'bx_analytics_limit_visits', '_bx_analytics_adm_stg_cpt_limit_visits', '_bx_analytics_adm_stg_inf_limit_visits', '100', 'digit', '', '', '', '', 9);
+
+DELETE FROM `sys_options_categories` WHERE `name`='bx_analytics_privacy';
+INSERT INTO `sys_options_categories` (`type_id`, `name`, `caption`, `hidden`, `order`) VALUES(@iTypeId, 'bx_analytics_privacy', '_bx_analytics_adm_stg_cpt_category_privacy', 0, 2);
+SET @iCategoryId = LAST_INSERT_ID();
+
+DELETE FROM `sys_options` WHERE `name` IN ('bx_analytics_identity', 'bx_analytics_link_members', 'bx_analytics_respect_dnt', 'bx_analytics_exclude_levels', 'bx_analytics_exclude_ips', 'bx_analytics_exclude_paths', 'bx_analytics_trusted_proxies', 'bx_analytics_geo_source', 'bx_analytics_retention_days');
+INSERT INTO `sys_options` (`category_id`, `name`, `caption`, `info`, `value`, `type`, `extra`, `check`, `check_params`, `check_error`, `order`) VALUES
+(@iCategoryId, 'bx_analytics_identity', '_bx_analytics_adm_stg_cpt_identity', '_bx_analytics_adm_stg_inf_identity', 'cookieless', 'select', 'a:2:{s:6:"module";s:12:"bx_analytics";s:6:"method";s:20:"get_options_identity";}', '', '', '', 1),
+(@iCategoryId, 'bx_analytics_link_members', '_bx_analytics_adm_stg_cpt_link_members', '_bx_analytics_adm_stg_inf_link_members', 'on', 'checkbox', '', '', '', '', 2),
+(@iCategoryId, 'bx_analytics_respect_dnt', '_bx_analytics_adm_stg_cpt_respect_dnt', '_bx_analytics_adm_stg_inf_respect_dnt', 'on', 'checkbox', '', '', '', '', 3),
+(@iCategoryId, 'bx_analytics_exclude_levels', '_bx_analytics_adm_stg_cpt_exclude_levels', '_bx_analytics_adm_stg_inf_exclude_levels', '', 'list', 'a:4:{s:6:"module";s:6:"system";s:6:"method";s:15:"get_memberships";s:6:"params";a:0:{}s:5:"class";s:18:"TemplAuditServices";}', '', '', '', 4),
+(@iCategoryId, 'bx_analytics_exclude_ips', '_bx_analytics_adm_stg_cpt_exclude_ips', '_bx_analytics_adm_stg_inf_exclude_ips', '', 'text', '', '', '', '', 5),
+(@iCategoryId, 'bx_analytics_exclude_paths', '_bx_analytics_adm_stg_cpt_exclude_paths', '_bx_analytics_adm_stg_inf_exclude_paths', '', 'text', '', '', '', '', 6),
+(@iCategoryId, 'bx_analytics_trusted_proxies', '_bx_analytics_adm_stg_cpt_trusted_proxies', '_bx_analytics_adm_stg_inf_trusted_proxies', '', 'text', '', '', '', '', 7),
+(@iCategoryId, 'bx_analytics_geo_source', '_bx_analytics_adm_stg_cpt_geo_source', '_bx_analytics_adm_stg_inf_geo_source', 'headers', 'select', 'a:2:{s:6:"module";s:12:"bx_analytics";s:6:"method";s:22:"get_options_geo_source";}', '', '', '', 8),
+(@iCategoryId, 'bx_analytics_retention_days', '_bx_analytics_adm_stg_cpt_retention_days', '_bx_analytics_adm_stg_inf_retention_days', '400', 'digit', '', '', '', '', 9);
+
+DELETE FROM `sys_options_categories` WHERE `name`='bx_analytics_health';
+INSERT INTO `sys_options_categories` (`type_id`, `name`, `caption`, `hidden`, `order`) VALUES(@iTypeId, 'bx_analytics_health', '_bx_analytics_adm_stg_cpt_category_health', 0, 3);
+SET @iCategoryId = LAST_INSERT_ID();
+
+DELETE FROM `sys_options` WHERE `name` IN ('bx_analytics_track_errors', 'bx_analytics_errors_ignore', 'bx_analytics_track_vitals', 'bx_analytics_vitals_sample', 'bx_analytics_vitals_retention_days');
+INSERT INTO `sys_options` (`category_id`, `name`, `caption`, `info`, `value`, `type`, `extra`, `check`, `check_params`, `check_error`, `order`) VALUES
+(@iCategoryId, 'bx_analytics_track_errors', '_bx_analytics_adm_stg_cpt_track_errors', '_bx_analytics_adm_stg_inf_track_errors', 'on', 'checkbox', '', '', '', '', 1),
+(@iCategoryId, 'bx_analytics_errors_ignore', '_bx_analytics_adm_stg_cpt_errors_ignore', '_bx_analytics_adm_stg_inf_errors_ignore', 'ResizeObserver loop*\nScript error.*\n*Non-Error promise rejection*', 'text', '', '', '', '', 2),
+(@iCategoryId, 'bx_analytics_track_vitals', '_bx_analytics_adm_stg_cpt_track_vitals', '_bx_analytics_adm_stg_inf_track_vitals', 'on', 'checkbox', '', '', '', '', 3),
+(@iCategoryId, 'bx_analytics_vitals_sample', '_bx_analytics_adm_stg_cpt_vitals_sample', '_bx_analytics_adm_stg_inf_vitals_sample', '100', 'digit', '', '', '', '', 4),
+(@iCategoryId, 'bx_analytics_vitals_retention_days', '_bx_analytics_adm_stg_cpt_vitals_retention_days', '_bx_analytics_adm_stg_inf_vitals_retention_days', '90', 'digit', '', '', '', '', 5);
+
+DELETE FROM `sys_options_categories` WHERE `name`='bx_analytics_system';
+INSERT INTO `sys_options_categories` (`type_id`, `name`, `caption`, `hidden`, `order`) VALUES(@iTypeId, 'bx_analytics_system', '_bx_analytics_adm_stg_cpt_category_system', 1, 5);
+SET @iCategoryId = LAST_INSERT_ID();
+
+DELETE FROM `sys_options` WHERE `name` IN ('bx_analytics_revenue_backfill', 'bx_analytics_revenue_backfill_sbs');
+INSERT INTO `sys_options` (`category_id`, `name`, `caption`, `value`, `type`, `extra`, `check`, `check_params`, `check_error`, `order`) VALUES
+(@iCategoryId, 'bx_analytics_revenue_backfill', '', '0', 'digit', '', '', '', '', 1),
+(@iCategoryId, 'bx_analytics_revenue_backfill_sbs', '', '', 'text', '', '', '', '', 2);
+
+-- PAGES & BLOCKS
+UPDATE `sys_objects_page` SET `layout_id`='12', `meta_robots`='noindex' WHERE `object`='bx_analytics_page';
+
+DELETE FROM `sys_objects_page` WHERE `object`='bx_analytics_revenue';
+INSERT INTO `sys_objects_page`(`object`, `title_system`, `title`, `module`, `layout_id`, `visible_for_levels`, `visible_for_levels_editable`, `uri`, `url`, `meta_description`, `meta_keywords`, `meta_robots`, `cache_lifetime`, `cache_editable`, `deletable`, `override_class_name`, `override_class_file`) VALUES
+('bx_analytics_revenue', '_bx_analytics_page_title_sys_revenue', '_bx_analytics_page_title_revenue', @sName, 5, 2147483647, 1, 'analytics-revenue', '', '', '', 'noindex', 0, 1, 0, 'BxAnalyticsPage', 'modules/boonex/analytics/classes/BxAnalyticsPage.php');
+
+DELETE FROM `sys_pages_blocks` WHERE `object`='bx_analytics_page' AND `title_system`='_bx_analytics_page_block_title_system_filters' AND `title`='_bx_analytics_page_block_title_filters';
+DELETE FROM `sys_pages_blocks` WHERE `object`='bx_analytics_page' AND `title_system`='_bx_analytics_page_block_title_system_overview' AND `title`='_bx_analytics_page_block_title_overview';
+DELETE FROM `sys_pages_blocks` WHERE `object`='bx_analytics_page' AND `title_system`='_bx_analytics_page_block_title_system_top_pages' AND `title`='_bx_analytics_page_block_title_top_pages';
+DELETE FROM `sys_pages_blocks` WHERE `object`='bx_analytics_page' AND `title_system`='_bx_analytics_page_block_title_system_top_sources' AND `title`='_bx_analytics_page_block_title_top_sources';
+DELETE FROM `sys_pages_blocks` WHERE `object`='bx_analytics_page' AND `title_system`='_bx_analytics_page_block_title_system_top_locations' AND `title`='_bx_analytics_page_block_title_top_locations';
+DELETE FROM `sys_pages_blocks` WHERE `object`='bx_analytics_page' AND `title_system`='_bx_analytics_page_block_title_system_top_devices' AND `title`='_bx_analytics_page_block_title_top_devices';
+DELETE FROM `sys_pages_blocks` WHERE `object`='bx_analytics_page' AND `title_system`='_bx_analytics_page_block_title_system_top_platforms' AND `title`='_bx_analytics_page_block_title_top_platforms';
+DELETE FROM `sys_pages_blocks` WHERE `object`='bx_analytics_page' AND `title_system`='_bx_analytics_page_block_title_system_canvas' AND `title`='_bx_analytics_page_block_title_canvas';
+DELETE FROM `sys_pages_blocks` WHERE `object`='bx_analytics_revenue';
+INSERT INTO `sys_pages_blocks` (`object`, `cell_id`, `module`, `title_system`, `title`, `designbox_id`, `visible_for_levels`, `type`, `content`, `deletable`, `copyable`, `active`, `order`) VALUES
+('bx_analytics_page', 2, @sName, '_bx_analytics_page_block_title_system_filters', '_bx_analytics_page_block_title_filters', 11, 2147483647, 'service', 'a:2:{s:6:"module";s:12:"bx_analytics";s:6:"method";s:17:"get_block_filters";}', 0, 1, 1, 1),
+('bx_analytics_page', 2, @sName, '_bx_analytics_page_block_title_system_overview', '_bx_analytics_page_block_title_overview', 11, 2147483647, 'service', 'a:2:{s:6:"module";s:12:"bx_analytics";s:6:"method";s:18:"get_block_overview";}', 0, 1, 1, 2),
+('bx_analytics_page', 2, @sName, '_bx_analytics_page_block_title_system_top_pages', '_bx_analytics_page_block_title_top_pages', 11, 2147483647, 'service', 'a:3:{s:6:"module";s:12:"bx_analytics";s:6:"method";s:13:"get_block_top";s:6:"params";a:1:{i:0;s:5:"pages";}}', 0, 1, 1, 3),
+('bx_analytics_page', 2, @sName, '_bx_analytics_page_block_title_system_top_sources', '_bx_analytics_page_block_title_top_sources', 11, 2147483647, 'service', 'a:3:{s:6:"module";s:12:"bx_analytics";s:6:"method";s:13:"get_block_top";s:6:"params";a:1:{i:0;s:7:"sources";}}', 0, 1, 1, 4),
+('bx_analytics_page', 3, @sName, '_bx_analytics_page_block_title_system_top_locations', '_bx_analytics_page_block_title_top_locations', 11, 2147483647, 'service', 'a:3:{s:6:"module";s:12:"bx_analytics";s:6:"method";s:13:"get_block_top";s:6:"params";a:1:{i:0;s:9:"locations";}}', 0, 1, 1, 1),
+('bx_analytics_page', 3, @sName, '_bx_analytics_page_block_title_system_top_devices', '_bx_analytics_page_block_title_top_devices', 11, 2147483647, 'service', 'a:3:{s:6:"module";s:12:"bx_analytics";s:6:"method";s:13:"get_block_top";s:6:"params";a:1:{i:0;s:7:"devices";}}', 0, 1, 1, 2),
+('bx_analytics_page', 3, @sName, '_bx_analytics_page_block_title_system_top_platforms', '_bx_analytics_page_block_title_top_platforms', 11, 2147483647, 'service', 'a:3:{s:6:"module";s:12:"bx_analytics";s:6:"method";s:13:"get_block_top";s:6:"params";a:1:{i:0;s:9:"platforms";}}', 0, 1, 1, 3),
+('bx_analytics_page', 4, @sName, '_bx_analytics_page_block_title_system_canvas', '_bx_analytics_page_block_title_canvas', 11, 2147483647, 'service', 'a:2:{s:6:"module";s:12:"bx_analytics";s:6:"method";s:10:"get_canvas";}', 0, 1, 1, 1),
+('bx_analytics_revenue', 1, @sName, '_bx_analytics_page_block_title_system_filters', '_bx_analytics_page_block_title_filters', 11, 2147483647, 'service', 'a:2:{s:6:"module";s:12:"bx_analytics";s:6:"method";s:17:"get_block_filters";}', 0, 1, 1, 1),
+('bx_analytics_revenue', 1, @sName, '_bx_analytics_page_block_title_system_revenue', '_bx_analytics_page_block_title_revenue', 11, 2147483647, 'service', 'a:3:{s:6:"module";s:12:"bx_analytics";s:6:"method";s:16:"get_block_report";s:6:"params";a:1:{i:0;s:7:"revenue";}}', 0, 1, 1, 2),
+('bx_analytics_revenue', 1, @sName, '_bx_analytics_page_block_title_system_subscriptions', '_bx_analytics_page_block_title_subscriptions', 11, 2147483647, 'service', 'a:3:{s:6:"module";s:12:"bx_analytics";s:6:"method";s:16:"get_block_report";s:6:"params";a:1:{i:0;s:13:"subscriptions";}}', 0, 1, 1, 3),
+('bx_analytics_revenue', 1, @sName, '_bx_analytics_page_block_title_system_sales', '_bx_analytics_page_block_title_sales', 11, 2147483647, 'service', 'a:3:{s:6:"module";s:12:"bx_analytics";s:6:"method";s:16:"get_block_report";s:6:"params";a:1:{i:0;s:5:"sales";}}', 0, 1, 1, 4),
+('bx_analytics_revenue', 1, @sName, '_bx_analytics_page_block_title_system_credits', '_bx_analytics_page_block_title_credits', 11, 2147483647, 'service', 'a:3:{s:6:"module";s:12:"bx_analytics";s:6:"method";s:16:"get_block_report";s:6:"params";a:1:{i:0;s:7:"credits";}}', 0, 1, 1, 5);
+
+DELETE FROM `sys_pages_blocks` WHERE `object`='' AND `module`=@sName AND `title_system`='_bx_analytics_page_block_title_system_filters' AND `title`='_bx_analytics_page_block_title_filters';
+DELETE FROM `sys_pages_blocks` WHERE `object`='' AND `module`=@sName AND `title_system`='_bx_analytics_page_block_title_system_overview' AND `title`='_bx_analytics_page_block_title_overview';
+DELETE FROM `sys_pages_blocks` WHERE `object`='' AND `module`=@sName AND `title_system`='_bx_analytics_page_block_title_system_top_pages' AND `title`='_bx_analytics_page_block_title_top_pages';
+DELETE FROM `sys_pages_blocks` WHERE `object`='' AND `module`=@sName AND `title_system`='_bx_analytics_page_block_title_system_top_sources' AND `title`='_bx_analytics_page_block_title_top_sources';
+DELETE FROM `sys_pages_blocks` WHERE `object`='' AND `module`=@sName AND `title_system`='_bx_analytics_page_block_title_system_top_locations' AND `title`='_bx_analytics_page_block_title_top_locations';
+DELETE FROM `sys_pages_blocks` WHERE `object`='' AND `module`=@sName AND `title_system`='_bx_analytics_page_block_title_system_top_devices' AND `title`='_bx_analytics_page_block_title_top_devices';
+DELETE FROM `sys_pages_blocks` WHERE `object`='' AND `module`=@sName AND `title_system`='_bx_analytics_page_block_title_system_top_platforms' AND `title`='_bx_analytics_page_block_title_top_platforms';
+DELETE FROM `sys_pages_blocks` WHERE `object`='' AND `module`=@sName AND `title_system`='_bx_analytics_page_block_title_system_table_pages' AND `title`='_bx_analytics_page_block_title_table_pages';
+DELETE FROM `sys_pages_blocks` WHERE `object`='' AND `module`=@sName AND `title_system`='_bx_analytics_page_block_title_system_table_sources' AND `title`='_bx_analytics_page_block_title_table_sources';
+DELETE FROM `sys_pages_blocks` WHERE `object`='' AND `module`=@sName AND `title_system`='_bx_analytics_page_block_title_system_realtime' AND `title`='_bx_analytics_page_block_title_realtime';
+DELETE FROM `sys_pages_blocks` WHERE `object`='' AND `module`=@sName AND `title_system`='_bx_analytics_page_block_title_system_map' AND `title`='_bx_analytics_page_block_title_map';
+DELETE FROM `sys_pages_blocks` WHERE `object`='' AND `module`=@sName AND `title_system`='_bx_analytics_page_block_title_system_bots' AND `title`='_bx_analytics_page_block_title_bots';
+DELETE FROM `sys_pages_blocks` WHERE `object`='' AND `module`=@sName AND `title_system`='_bx_analytics_page_block_title_system_events' AND `title`='_bx_analytics_page_block_title_events';
+DELETE FROM `sys_pages_blocks` WHERE `object`='' AND `module`=@sName AND `title_system`='_bx_analytics_page_block_title_system_sessions' AND `title`='_bx_analytics_page_block_title_sessions';
+DELETE FROM `sys_pages_blocks` WHERE `object`='' AND `module`=@sName AND `title_system`='_bx_analytics_page_block_title_system_users' AND `title`='_bx_analytics_page_block_title_users';
+DELETE FROM `sys_pages_blocks` WHERE `object`='' AND `module`=@sName AND `title_system`='_bx_analytics_page_block_title_system_journeys' AND `title`='_bx_analytics_page_block_title_journeys';
+DELETE FROM `sys_pages_blocks` WHERE `object`='' AND `module`=@sName AND `title_system`='_bx_analytics_page_block_title_system_retention' AND `title`='_bx_analytics_page_block_title_retention';
+DELETE FROM `sys_pages_blocks` WHERE `object`='' AND `module`=@sName AND `title_system`='_bx_analytics_page_block_title_system_goals' AND `title`='_bx_analytics_page_block_title_goals';
+DELETE FROM `sys_pages_blocks` WHERE `object`='' AND `module`=@sName AND `title_system`='_bx_analytics_page_block_title_system_funnels' AND `title`='_bx_analytics_page_block_title_funnels';
+DELETE FROM `sys_pages_blocks` WHERE `object`='' AND `module`=@sName AND `title_system`='_bx_analytics_page_block_title_system_revenue' AND `title`='_bx_analytics_page_block_title_revenue';
+DELETE FROM `sys_pages_blocks` WHERE `object`='' AND `module`=@sName AND `title_system`='_bx_analytics_page_block_title_system_subscriptions' AND `title`='_bx_analytics_page_block_title_subscriptions';
+DELETE FROM `sys_pages_blocks` WHERE `object`='' AND `module`=@sName AND `title_system`='_bx_analytics_page_block_title_system_sales' AND `title`='_bx_analytics_page_block_title_sales';
+DELETE FROM `sys_pages_blocks` WHERE `object`='' AND `module`=@sName AND `title_system`='_bx_analytics_page_block_title_system_credits' AND `title`='_bx_analytics_page_block_title_credits';
+DELETE FROM `sys_pages_blocks` WHERE `object`='' AND `module`=@sName AND `title_system`='_bx_analytics_page_block_title_system_errors' AND `title`='_bx_analytics_page_block_title_errors';
+DELETE FROM `sys_pages_blocks` WHERE `object`='' AND `module`=@sName AND `title_system`='_bx_analytics_page_block_title_system_performance' AND `title`='_bx_analytics_page_block_title_performance';
+DELETE FROM `sys_pages_blocks` WHERE `object`='' AND `module`=@sName AND `title_system`='_bx_analytics_page_block_title_system_query' AND `title`='_bx_analytics_page_block_title_query';
+DELETE FROM `sys_pages_blocks` WHERE `object`='' AND `module`=@sName AND `title_system`='_bx_analytics_page_block_title_system_canvas' AND `title`='_bx_analytics_page_block_title_canvas';
+INSERT INTO `sys_pages_blocks` (`object`, `cell_id`, `module`, `title_system`, `title`, `designbox_id`, `visible_for_levels`, `type`, `content`, `deletable`, `copyable`, `active`, `order`) VALUES
+('', 0, @sName, '_bx_analytics_page_block_title_system_filters', '_bx_analytics_page_block_title_filters', 11, 2147483647, 'service', 'a:2:{s:6:"module";s:12:"bx_analytics";s:6:"method";s:17:"get_block_filters";}', 0, 1, 1, 1),
+('', 0, @sName, '_bx_analytics_page_block_title_system_overview', '_bx_analytics_page_block_title_overview', 11, 2147483647, 'service', 'a:2:{s:6:"module";s:12:"bx_analytics";s:6:"method";s:18:"get_block_overview";}', 0, 1, 1, 2),
+('', 0, @sName, '_bx_analytics_page_block_title_system_top_pages', '_bx_analytics_page_block_title_top_pages', 11, 2147483647, 'service', 'a:3:{s:6:"module";s:12:"bx_analytics";s:6:"method";s:13:"get_block_top";s:6:"params";a:1:{i:0;s:5:"pages";}}', 0, 1, 1, 3),
+('', 0, @sName, '_bx_analytics_page_block_title_system_top_sources', '_bx_analytics_page_block_title_top_sources', 11, 2147483647, 'service', 'a:3:{s:6:"module";s:12:"bx_analytics";s:6:"method";s:13:"get_block_top";s:6:"params";a:1:{i:0;s:7:"sources";}}', 0, 1, 1, 4),
+('', 0, @sName, '_bx_analytics_page_block_title_system_top_locations', '_bx_analytics_page_block_title_top_locations', 11, 2147483647, 'service', 'a:3:{s:6:"module";s:12:"bx_analytics";s:6:"method";s:13:"get_block_top";s:6:"params";a:1:{i:0;s:9:"locations";}}', 0, 1, 1, 5),
+('', 0, @sName, '_bx_analytics_page_block_title_system_top_devices', '_bx_analytics_page_block_title_top_devices', 11, 2147483647, 'service', 'a:3:{s:6:"module";s:12:"bx_analytics";s:6:"method";s:13:"get_block_top";s:6:"params";a:1:{i:0;s:7:"devices";}}', 0, 1, 1, 6),
+('', 0, @sName, '_bx_analytics_page_block_title_system_top_platforms', '_bx_analytics_page_block_title_top_platforms', 11, 2147483647, 'service', 'a:3:{s:6:"module";s:12:"bx_analytics";s:6:"method";s:13:"get_block_top";s:6:"params";a:1:{i:0;s:9:"platforms";}}', 0, 1, 1, 7),
+('', 0, @sName, '_bx_analytics_page_block_title_system_table_pages', '_bx_analytics_page_block_title_table_pages', 11, 2147483647, 'service', 'a:3:{s:6:"module";s:12:"bx_analytics";s:6:"method";s:15:"get_block_table";s:6:"params";a:1:{i:0;s:5:"pages";}}', 0, 1, 1, 8),
+('', 0, @sName, '_bx_analytics_page_block_title_system_table_sources', '_bx_analytics_page_block_title_table_sources', 11, 2147483647, 'service', 'a:3:{s:6:"module";s:12:"bx_analytics";s:6:"method";s:15:"get_block_table";s:6:"params";a:1:{i:0;s:7:"sources";}}', 0, 1, 1, 9),
+('', 0, @sName, '_bx_analytics_page_block_title_system_realtime', '_bx_analytics_page_block_title_realtime', 11, 2147483647, 'service', 'a:3:{s:6:"module";s:12:"bx_analytics";s:6:"method";s:16:"get_block_report";s:6:"params";a:1:{i:0;s:8:"realtime";}}', 0, 1, 1, 10),
+('', 0, @sName, '_bx_analytics_page_block_title_system_map', '_bx_analytics_page_block_title_map', 11, 2147483647, 'service', 'a:3:{s:6:"module";s:12:"bx_analytics";s:6:"method";s:16:"get_block_report";s:6:"params";a:1:{i:0;s:3:"map";}}', 0, 1, 1, 11),
+('', 0, @sName, '_bx_analytics_page_block_title_system_bots', '_bx_analytics_page_block_title_bots', 11, 2147483647, 'service', 'a:3:{s:6:"module";s:12:"bx_analytics";s:6:"method";s:16:"get_block_report";s:6:"params";a:1:{i:0;s:4:"bots";}}', 0, 1, 1, 12),
+('', 0, @sName, '_bx_analytics_page_block_title_system_events', '_bx_analytics_page_block_title_events', 11, 2147483647, 'service', 'a:3:{s:6:"module";s:12:"bx_analytics";s:6:"method";s:16:"get_block_report";s:6:"params";a:1:{i:0;s:6:"events";}}', 0, 1, 1, 13),
+('', 0, @sName, '_bx_analytics_page_block_title_system_sessions', '_bx_analytics_page_block_title_sessions', 11, 2147483647, 'service', 'a:3:{s:6:"module";s:12:"bx_analytics";s:6:"method";s:16:"get_block_report";s:6:"params";a:1:{i:0;s:8:"sessions";}}', 0, 1, 1, 14),
+('', 0, @sName, '_bx_analytics_page_block_title_system_users', '_bx_analytics_page_block_title_users', 11, 2147483647, 'service', 'a:3:{s:6:"module";s:12:"bx_analytics";s:6:"method";s:16:"get_block_report";s:6:"params";a:1:{i:0;s:5:"users";}}', 0, 1, 1, 15),
+('', 0, @sName, '_bx_analytics_page_block_title_system_journeys', '_bx_analytics_page_block_title_journeys', 11, 2147483647, 'service', 'a:3:{s:6:"module";s:12:"bx_analytics";s:6:"method";s:16:"get_block_report";s:6:"params";a:1:{i:0;s:8:"journeys";}}', 0, 1, 1, 16),
+('', 0, @sName, '_bx_analytics_page_block_title_system_retention', '_bx_analytics_page_block_title_retention', 11, 2147483647, 'service', 'a:3:{s:6:"module";s:12:"bx_analytics";s:6:"method";s:16:"get_block_report";s:6:"params";a:1:{i:0;s:9:"retention";}}', 0, 1, 1, 17),
+('', 0, @sName, '_bx_analytics_page_block_title_system_goals', '_bx_analytics_page_block_title_goals', 11, 2147483647, 'service', 'a:3:{s:6:"module";s:12:"bx_analytics";s:6:"method";s:16:"get_block_report";s:6:"params";a:1:{i:0;s:5:"goals";}}', 0, 1, 1, 18),
+('', 0, @sName, '_bx_analytics_page_block_title_system_funnels', '_bx_analytics_page_block_title_funnels', 11, 2147483647, 'service', 'a:3:{s:6:"module";s:12:"bx_analytics";s:6:"method";s:16:"get_block_report";s:6:"params";a:1:{i:0;s:7:"funnels";}}', 0, 1, 1, 19),
+('', 0, @sName, '_bx_analytics_page_block_title_system_revenue', '_bx_analytics_page_block_title_revenue', 11, 2147483647, 'service', 'a:3:{s:6:"module";s:12:"bx_analytics";s:6:"method";s:16:"get_block_report";s:6:"params";a:1:{i:0;s:7:"revenue";}}', 0, 1, 1, 20),
+('', 0, @sName, '_bx_analytics_page_block_title_system_subscriptions', '_bx_analytics_page_block_title_subscriptions', 11, 2147483647, 'service', 'a:3:{s:6:"module";s:12:"bx_analytics";s:6:"method";s:16:"get_block_report";s:6:"params";a:1:{i:0;s:13:"subscriptions";}}', 0, 1, 1, 21),
+('', 0, @sName, '_bx_analytics_page_block_title_system_sales', '_bx_analytics_page_block_title_sales', 11, 2147483647, 'service', 'a:3:{s:6:"module";s:12:"bx_analytics";s:6:"method";s:16:"get_block_report";s:6:"params";a:1:{i:0;s:5:"sales";}}', 0, 1, 1, 22),
+('', 0, @sName, '_bx_analytics_page_block_title_system_credits', '_bx_analytics_page_block_title_credits', 11, 2147483647, 'service', 'a:3:{s:6:"module";s:12:"bx_analytics";s:6:"method";s:16:"get_block_report";s:6:"params";a:1:{i:0;s:7:"credits";}}', 0, 1, 1, 23),
+('', 0, @sName, '_bx_analytics_page_block_title_system_errors', '_bx_analytics_page_block_title_errors', 11, 2147483647, 'service', 'a:3:{s:6:"module";s:12:"bx_analytics";s:6:"method";s:16:"get_block_report";s:6:"params";a:1:{i:0;s:6:"errors";}}', 0, 1, 1, 24),
+('', 0, @sName, '_bx_analytics_page_block_title_system_performance', '_bx_analytics_page_block_title_performance', 11, 2147483647, 'service', 'a:3:{s:6:"module";s:12:"bx_analytics";s:6:"method";s:16:"get_block_report";s:6:"params";a:1:{i:0;s:11:"performance";}}', 0, 1, 1, 25),
+('', 0, @sName, '_bx_analytics_page_block_title_system_query', '_bx_analytics_page_block_title_query', 11, 2147483647, 'service', 'a:3:{s:6:"module";s:12:"bx_analytics";s:6:"method";s:16:"get_block_report";s:6:"params";a:1:{i:0;s:5:"query";}}', 0, 1, 1, 26),
+('', 0, @sName, '_bx_analytics_page_block_title_system_canvas', '_bx_analytics_page_block_title_canvas', 11, 2147483647, 'service', 'a:2:{s:6:"module";s:12:"bx_analytics";s:6:"method";s:10:"get_canvas";}', 0, 1, 1, 27);
+
+DELETE FROM `sys_pages_blocks` WHERE `object`='sys_std_dashboard' AND `title`='_bx_analytics_page_block_title_std_dash_traffic';
+SET @iStdDashboardOrder = (SELECT IFNULL(MAX(`order`), 0) + 1 FROM `sys_pages_blocks` WHERE `object` = 'sys_std_dashboard' AND `cell_id` = 1);
+INSERT INTO `sys_pages_blocks` (`object`, `cell_id`, `module`, `title_system`, `title`, `icon`, `designbox_id`, `class`, `tabs`, `async`, `visible_for_levels`, `type`, `content`, `deletable`, `copyable`, `active`, `order`) VALUES
+('sys_std_dashboard', 1, @sName, '', '_bx_analytics_page_block_title_std_dash_traffic', 'chart-line', 11, 'bx-dbd-block-medium', 0, 0, 2147483647, 'service', 'a:2:{s:6:"module";s:12:"bx_analytics";s:6:"method";s:28:"get_studio_dashboard_traffic";}', 0, 0, 1, @iStdDashboardOrder);
+
+-- MENUS
+UPDATE `sys_menu_items` SET `icon`='chart-line' WHERE `set_name`='sys_account_dashboard' AND `module`=@sName AND `name`='dashboard-analytics';
+DELETE FROM `sys_menu_items` WHERE `set_name`='sys_account_dashboard' AND `module`=@sName AND `name`='analytics-revenue';
+SET @iMoAccountDashboard = (SELECT IFNULL(MAX(`order`), 0) FROM `sys_menu_items` WHERE `set_name`='sys_account_dashboard' LIMIT 1);
+INSERT INTO `sys_menu_items` (`set_name`, `module`, `name`, `title_system`, `title`, `link`, `onclick`, `target`, `icon`, `addon`, `submenu_object`, `visible_for_levels`, `visibility_custom`, `active`, `copyable`, `editable`, `order`) VALUES
+('sys_account_dashboard', @sName, 'analytics-revenue', '_bx_analytics_item_title_system_revenue', '_bx_analytics_item_title_revenue', 'page.php?i=analytics-revenue', '', '', 'circle-dollar-sign', '', '', 2147483647, 'a:2:{s:6:"module";s:12:"bx_analytics";s:6:"method";s:20:"is_revenue_avaliable";}', 1, 0, 1, @iMoAccountDashboard + 1);
+
+-- ACL
+SET @iIdActionViewPeople = (SELECT `ID` FROM `sys_acl_actions` WHERE `Module`=@sName AND `Name`='view analytics people' LIMIT 1);
+DELETE FROM `sys_acl_matrix` WHERE `IDAction`=@iIdActionViewPeople;
+DELETE FROM `sys_acl_actions` WHERE `ID`=@iIdActionViewPeople;
+INSERT INTO `sys_acl_actions` (`Module`, `Name`, `AdditionalParamName`, `Title`, `Desc`, `Countable`, `DisabledForLevels`) VALUES
+(@sName, 'view analytics people', NULL, '_bx_analytics_acl_action_view_people', '', 0, 3);
+SET @iIdActionViewPeople = LAST_INSERT_ID();
+
+SET @iIdActionManage = (SELECT `ID` FROM `sys_acl_actions` WHERE `Module`=@sName AND `Name`='manage analytics' LIMIT 1);
+DELETE FROM `sys_acl_matrix` WHERE `IDAction`=@iIdActionManage;
+DELETE FROM `sys_acl_actions` WHERE `ID`=@iIdActionManage;
+INSERT INTO `sys_acl_actions` (`Module`, `Name`, `AdditionalParamName`, `Title`, `Desc`, `Countable`, `DisabledForLevels`) VALUES
+(@sName, 'manage analytics', NULL, '_bx_analytics_acl_action_manage', '', 0, 3);
+SET @iIdActionManage = LAST_INSERT_ID();
+
+SET @iIdActionViewRevenue = (SELECT `ID` FROM `sys_acl_actions` WHERE `Module`=@sName AND `Name`='view analytics revenue' LIMIT 1);
+DELETE FROM `sys_acl_matrix` WHERE `IDAction`=@iIdActionViewRevenue;
+DELETE FROM `sys_acl_actions` WHERE `ID`=@iIdActionViewRevenue;
+INSERT INTO `sys_acl_actions` (`Module`, `Name`, `AdditionalParamName`, `Title`, `Desc`, `Countable`, `DisabledForLevels`) VALUES
+(@sName, 'view analytics revenue', NULL, '_bx_analytics_acl_action_view_revenue', '', 0, 3);
+SET @iIdActionViewRevenue = LAST_INSERT_ID();
+
+SET @iAdministrator = 8;
+INSERT INTO `sys_acl_matrix` (`IDLevel`, `IDAction`) VALUES
+(@iAdministrator, @iIdActionViewPeople),
+(@iAdministrator, @iIdActionManage),
+(@iAdministrator, @iIdActionViewRevenue);
+
+-- INJECTIONS
+DELETE FROM `sys_injections` WHERE `name`='bx_analytics_tracker';
+INSERT INTO `sys_injections` (`name`, `page_index`, `key`, `type`, `data`, `replace`, `active`) VALUES
+('bx_analytics_tracker', 0, 'injection_footer', 'service', 'a:2:{s:6:"module";s:12:"bx_analytics";s:6:"method";s:16:"get_tracker_code";}', 0, 1);
+
+-- ALERTS
+SET @iHandler := (SELECT `id` FROM `sys_alerts_handlers` WHERE `name`=@sName LIMIT 1);
+DELETE FROM `sys_alerts` WHERE `handler_id`=@iHandler;
+DELETE FROM `sys_alerts_handlers` WHERE `id`=@iHandler;
+
+INSERT INTO `sys_alerts_handlers` (`name`, `class`, `file`, `service_call`) VALUES
+(@sName, 'BxAnalyticsAlertsResponse', 'modules/boonex/analytics/classes/BxAnalyticsAlertsResponse.php', '');
+SET @iHandler := LAST_INSERT_ID();
+
+INSERT INTO `sys_alerts` (`unit`, `action`, `handler_id`) VALUES
+('profile', 'delete', @iHandler),
+('system', 'enable', @iHandler),
+('system', 'disable', @iHandler),
+('account', 'add', @iHandler),
+('account', 'login', @iHandler),
+('profile', 'add', @iHandler),
+('system', 'connection_added', @iHandler),
+('comment', 'added', @iHandler),
+('vote', 'do', @iHandler),
+('system', 'register_payment', @iHandler),
+('system', 'refund_payment', @iHandler),
+('system', 'create_subscription', @iHandler),
+('system', 'prolong_subscription', @iHandler),
+('system', 'overdue_subscription', @iHandler),
+('system', 'cancel_subscription', @iHandler),
+('system', 'cancel_order', @iHandler),
+('system', 'finalize_checkout', @iHandler);
+
+-- CRON
+DELETE FROM `sys_cron_jobs` WHERE `name`='bx_analytics_pruning';
+INSERT INTO `sys_cron_jobs` (`name`, `time`, `class`, `file`, `service_call`) VALUES
+('bx_analytics_pruning', '15 * * * *', '', '', 'a:2:{s:6:"module";s:12:"bx_analytics";s:6:"method";s:7:"pruning";}');

+ 305 - 0
modules/boonex/analytics/updates/15.0.1_15.0.2/install/sql/install.sql

@@ -0,0 +1,305 @@
+SET @sName = 'bx_analytics';
+
+-- TABLES
+
+-- One row per visit: the visitor, the client it came from and how it arrived
+CREATE TABLE IF NOT EXISTS `bx_analytics_sessions` (
+  `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
+  `visitor` binary(16) NOT NULL,
+  `profile_id` int(11) unsigned NOT NULL DEFAULT '0',
+  `platform` varchar(16) NOT NULL DEFAULT '',
+  `app` varchar(64) NOT NULL DEFAULT '',
+  `app_version` varchar(32) NOT NULL DEFAULT '',
+  `started` int(11) unsigned NOT NULL DEFAULT '0',
+  `last_seen` int(11) unsigned NOT NULL DEFAULT '0',
+  `duration` int(11) unsigned NOT NULL DEFAULT '0',
+  `pageviews` smallint(5) unsigned NOT NULL DEFAULT '0',
+  `events` smallint(5) unsigned NOT NULL DEFAULT '0',
+  `errors` smallint(5) unsigned NOT NULL DEFAULT '0',
+  `bounce` tinyint(4) unsigned NOT NULL DEFAULT '1',
+  `hostname` varchar(128) NOT NULL DEFAULT '',
+  `entry_path` varchar(255) NOT NULL DEFAULT '',
+  `exit_path` varchar(255) NOT NULL DEFAULT '',
+  `referrer` varchar(255) NOT NULL DEFAULT '',
+  `referrer_host` varchar(128) NOT NULL DEFAULT '',
+  `channel` varchar(16) NOT NULL DEFAULT '',
+  `utm_source` varchar(128) NOT NULL DEFAULT '',
+  `utm_medium` varchar(128) NOT NULL DEFAULT '',
+  `utm_campaign` varchar(128) NOT NULL DEFAULT '',
+  `utm_term` varchar(128) NOT NULL DEFAULT '',
+  `utm_content` varchar(128) NOT NULL DEFAULT '',
+  `country` char(2) NOT NULL DEFAULT '',
+  `region` varchar(64) NOT NULL DEFAULT '',
+  `city` varchar(64) NOT NULL DEFAULT '',
+  `lat` decimal(4,1) DEFAULT NULL,
+  `lng` decimal(4,1) DEFAULT NULL,
+  `language` varchar(16) NOT NULL DEFAULT '',
+  `browser` varchar(32) NOT NULL DEFAULT '',
+  `browser_version` varchar(16) NOT NULL DEFAULT '',
+  `os` varchar(32) NOT NULL DEFAULT '',
+  `os_version` varchar(16) NOT NULL DEFAULT '',
+  `device` varchar(16) NOT NULL DEFAULT '',
+  `screen` varchar(16) NOT NULL DEFAULT '',
+  PRIMARY KEY (`id`),
+  -- the visitor's open visit; read in this order, retention cohorts need no sort
+  KEY `visitor` (`visitor`, `last_seen`, `started`, `platform`, `profile_id`),
+  -- covers the key figures and their chart (no row is read for them)
+  KEY `started` (`started`, `platform`, `visitor`, `profile_id`, `pageviews`, `bounce`, `duration`),
+  KEY `last_seen` (`last_seen`),
+  KEY `platform_seen` (`platform`, `last_seen`),
+  -- a member's visits; with the last hit and the platform it pages the members list alone
+  KEY `profile_id` (`profile_id`, `started`, `last_seen`, `platform`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+-- One row per pageview or event; visitor, profile and platform are copied from the session
+CREATE TABLE IF NOT EXISTS `bx_analytics_events` (
+  `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
+  `session_id` bigint(20) unsigned NOT NULL,
+  `visitor` binary(16) NOT NULL,
+  `profile_id` int(11) unsigned NOT NULL DEFAULT '0',
+  `platform` varchar(16) NOT NULL DEFAULT '',
+  `ts` int(11) unsigned NOT NULL DEFAULT '0',
+  `type` varchar(16) NOT NULL DEFAULT 'pageview',
+  `name` varchar(64) NOT NULL DEFAULT '',
+  `hostname` varchar(128) NOT NULL DEFAULT '',
+  `path` varchar(255) NOT NULL DEFAULT '',
+  `title` varchar(255) NOT NULL DEFAULT '',
+  `module` varchar(32) NOT NULL DEFAULT '',
+  `object_id` int(11) unsigned NOT NULL DEFAULT '0',
+  `context_id` int(11) unsigned NOT NULL DEFAULT '0',
+  `value` double NOT NULL DEFAULT '0',
+  `engaged` int(11) unsigned NOT NULL DEFAULT '0',
+  `props` text NOT NULL,
+  PRIMARY KEY (`id`),
+  KEY `ts` (`ts`, `type`, `platform`),
+  -- covers pages and the events list by type and period
+  KEY `type_ts` (`type`, `ts`, `platform`, `path`, `name`, `module`, `visitor`, `session_id`, `engaged`),
+  KEY `platform` (`platform`, `ts`),
+  KEY `session_id` (`session_id`, `ts`),
+  -- covers one page (a filter, a goal, a funnel step, the Query chart) over a period, on a platform too
+  KEY `path` (`type`, `path`, `ts`, `platform`, `session_id`, `visitor`, `engaged`),
+  -- the same for page titles: the titles tab, a title filter
+  KEY `title` (`type`, `title`, `ts`, `platform`, `session_id`, `visitor`, `engaged`),
+  KEY `name` (`type`, `name`, `ts`),
+  -- a site action about something: forgetting a deleted profile finds its creation by it
+  KEY `object` (`type`, `name`, `object_id`),
+  -- a member's events: forgetting a deleted profile, the member's actions in a period
+  KEY `profile_id` (`profile_id`, `ts`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+-- A random salt per day for cookieless visitor ids; cron deletes old ones
+CREATE TABLE IF NOT EXISTS `bx_analytics_salts` (
+  `day` date NOT NULL,
+  `salt` binary(32) NOT NULL,
+  PRIMARY KEY (`day`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+-- Rate limits of the public collector: hits per address and minute, new visits per address
+-- and hour. The key is a keyed hash of the kind and the address with the day's salt, never
+-- the address; `bucket` is the minute's or hour's start. Cron deletes buckets over 2 hours old
+CREATE TABLE IF NOT EXISTS `bx_analytics_limits` (
+  `key` binary(8) NOT NULL,
+  `bucket` int(11) unsigned NOT NULL,
+  `hits` int(11) unsigned NOT NULL DEFAULT '0',
+  PRIMARY KEY (`key`, `bucket`),
+  KEY `bucket` (`bucket`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+
+-- Bots and agents per day: crawlers, AI fetchers, link previews and scripts don't run the
+-- tracker, so they are counted on the server and kept as daily totals, not as visits
+CREATE TABLE IF NOT EXISTS `bx_analytics_bots` (
+  `day` date NOT NULL,
+  `bot` varchar(64) NOT NULL,
+  `category` varchar(16) NOT NULL DEFAULT '',
+  `platform` varchar(16) NOT NULL DEFAULT '',
+  `hits` int(11) unsigned NOT NULL DEFAULT '0',
+  `last_seen` int(11) unsigned NOT NULL DEFAULT '0',
+  PRIMARY KEY (`day`, `bot`, `platform`),
+  KEY `category` (`category`, `day`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+-- Pages bots asked for, per day and bot category
+CREATE TABLE IF NOT EXISTS `bx_analytics_bot_pages` (
+  `day` date NOT NULL,
+  `category` varchar(16) NOT NULL DEFAULT '',
+  `path_hash` binary(8) NOT NULL,
+  `path` varchar(255) NOT NULL DEFAULT '',
+  `hits` int(11) unsigned NOT NULL DEFAULT '0',
+  PRIMARY KEY (`day`, `category`, `path_hash`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+-- Goals: a page (path, * for any characters) or an event (name, or name@module) that
+-- counts as a conversion
+CREATE TABLE IF NOT EXISTS `bx_analytics_goals` (
+  `id` int(11) unsigned NOT NULL AUTO_INCREMENT,
+  `name` varchar(128) NOT NULL DEFAULT '',
+  `type` varchar(16) NOT NULL DEFAULT 'page',
+  `pattern` varchar(255) NOT NULL DEFAULT '',
+  `added` int(11) unsigned NOT NULL DEFAULT '0',
+  `order` int(11) NOT NULL DEFAULT '0',
+  PRIMARY KEY (`id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+-- Funnels: steps as a JSON list of {type, pattern}, matched in order within a visit
+CREATE TABLE IF NOT EXISTS `bx_analytics_funnels` (
+  `id` int(11) unsigned NOT NULL AUTO_INCREMENT,
+  `name` varchar(128) NOT NULL DEFAULT '',
+  `steps` text NOT NULL,
+  `added` int(11) unsigned NOT NULL DEFAULT '0',
+  `order` int(11) NOT NULL DEFAULT '0',
+  PRIMARY KEY (`id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+-- Revenue ledger: one row per money movement the payment module reports (a sale, a renewal,
+-- a refund or a reversal of a processed order), with the amount in the site's default
+-- currency and the platform and traffic source of the buyer. Pruning never touches it.
+-- A subscription's trial is registered at the full price although nothing was charged: those
+-- rows have the kind `trial` and no revenue report reads them. A transaction has one charge
+-- row (sale, renewal or trial) and at most one negation (refund or reversal), hence the
+-- unique key on the payment module, transaction and kind.
+CREATE TABLE IF NOT EXISTS `bx_analytics_revenue` (
+  `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
+  `ts` int(11) unsigned NOT NULL DEFAULT '0',
+  `kind` varchar(16) NOT NULL DEFAULT '',
+  `cash` tinyint(4) unsigned NOT NULL DEFAULT '1',
+  `payment` varchar(32) NOT NULL DEFAULT '',
+  `pending_id` int(11) unsigned NOT NULL DEFAULT '0',
+  `transaction_id` int(11) unsigned NOT NULL DEFAULT '0',
+  `order` varchar(32) NOT NULL DEFAULT '',
+  `module_id` int(11) unsigned NOT NULL DEFAULT '0',
+  `module` varchar(32) NOT NULL DEFAULT '',
+  `item_id` int(11) unsigned NOT NULL DEFAULT '0',
+  `item_count` int(11) unsigned NOT NULL DEFAULT '0',
+  `seller_id` int(11) unsigned NOT NULL DEFAULT '0',
+  `author_id` int(11) unsigned NOT NULL DEFAULT '0',
+  `client_id` int(11) unsigned NOT NULL DEFAULT '0',
+  `provider` varchar(32) NOT NULL DEFAULT '',
+  `type` varchar(16) NOT NULL DEFAULT '',
+  `amount` decimal(14,4) NOT NULL DEFAULT '0',
+  `currency` varchar(4) NOT NULL DEFAULT '',
+  `rate` double NOT NULL DEFAULT '1',
+  `amount_default` decimal(14,4) DEFAULT NULL,
+  `platform` varchar(16) NOT NULL DEFAULT '',
+  `session_id` bigint(20) unsigned NOT NULL DEFAULT '0',
+  `first_channel` varchar(16) NOT NULL DEFAULT '',
+  `first_referrer_host` varchar(128) NOT NULL DEFAULT '',
+  `first_utm_campaign` varchar(128) NOT NULL DEFAULT '',
+  `last_channel` varchar(16) NOT NULL DEFAULT '',
+  `last_referrer_host` varchar(128) NOT NULL DEFAULT '',
+  `last_utm_campaign` varchar(128) NOT NULL DEFAULT '',
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `movement` (`payment`, `transaction_id`, `kind`),
+  KEY `ts` (`cash`, `ts`, `platform`),
+  KEY `pending` (`payment`, `pending_id`),
+  KEY `module` (`module_id`, `ts`),
+  KEY `seller` (`seller_id`, `ts`),
+  KEY `client` (`client_id`, `ts`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+-- Subscriptions as the ledger sees them: plan, price per period and per month (MRR) in the
+-- default currency, when they started, were first and last paid, and were cancelled
+CREATE TABLE IF NOT EXISTS `bx_analytics_subscriptions` (
+  `payment` varchar(32) NOT NULL DEFAULT '',
+  `pending_id` int(11) unsigned NOT NULL DEFAULT '0',
+  `subscription_id` varchar(32) NOT NULL DEFAULT '',
+  `client_id` int(11) unsigned NOT NULL DEFAULT '0',
+  `seller_id` int(11) unsigned NOT NULL DEFAULT '0',
+  `module_id` int(11) unsigned NOT NULL DEFAULT '0',
+  `module` varchar(32) NOT NULL DEFAULT '',
+  `item_id` int(11) unsigned NOT NULL DEFAULT '0',
+  `provider` varchar(32) NOT NULL DEFAULT '',
+  `period` int(11) unsigned NOT NULL DEFAULT '1',
+  `period_unit` varchar(16) NOT NULL DEFAULT '',
+  `trial` int(11) unsigned NOT NULL DEFAULT '0',
+  `amount_default` decimal(14,4) DEFAULT NULL,
+  `mrr_default` decimal(14,4) DEFAULT NULL,
+  `status` varchar(16) NOT NULL DEFAULT '',
+  `started` int(11) unsigned NOT NULL DEFAULT '0',
+  `first_paid` int(11) unsigned NOT NULL DEFAULT '0',
+  `last_paid` int(11) unsigned NOT NULL DEFAULT '0',
+  `paid_count` int(11) unsigned NOT NULL DEFAULT '0',
+  `canceled` int(11) unsigned NOT NULL DEFAULT '0',
+  `reason` varchar(255) NOT NULL DEFAULT '',
+  `platform` varchar(16) NOT NULL DEFAULT '',
+  PRIMARY KEY (`payment`, `pending_id`),
+  KEY `paid` (`first_paid`, `canceled`),
+  KEY `started` (`started`),
+  KEY `canceled` (`canceled`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+-- Client errors grouped by fingerprint (kind, normalized message, top frame, HTTP status),
+-- with a sample stack. `created` is server time, for the limit of new groups an hour;
+-- `first_seen` and `last_seen` are when the errors happened.
+CREATE TABLE IF NOT EXISTS `bx_analytics_errors` (
+  `id` int(11) unsigned NOT NULL AUTO_INCREMENT,
+  `fingerprint` binary(16) NOT NULL,
+  `kind` varchar(16) NOT NULL DEFAULT 'js',
+  `message` varchar(255) NOT NULL DEFAULT '',
+  `source` varchar(255) NOT NULL DEFAULT '',
+  `line` int(11) unsigned NOT NULL DEFAULT '0',
+  `col` int(11) unsigned NOT NULL DEFAULT '0',
+  `status_code` smallint(5) unsigned NOT NULL DEFAULT '0',
+  `stack` text NOT NULL,
+  `stack_ts` int(11) unsigned NOT NULL DEFAULT '0',
+  `first_seen` int(11) unsigned NOT NULL DEFAULT '0',
+  `last_seen` int(11) unsigned NOT NULL DEFAULT '0',
+  `occurrences` int(11) unsigned NOT NULL DEFAULT '0',
+  `fatal` tinyint(4) unsigned NOT NULL DEFAULT '0',
+  `status` varchar(16) NOT NULL DEFAULT 'open',
+  `status_changed` int(11) unsigned NOT NULL DEFAULT '0',
+  `created` int(11) unsigned NOT NULL DEFAULT '0',
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `fingerprint` (`fingerprint`),
+  KEY `created` (`created`),
+  KEY `last_seen` (`last_seen`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+-- One row per error report from a client (repeats on one page counted in `repeats`). The
+-- client's columns are copied, so an error outside a visit (`session_id` 0) still has them.
+CREATE TABLE IF NOT EXISTS `bx_analytics_error_hits` (
+  `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
+  `error_id` int(11) unsigned NOT NULL,
+  `ts` int(11) unsigned NOT NULL DEFAULT '0',
+  `session_id` bigint(20) unsigned NOT NULL DEFAULT '0',
+  `visitor` binary(16) NOT NULL,
+  `profile_id` int(11) unsigned NOT NULL DEFAULT '0',
+  `platform` varchar(16) NOT NULL DEFAULT '',
+  `app_version` varchar(32) NOT NULL DEFAULT '',
+  `browser` varchar(32) NOT NULL DEFAULT '',
+  `os` varchar(32) NOT NULL DEFAULT '',
+  `device` varchar(16) NOT NULL DEFAULT '',
+  `country` char(2) NOT NULL DEFAULT '',
+  `path` varchar(255) NOT NULL DEFAULT '',
+  `route` varchar(128) NOT NULL DEFAULT '',
+  `repeats` smallint(5) unsigned NOT NULL DEFAULT '1',
+  `fatal` tinyint(4) unsigned NOT NULL DEFAULT '0',
+  PRIMARY KEY (`id`),
+  KEY `ts` (`ts`, `platform`),
+  KEY `error_id` (`error_id`, `ts`),
+  KEY `profile_id` (`profile_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+-- Web vitals and app timings: one row per sampled page view or screen, NULL for what wasn't
+-- measured; milliseconds, CLS in thousandths. No visitor or member is stored.
+CREATE TABLE IF NOT EXISTS `bx_analytics_vitals` (
+  `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
+  `ts` int(11) unsigned NOT NULL DEFAULT '0',
+  `session_id` bigint(20) unsigned NOT NULL DEFAULT '0',
+  `platform` varchar(16) NOT NULL DEFAULT '',
+  `device` varchar(16) NOT NULL DEFAULT '',
+  `path` varchar(255) NOT NULL DEFAULT '',
+  `route` varchar(128) NOT NULL DEFAULT '',
+  `nav` varchar(16) NOT NULL DEFAULT '',
+  `ttfb` mediumint(8) unsigned DEFAULT NULL,
+  `fcp` mediumint(8) unsigned DEFAULT NULL,
+  `lcp` mediumint(8) unsigned DEFAULT NULL,
+  `inp` mediumint(8) unsigned DEFAULT NULL,
+  `cls` smallint(5) unsigned DEFAULT NULL,
+  `screen_load` mediumint(8) unsigned DEFAULT NULL,
+  `app_start` mediumint(8) unsigned DEFAULT NULL,
+  PRIMARY KEY (`id`),
+  KEY `ts` (`ts`, `platform`),
+  KEY `route` (`route`, `ts`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+

+ 29 - 0
modules/boonex/analytics/updates/15.0.1_15.0.2/source/classes/BxAnalyticsAlertsResponse.php

@@ -0,0 +1,29 @@
+<?php defined('BX_DOL') or die('hack attempt');
+/**
+ * Copyright (c) UNA, Inc - https://una.io
+ * MIT License - https://opensource.org/licenses/MIT
+ *
+ * @defgroup    Analytics Analytics
+ * @ingroup     UnaModules
+ *
+ * @{
+ */
+
+class BxAnalyticsAlertsResponse extends BxDolAlertsResponse
+{
+    protected $_sModule;
+
+    public function __construct()
+    {
+        parent::__construct();
+
+        $this->_sModule = 'bx_analytics';
+    }
+
+    public function response($oAlert)
+    {
+        BxDolModule::getInstance($this->_sModule)->onAlert($oAlert);
+    }
+}
+
+/** @} */

+ 1316 - 0
modules/boonex/analytics/updates/15.0.1_15.0.2/source/classes/BxAnalyticsCollector.php

@@ -0,0 +1,1316 @@
+<?php defined('BX_DOL') or die('hack attempt');
+/**
+ * Copyright (c) UNA, Inc - https://una.io
+ * MIT License - https://opensource.org/licenses/MIT
+ *
+ * @defgroup    Analytics Analytics
+ * @ingroup     UnaModules
+ *
+ * @{
+ */
+
+/**
+ * Takes a batch of hits from a tracker, decides whether to keep them, adds what the server
+ * knows (visitor id, member, country, browser, traffic source) and writes sessions and events.
+ *
+ * Payload, as JSON from the classic tracker, NEO or any API client:
+ *   platform     core-web | next-web | rn-ios | rn-android | api
+ *   app, app_version   bundle id or client name and its version (apps)
+ *   referrer     document.referrer or the app's referrer, for a new session
+ *   language     navigator.language
+ *   screen       "1440x900"
+ *   vid          random visitor id kept by the client, used in persistent identity mode only;
+ *                site requests carry it in the bx_analytics_vid cookie or the X-Analytics-Vid
+ *                header, so the site's own actions join the device's visit
+ *   os, os_version, device   sent by native apps, which have no browser user agent
+ *   hits         list of {type: pageview|event|leave, url, title, name, value, props, engaged,
+ *                module, object_id, context_id, age}; one hit may also be given at the top level
+ *
+ * Health hits (see BxAnalyticsModule::serviceGetTrackerConfig for the client settings: send
+ * them only when `errors` is true, and vitals only for `vitals_sample` percent of page loads):
+ *
+ *   {type: 'error', url, kind, message, source, line, col, stack, component_stack, fatal,
+ *    repeats, status, age, deferred}
+ *     kind       js (uncaught error), promise (unhandled rejection), react (error boundary),
+ *                native (RN global handler), http (a request answered 500 or more), resource
+ *                (a script or style that failed to load)
+ *     message    the error's message, up to 500 characters (255 are kept); for http
+ *                "HTTP 502 /api.php" (no query string), or empty with `status` set
+ *     source, line, col   the script and position, when known
+ *     stack      the stack as the runtime prints it (V8/Hermes "at fn (file:1:2)" or
+ *                Firefox/Safari "fn@file:1:2"), up to 8000 characters; component_stack is
+ *                React's component stack of an error boundary
+ *     fatal      1 when the app or page could not go on (a root boundary, isFatal on native)
+ *     repeats    how many times it happened on this page or screen, 1 to 100 (deduplicate
+ *                on the client and count instead of sending each)
+ *     status     HTTP status, http kind only
+ *     age        milliseconds since it happened, up to 7 days: an app that crashed sends the
+ *                error on its next launch with `deferred: 1` and the real age
+ *   Error hits never start a visit. They join the visitor's visit on the platform they
+ *   happened in (the running one, or for a deferred error the earlier one it ended), or are
+ *   stored on their own (an app that crashes before its first page). At most 10 per request
+ *   and 50 per visit; messages matching the ignore list are dropped. Strip query strings
+ *   before sending (keep only `i` and `r`); the server strips them again and redacts tokens
+ *   and emails.
+ *
+ *   {type: 'vitals', url, nav, metrics: {ttfb, fcp, lcp, inp, cls, screen_load, app_start}}
+ *     one per page view or screen, sent once when it is left or hidden (with the leave hit);
+ *     timings in milliseconds (0 to 120000), cls a score (0 to 10); leave out what wasn't
+ *     measured. Web pages: ttfb, fcp, lcp, inp, cls on a full load; screen_load (and cls,
+ *     inp) after a soft navigation. Apps: screen_load, from the navigation to the screen's
+ *     content laid out; app_start once per cold start, from process start to the first page.
+ *     nav: navigate, reload, back_forward, prerender, soft, restore.
+ *   Vitals join a running visit and are dropped without one; they count no pageview or event.
+ *
+ * Rate limits: one address (an IPv6 address by its /64 network) may send so many hits a
+ * minute and start so many visits an hour (Settings > Tracking). Past a limit the hits are
+ * dropped quietly and the request is answered as usual, see _isWithinHitLimit. Site actions,
+ * custom events from server code and payments don't come through collect() and aren't limited.
+ */
+class BxAnalyticsCollector
+{
+    protected $_oModule;
+    protected $_oConfig;
+    protected $_oDb;
+    protected $_oRequest;
+    protected $_iNow;
+
+    protected $_aSalts = [];
+    protected $_sIgnoredParams = null;
+
+    /**
+     * Whether the last collect() dropped its hits for a rate limit.
+     */
+    protected $_bLimited = false;
+
+    /**
+     * Query parameters removed from stored paths: campaign tags and click ids are in the
+     * session's source already, and would split one page into many rows.
+     */
+    protected $_aDropParams = [
+        'utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'utm_id',
+        'gclid', 'gbraid', 'wbraid', 'dclid', 'msclkid', 'fbclid', 'ttclid', 'twclid', 'li_fat_id',
+        'yclid', 'mc_cid', 'mc_eid', 'igshid', 'sccid', 'epik', '_ga', '_gl', 'ref', 'source', '_r', '_t',
+    ];
+
+    /**
+     * Query parameters that carry secrets or a person's identity, dropped from stored paths
+     * with their values: a password reset key, an email confirmation or unsubscribe code, an
+     * OAuth code, an invitation or API token, an address, a mail recipient's tracking id.
+     * Reports show paths to everyone who may use analytics, and keep them for months.
+     */
+    protected $_aSecretParams = [
+        'key', 'code', 'token', 'hash', 'secret', 'password', 'pass', 'passwd', 'pwd', 'auth', 'sid', 'sk',
+        'otp', 'email', 'session', 'ticket', 'invite', 'icode', 'confirm', 'signature', 'sig',
+        '_hsenc', '_hsmi', 'mkt_tok', '_ke',
+    ];
+
+    /**
+     * Compound names of such parameters, with or without a separator: api_key, access_token,
+     * reset-code, user.email, apikey, icode (an invitation), promocode, passcode... A few
+     * harmless names that end the same way (zipcode) are dropped too. `sid` only after a
+     * separator, as too many words end in it.
+     */
+    protected $_sSecretParamsPattern = '/(?:key|code|token|hash|secret|password|passwd|pwd|auth|session|otp|email|signature|[_.-]sid)$/';
+
+    /**
+     * Seconds between a guest visit's last hit and a member's hit for the visit to become the
+     * member's (signing in), when the guest is known only by address and browser.
+     */
+    const SIGN_IN_SECONDS = 120;
+
+    public function __construct($oModule, $oRequest = null, $iNow = null)
+    {
+        $this->_oModule = $oModule;
+        $this->_oConfig = $oModule->_oConfig;
+        $this->_oDb = $oModule->_oDb;
+        $this->_oRequest = $oRequest ? $oRequest : new BxAnalyticsRequest();
+        $this->_iNow = $iNow ? (int)$iNow : time();
+    }
+
+    /**
+     * @param array $aPayload decoded tracker payload, see the class description
+     * @param array $aContext what the entry point knows: `platform` (default when the payload
+     *      has none), `trusted` (the request carries a valid API key), `profile_id` (signed-in
+     *      profile), `same_origin` (hits must come from the site's own pages)
+     * @return int number of hits stored
+     */
+    public function collect($aPayload, $aContext = [])
+    {
+        $this->_bLimited = false;
+        if(!is_array($aPayload))
+            return 0;
+
+        $aAccept = $this->_accept($aPayload, $aContext);
+        $bBot = !empty($aAccept['reasons']) && $aAccept['reasons'][0] == 'bot';
+        if(!empty($aAccept['reasons']) && !$bBot)
+            return 0;
+
+        // a flood from one address, a headless bot's too, writes nothing past the limit
+        if(!$this->_isWithinHitLimit($aAccept['ip'], count($aAccept['hits'])))
+            return 0;
+
+        // a bot that runs the tracker (a headless browser): counted with the bots
+        if($bBot) {
+            $this->collectBot($aAccept['user_agent'], $aAccept['hits'][0]['url'], $aAccept['platform']);
+            return 0;
+        }
+
+        $sPlatform = $aAccept['platform'];
+        $aHits = $aAccept['hits'];
+        $sIp = $aAccept['ip'];
+        $sUserAgent = $aAccept['user_agent'];
+        $aClient = $aAccept['client'];
+        $iProfileId = $aAccept['profile_id'];
+
+        $sVid = isset($aPayload['vid']) && is_string($aPayload['vid']) && BxAnalyticsRequest::isVisitorId($aPayload['vid']) ? $aPayload['vid'] : '';
+        $aVisitor = $this->_getVisitor($sIp, $sUserAgent, $iProfileId, $sVid);
+
+        // what an error hit outside a visit records about the client
+        $aClientRow = [
+            'ids' => $aVisitor,
+            'visitor' => $aVisitor['id'],
+            'profile_id' => $iProfileId,
+            'platform' => $sPlatform,
+            'app_version' => $this->_string($aPayload, 'app_version', 32, '/^[a-zA-Z0-9._+ -]+$/'),
+            'browser' => $aClient['browser'],
+            'os' => $aClient['os'],
+            'device' => $aClient['device'],
+            'country' => ($sEdge = $this->_oConfig->getGeoEdge()) !== false ? $this->_oRequest->getCountry($sEdge) : '',
+        ];
+
+        $aSession = $this->_getSession($sPlatform, $aVisitor, $iProfileId, $aPayload, $aClient, $aHits, $sIp);
+        if($this->_bLimited)
+            return 0;
+
+        if(empty($aSession)) {
+            // errors without a visit (an app that crashed before its first page, a beacon after
+            // the visit timed out) are kept on their own; vitals belong to a page view
+            $aErrors = array_filter($aHits, function($aHit) { return $aHit['type'] == BX_ANALYTICS_HIT_ERROR; });
+
+            return $this->_storeErrors([], $aErrors, $aClientRow);
+        }
+
+        return $this->_store($aSession, $aHits, $aClientRow);
+    }
+
+    /**
+     * Whether the last collect() dropped its hits because the address was over a rate limit.
+     */
+    public function isLimited()
+    {
+        return $this->_bLimited;
+    }
+
+    /**
+     * What collect() would do with a payload, without storing anything: the reasons it would
+     * drop the hits (or `ok`), and what it knows about the request. For the Setup section's
+     * check of the admin's own browser.
+     *
+     * @param array $aPayload decoded tracker payload
+     * @param array $aContext as for collect()
+     * @return array reasons (tracking_off, platform_off, dnt, cross_site, excluded_path,
+     *      no_hits, excluded_ip, bot, excluded_level, rate_limited; or ok), notes
+     *      (link_members_off: stored, but not tied to the member), platform, ip (resolved),
+     *      remote_addr, country_header, country, geo_source, identity
+     */
+    public function check($aPayload, $aContext = [])
+    {
+        $aAccept = $this->_accept(is_array($aPayload) ? $aPayload : [], $aContext, true);
+        if($this->_isOverLimit($aAccept['ip']))
+            $aAccept['reasons'][] = 'rate_limited';
+        $sEdge = $this->_oConfig->getGeoEdge();
+        $aCountry = $this->_oRequest->getCountryHeader($sEdge !== false ? $sEdge : '');
+
+        return [
+            'reasons' => !empty($aAccept['reasons']) ? $aAccept['reasons'] : ['ok'],
+            'notes' => $aAccept['notes'],
+            'platform' => $aAccept['platform'],
+            'ip' => $aAccept['ip'],
+            'remote_addr' => $this->_oRequest->getRemoteAddr(),
+            'country_header' => $aCountry ? $aCountry['header'] : '',
+            'country' => $aCountry ? $aCountry['country'] : '',
+            'geo_source' => $this->_oConfig->getGeoSource(),
+            'identity' => $this->_oConfig->getIdentityMode(),
+        ];
+    }
+
+    /**
+     * The guards of collect() in one place: whether a payload's hits are kept, and what the
+     * request tells about the visitor. collect() stops at the first reason; a check collects
+     * them all.
+     *
+     * @param bool $bAll go on after a reason, to list every one
+     * @return array reasons (empty when the hits are kept), notes, platform, hits, ip,
+     *      user_agent, client (false for a bot), profile_id (0 when members aren't linked)
+     */
+    protected function _accept($aPayload, $aContext, $bAll = false)
+    {
+        $aResult = [
+            'reasons' => [],
+            'notes' => [],
+            'platform' => isset($aPayload['platform']) && is_string($aPayload['platform']) ? $aPayload['platform'] : (isset($aContext['platform']) ? $aContext['platform'] : BX_ANALYTICS_PLATFORM_API),
+            'hits' => [],
+            'ip' => '',
+            'user_agent' => '',
+            'client' => false,
+            'profile_id' => 0,
+        ];
+
+        // true when the caller should stop here
+        $fReject = function($sReason) use (&$aResult, $bAll) {
+            $aResult['reasons'][] = $sReason;
+            return !$bAll;
+        };
+
+        if(!$this->_oConfig->isTrackingEnabled() && $fReject('tracking_off'))
+            return $aResult;
+
+        // `core` is the server's own platform, never a client's
+        $sPlatform = $aResult['platform'];
+        if(($sPlatform == BX_ANALYTICS_PLATFORM_CORE || !$this->_oConfig->isPlatformEnabled($sPlatform)) && $fReject('platform_off'))
+            return $aResult;
+
+        if($this->_oConfig->isRespectDnt() && $this->_oRequest->isDoNotTrack() && $fReject('dnt'))
+            return $aResult;
+
+        $bSameOrigin = !empty($aContext['same_origin']);
+        if($bSameOrigin && $this->_oRequest->isCrossSite() && $fReject('cross_site'))
+            return $aResult;
+
+        $aResult['hits'] = $this->_getHits($aPayload, $bSameOrigin);
+        if(empty($aResult['hits']) && $fReject($this->_getNoHitsReason($aPayload)))
+            return $aResult;
+
+        $aResult['ip'] = $this->_oRequest->getIp($this->_oConfig->getTrustedProxies(), !empty($aContext['trusted']));
+        if($aResult['ip'] !== '' && BxAnalyticsRequest::inList($aResult['ip'], $this->_oConfig->getExcludedIps()) && $fReject('excluded_ip'))
+            return $aResult;
+
+        $aResult['user_agent'] = $this->_oRequest->getUserAgent();
+        $aResult['client'] = $this->_getClient($sPlatform, $aPayload, $aResult['user_agent']);
+        if($aResult['client'] === false && !empty($aResult['hits']) && $fReject('bot'))
+            return $aResult;
+
+        $iProfileId = isset($aContext['profile_id']) ? (int)$aContext['profile_id'] : 0;
+        $bExcluded = $iProfileId ? $this->_isExcludedProfile($iProfileId) : in_array(MEMBERSHIP_ID_NON_MEMBER, $this->_oConfig->getExcludedLevels(), true);
+        if($bExcluded && $fReject('excluded_level'))
+            return $aResult;
+
+        if($iProfileId && !$this->_oConfig->isLinkMembers()) {
+            $aResult['notes'][] = 'link_members_off';
+            $iProfileId = 0;
+        }
+        $aResult['profile_id'] = $iProfileId;
+
+        return $aResult;
+    }
+
+    /**
+     * Why a payload has no usable hits: all of them on excluded paths, or none valid.
+     */
+    protected function _getNoHitsReason($aPayload)
+    {
+        $aInput = isset($aPayload['hits']) && is_array($aPayload['hits']) ? $aPayload['hits'] : [$aPayload];
+        foreach($aInput as $aHit) {
+            $aUrl = is_array($aHit) && isset($aHit['url']) && is_string($aHit['url']) ? $this->_parseUrl($aHit['url']) : false;
+            if($aUrl !== false && $this->_isExcludedPath($aUrl['path'], $this->_oConfig->getExcludedPaths()))
+                return 'excluded_path';
+        }
+
+        return 'no_hits';
+    }
+
+    /**
+     * Count a request from a bot. Bots don't run the tracker, so the page itself reports
+     * them as it renders. They are kept as totals per day, bot and page, never as visits.
+     *
+     * @param string $sUserAgent the request's user agent
+     * @param string $sUrl the requested URL or path
+     * @param string $sPlatform where the request came to
+     * @return bool whether it was a bot and was counted
+     */
+    public function collectBot($sUserAgent, $sUrl, $sPlatform = BX_ANALYTICS_PLATFORM_CORE_WEB)
+    {
+        if(!$this->_oConfig->isTrackingEnabled() || !$this->_oConfig->isTrackBots() || !$this->_oConfig->isPlatform($sPlatform))
+            return false;
+
+        $aBot = BxAnalyticsUserAgent::getBot($sUserAgent);
+        if(!$aBot)
+            return false;
+
+        $aUrl = $this->_parseUrl((string)$sUrl);
+        $sPath = $aUrl !== false ? $aUrl['path'] : '';
+        if($sPath !== '' && $this->_isExcludedPath($sPath, $this->_oConfig->getExcludedPaths()))
+            return false;
+
+        return $this->_oDb->addBotHit(gmdate('Y-m-d', $this->_iNow), $aBot['name'], $aBot['category'], $sPlatform, $sPath, $this->_iNow);
+    }
+
+    /**
+     * Record something done on the site (a sign-up, a post, a comment, a reaction) as an
+     * event of type `una`, from whatever client did it. The event joins the actor's running
+     * visit on that client when there is one, so funnels and journeys see it.
+     *
+     * @param string $sName action name: signup, login, profile_add, content_add, comment, vote, connection...
+     * @param string $sModule module or object system the action happened in
+     * @param int $iObjectId content, comment or profile the action is about
+     * @param int $iProfileId who did it
+     * @param array $aContext `platform` (otherwise worked out from the request), `api_key` (the
+     *      request carries an accepted API key), `props`, `value`, `type` (`event` for a custom
+     *      event from server code, which the "track site actions" setting doesn't turn off),
+     *      `request` (whether the request is the actor's own, whose Do Not Track, address and
+     *      user agent apply; by default any request but the server platform's)
+     * @return bool whether it was stored (not when tracking is off, the platform isn't
+     *      enabled, the member's or the guest's level is excluded, or the request isn't
+     *      tracked: Do Not Track when respected, an excluded address, a bot)
+     */
+    public function collectAction($sName, $sModule, $iObjectId, $iProfileId, $aContext = [])
+    {
+        $sType = isset($aContext['type']) && $aContext['type'] === BX_ANALYTICS_HIT_EVENT ? BX_ANALYTICS_HIT_EVENT : BX_ANALYTICS_HIT_UNA;
+        if(!$this->_oConfig->isTrackingEnabled() || ($sType == BX_ANALYTICS_HIT_UNA && !$this->_oConfig->isTrackActions()))
+            return false;
+
+        $sPlatform = !empty($aContext['platform']) ? $aContext['platform'] : $this->_oRequest->getPlatform(defined('BX_API'), !empty($aContext['api_key']));
+        // a platform left out of Settings > Platforms collects nothing, server events included
+        if(!$this->_oConfig->isPlatformEnabled($sPlatform))
+            return false;
+
+        // the same rules as the visitor's own hits; a cron job or a webhook (the server's
+        // platform) has no visitor request to read them from
+        $iProfileId = (int)$iProfileId;
+        $bRequest = isset($aContext['request']) ? (bool)$aContext['request'] : $sPlatform != BX_ANALYTICS_PLATFORM_CORE;
+        if(!$this->isTracked($sPlatform, $iProfileId, !empty($aContext['api_key']), $bRequest))
+            return false;
+
+        if(!$iProfileId && in_array(MEMBERSHIP_ID_NON_MEMBER, $this->_oConfig->getExcludedLevels(), true))
+            return false;
+
+        $iLinkedProfileId = $this->_oConfig->isLinkMembers() ? $iProfileId : 0;
+        list($aVisitor, $aSession) = $this->_getRequestVisit($sPlatform, $iLinkedProfileId, !empty($aContext['api_key']));
+        // signing in (`login`) is a member's action in the guest's visit: the visit is theirs now
+        if(!empty($aSession))
+            $aSession = $this->_takeOver($aSession, $aVisitor, $iLinkedProfileId);
+
+        $aProps = isset($aContext['props']) && is_array($aContext['props']) ? $this->_getProps($aContext['props']) : [];
+
+        $iStored = $this->_oDb->insertEvents([[
+            'session_id' => !empty($aSession) ? (int)$aSession['id'] : 0,
+            'visitor' => !empty($aSession) ? $aSession['visitor'] : $aVisitor['id'],
+            'profile_id' => $iLinkedProfileId,
+            'platform' => $sPlatform,
+            'ts' => $this->_iNow,
+            'type' => $sType,
+            'name' => get_mb_substr((string)$sName, 0, 64),
+            'hostname' => '',
+            'path' => '',
+            'title' => '',
+            'module' => get_mb_substr(preg_replace('/[^a-z0-9_]/', '', mb_strtolower((string)$sModule)), 0, 32),
+            'object_id' => max(0, (int)$iObjectId),
+            'context_id' => 0,
+            'value' => isset($aContext['value']) && is_numeric($aContext['value']) ? (float)$aContext['value'] : 0,
+            'engaged' => 0,
+            'props' => empty($aProps) ? '' : json_encode($aProps, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
+        ]]);
+
+        if($iStored && !empty($aSession))
+            $this->_oDb->addSessionHits($aSession['id'], 0, 1, true, $this->_iNow, false);
+
+        return $iStored > 0;
+    }
+
+    /**
+     * Whether something a member did may be tied to their visits: tracking is on and the
+     * member's level isn't excluded. For the member's own request (`$bRequest`) also: its
+     * platform is enabled, it doesn't ask not to be tracked (when that is respected), its
+     * address isn't excluded and a browser platform isn't a bot.
+     *
+     * @param string $sPlatform platform of the request
+     * @param int $iProfileId the member
+     * @param bool $bApiKey the request carries an accepted API key
+     * @param bool $bRequest the request is the member's own, not a webhook or a cron job
+     */
+    public function isTracked($sPlatform, $iProfileId, $bApiKey = false, $bRequest = true)
+    {
+        if(!$this->_oConfig->isTrackingEnabled())
+            return false;
+
+        if((int)$iProfileId && $this->_isExcludedProfile((int)$iProfileId))
+            return false;
+
+        if(!$bRequest)
+            return true;
+
+        if(!$this->_oConfig->isPlatformEnabled($sPlatform))
+            return false;
+
+        if($this->_oConfig->isRespectDnt() && $this->_oRequest->isDoNotTrack())
+            return false;
+
+        $sIp = $this->_oRequest->getIp($this->_oConfig->getTrustedProxies(), $bApiKey);
+        if($sIp !== '' && BxAnalyticsRequest::inList($sIp, $this->_oConfig->getExcludedIps()))
+            return false;
+
+        if($this->_oConfig->isBrowserPlatform($sPlatform) && BxAnalyticsUserAgent::isBot($this->_oRequest->getUserAgent()))
+            return false;
+
+        return true;
+    }
+
+    /**
+     * The running visit of the request's visitor on a platform, or an empty array.
+     *
+     * @param string $sPlatform platform of the request
+     * @param int $iProfileId the signed-in member, used when members are linked to visits
+     * @param bool $bApiKey the request carries an accepted API key
+     */
+    public function getRequestSession($sPlatform, $iProfileId, $bApiKey = false)
+    {
+        list(, $aSession) = $this->_getRequestVisit($sPlatform, $this->_oConfig->isLinkMembers() ? (int)$iProfileId : 0, $bApiKey);
+
+        return $aSession;
+    }
+
+    /**
+     * Visitor ids of the current request and its running visit on a platform. The server's
+     * own platform has no visits: its visitor is the member, or nobody.
+     *
+     * @return array [visitor ids, session or []]
+     */
+    protected function _getRequestVisit($sPlatform, $iLinkedProfileId, $bApiKey)
+    {
+        if($sPlatform == BX_ANALYTICS_PLATFORM_CORE)
+            return [['id' => $iLinkedProfileId ? $this->_hash('p:' . $iLinkedProfileId, BX_DOL_SECRET) : str_repeat("\0", 16)], []];
+
+        // the device's own id, in persistent mode: the classic tracker keeps it in a cookie, NEO sends it in a header
+        $aVisitor = $this->_getVisitor($this->_oRequest->getIp($this->_oConfig->getTrustedProxies(), $bApiKey), $this->_oRequest->getUserAgent(), $iLinkedProfileId, $this->_oRequest->getVisitorId());
+
+        // a visit that started after the action (a clock given by a test or an import) isn't its visit
+        return [$aVisitor, $this->_findSession($aVisitor, $sPlatform, $this->_iNow - $this->_oConfig->getSessionTimeout(), $this->_iNow, $this->_iNow)];
+    }
+
+    /**
+     * Visitor ids for a hit: `id` to store, `lookup` to find a running session.
+     * Members get one id across devices and days. Persistent mode uses the client's own id.
+     * Cookieless mode hashes the address and user agent with a salt that changes daily, and
+     * also checks yesterday's id so a visit doesn't split at midnight.
+     *
+     * @return array id, lookup, anonymous (the ids of the guest), member (a member's ids),
+     *      device (the guest id is the device's own, not the address and browser)
+     */
+    protected function _getVisitor($sIp, $sUserAgent, $iProfileId, $sVid)
+    {
+        $bDevice = $sVid !== '' && $this->_oConfig->getIdentityMode() == BX_ANALYTICS_IDENTITY_PERSISTENT;
+        if($bDevice)
+            $aAnonymous = [$this->_hash('v:' . $sVid, BX_DOL_SECRET)];
+        else {
+            $sToday = gmdate('Y-m-d', $this->_iNow);
+            $sYesterday = gmdate('Y-m-d', $this->_iNow - 86400);
+            $this->_loadSalts([$sToday, $sYesterday]);
+
+            $aAnonymous = [
+                $this->_hash($sIp . '|' . $sUserAgent, $this->_getSalt($sToday)),
+                $this->_hash($sIp . '|' . $sUserAgent, $this->_getSalt($sYesterday)),
+            ];
+        }
+
+        if($iProfileId) {
+            $sMember = $this->_hash('p:' . $iProfileId, BX_DOL_SECRET);
+            return ['id' => $sMember, 'lookup' => array_merge([$sMember], $aAnonymous), 'anonymous' => $aAnonymous, 'member' => true, 'device' => $bDevice];
+        }
+
+        return ['id' => $aAnonymous[0], 'lookup' => $aAnonymous, 'anonymous' => $aAnonymous, 'member' => false, 'device' => $bDevice];
+    }
+
+    /**
+     * The visitor's latest visit on a platform active since a time. A member's own visit comes
+     * first. A guest visit is the member's (they signed in during it) when the guest id is the
+     * device's own, or else only when the visit was active just before: people behind one
+     * address (a mobile carrier, an office) often have the same browser, and so the same
+     * cookieless id, and one signing in mustn't take over another's visit.
+     *
+     * @param int $iUntil the visit started by then, 0 for any
+     * @param int $iAt when the member's hit happened
+     * @return array the visit or []
+     */
+    protected function _findSession($aVisitor, $sPlatform, $iSince, $iUntil = 0, $iAt = 0)
+    {
+        if(empty($aVisitor['member']))
+            return $this->_oDb->getActiveSession($aVisitor['lookup'], $sPlatform, $iSince, $iUntil);
+
+        $aSession = $this->_oDb->getActiveSession([$aVisitor['id']], $sPlatform, $iSince, $iUntil);
+        if(!empty($aSession))
+            return $aSession;
+
+        if(empty($aVisitor['device']))
+            $iSince = max($iSince, ($iAt ? $iAt : $this->_iNow) - self::SIGN_IN_SECONDS);
+
+        return $this->_oDb->getActiveSession($aVisitor['anonymous'], $sPlatform, $iSince, $iUntil);
+    }
+
+    /**
+     * A guest visit found for a member (see _findSession) becomes the member's: the visit and
+     * every event recorded in it so far get the member's visitor id and profile, so one visit
+     * isn't counted as two people and the member's actions include those before signing in.
+     * Error hits keep the guest's id.
+     *
+     * @return array the visit as it is now
+     */
+    protected function _takeOver($aSession, $aVisitor, $iProfileId)
+    {
+        if(!$iProfileId || (int)$aSession['profile_id'] || $aSession['visitor'] === $aVisitor['id'])
+            return $aSession;
+
+        $this->_oDb->updateSession($aSession['id'], ['profile_id' => $iProfileId, 'visitor' => $aVisitor['id']]);
+        $this->_oDb->assignSessionEvents($aSession['id'], $aVisitor['id'], $iProfileId);
+
+        $aSession['profile_id'] = $iProfileId;
+        $aSession['visitor'] = $aVisitor['id'];
+
+        return $aSession;
+    }
+
+    /**
+     * The visitor's running session on this platform, or a new one. A guest session that
+     * signs in continues as the member's (see _findSession). No new one past the address's
+     * limit of visits an hour (`_bLimited` tells).
+     *
+     * @param string $sIp the client's address, for the rate limit
+     */
+    protected function _getSession($sPlatform, $aVisitor, $iProfileId, $aPayload, $aClient, $aHits, $sIp = '')
+    {
+        $aSession = $this->_findSession($aVisitor, $sPlatform, $this->_iNow - $this->_oConfig->getSessionTimeout());
+        if(!empty($aSession))
+            return $this->_takeOver($aSession, $aVisitor, $iProfileId);
+
+        // a hit that only closes a page, reports an error or a page's timings doesn't start a visit
+        $aFirst = null;
+        foreach($aHits as $aHit)
+            if(!in_array($aHit['type'], [BX_ANALYTICS_HIT_LEAVE, BX_ANALYTICS_HIT_ERROR, BX_ANALYTICS_HIT_VITALS], true)) {
+                $aFirst = $aHit;
+                break;
+            }
+        if($aFirst === null)
+            return [];
+
+        // two first requests of a visit at once (a pageview and an event, tabs opened together)
+        // would both find nothing and start two visits: the second waits for the first and
+        // finds its visit. Without the lock (a timeout, most likely while another request started
+        // this visit) it looks again and then goes on as before rather than drop hits.
+        $sLock = $this->_getVisitLockName($sPlatform, $aVisitor);
+        $bLocked = $this->_oDb->lockVisit($sLock, 2);
+        try {
+            $aSession = $this->_findSession($aVisitor, $sPlatform, $this->_iNow - $this->_oConfig->getSessionTimeout());
+            if(!empty($aSession))
+                return $this->_takeOver($aSession, $aVisitor, $iProfileId);
+
+            return $this->_startSession($sPlatform, $aVisitor, $iProfileId, $aPayload, $aClient, $aFirst, $sIp);
+        }
+        finally {
+            if($bLocked)
+                $this->_oDb->unlockVisit($sLock);
+        }
+    }
+
+    /**
+     * The name of the lock that starts a visit, one per visitor and platform; MySQL allows 64
+     * characters, so the hash is cut to 40.
+     */
+    protected function _getVisitLockName($sPlatform, $aVisitor)
+    {
+        return 'bx_analytics_s_' . substr(hash('sha256', $sPlatform . '|' . $aVisitor['id']), 0, 40);
+    }
+
+    /**
+     * A new visit that starts with a hit.
+     */
+    protected function _startSession($sPlatform, $aVisitor, $iProfileId, $aPayload, $aClient, $aFirst, $sIp)
+    {
+        // a script that changes its user agent or visitor id for every request would start a
+        // visit with each
+        if(!$this->_isWithinVisitLimit($sIp))
+            return [];
+
+        $sReferrer = isset($aPayload['referrer']) && is_string($aPayload['referrer']) ? $aPayload['referrer'] : '';
+        $aSource = BxAnalyticsSource::classify($sReferrer, $aFirst['url'], $this->_oConfig->getInternalHosts());
+
+        $sLanguage = isset($aPayload['language']) && is_string($aPayload['language']) && preg_match('/^[a-zA-Z]{2,3}(-[a-zA-Z0-9]{2,4})?$/', $aPayload['language']) ? $aPayload['language'] : $this->_oRequest->getLanguage();
+        $sScreen = isset($aPayload['screen']) && is_string($aPayload['screen']) && preg_match('/^\d{2,5}x\d{2,5}$/', $aPayload['screen']) ? $aPayload['screen'] : '';
+
+        $aGeo = ['country' => '', 'region' => '', 'city' => '', 'lat' => null, 'lng' => null];
+        if(($sEdge = $this->_oConfig->getGeoEdge()) !== false) {
+            $aCoordinates = $this->_oRequest->getCoordinates($sEdge);
+            $aGeo = [
+                'country' => $this->_oRequest->getCountry($sEdge),
+                'region' => $this->_oRequest->getRegion($sEdge),
+                'city' => $this->_oRequest->getCity($sEdge),
+                'lat' => $aCoordinates ? $aCoordinates[0] : null,
+                'lng' => $aCoordinates ? $aCoordinates[1] : null,
+            ];
+        }
+
+        $iStarted = $aFirst['ts'];
+        $aSession = array_merge([
+            'visitor' => $aVisitor['id'],
+            'profile_id' => $iProfileId,
+            'platform' => $sPlatform,
+            'app' => $this->_string($aPayload, 'app', 64, '/^[a-zA-Z0-9._ -]+$/'),
+            'app_version' => $this->_string($aPayload, 'app_version', 32, '/^[a-zA-Z0-9._+ -]+$/'),
+            'started' => $iStarted,
+            'last_seen' => $iStarted,
+            'duration' => 0,
+            'pageviews' => 0,
+            'events' => 0,
+            'bounce' => 1,
+            'hostname' => $aFirst['hostname'],
+            'entry_path' => $aFirst['path'],
+            'exit_path' => $aFirst['path'],
+            'language' => get_mb_substr($sLanguage, 0, 16),
+            'screen' => $sScreen,
+        ], $aSource, $aGeo, $aClient);
+
+        unset($aSession['bot']);
+
+        $aSession['id'] = $this->_oDb->insertSession($aSession);
+        if(!$aSession['id'])
+            return [];
+
+        return $aSession;
+    }
+
+    /**
+     * Write the hits and add them to the session's counters in one query. The counters change
+     * in the database, not from `$aSession`, which another request of the visit may have
+     * changed since it was read.
+     *
+     * @param array $aClientRow the client's columns for errors that don't belong to the visit
+     */
+    protected function _store($aSession, $aHits, $aClientRow = [])
+    {
+        $iPageviews = 0;
+        $iEvents = 0;
+        $bInteractive = false;
+        $iLastSeen = 0;
+
+        $aRows = [];
+        $aErrors = [];
+        $iStored = 0;
+        foreach($aHits as $aHit) {
+            if($aHit['type'] == BX_ANALYTICS_HIT_ERROR) {
+                $aErrors[] = $aHit;
+                if($this->_isSessionError($aSession, $aHit))
+                    $iLastSeen = max($iLastSeen, $aHit['ts']);
+                continue;
+            }
+
+            $iLastSeen = max($iLastSeen, $aHit['ts']);
+
+            // a page's timings: a row of their own, no pageview or event
+            if($aHit['type'] == BX_ANALYTICS_HIT_VITALS) {
+                $iStored += $this->_oDb->insertVitals(array_merge([
+                    'ts' => $aHit['ts'],
+                    'session_id' => (int)$aSession['id'],
+                    'platform' => $aSession['platform'],
+                    'device' => $aSession['device'],
+                    'path' => $aHit['path'],
+                    'route' => $aHit['route'],
+                    'nav' => $aHit['vitals']['nav'],
+                ], $aHit['vitals']['metrics']));
+                continue;
+            }
+
+            if($aHit['type'] == BX_ANALYTICS_HIT_LEAVE) {
+                if($aHit['engaged'] > 0)
+                    $this->_oDb->addEngagedTime($aSession['id'], $aHit['path'], $aHit['engaged'], $this->_oConfig->CNF['MAX_ENGAGED_MS']);
+                $iStored++;
+                continue;
+            }
+
+            if($aHit['type'] == BX_ANALYTICS_HIT_PAGEVIEW)
+                $iPageviews++;
+            else {
+                $iEvents++;
+                if(empty($aHit['non_interactive']))
+                    $bInteractive = true;
+            }
+
+            $aRows[] = [
+                'session_id' => $aSession['id'],
+                'visitor' => $aSession['visitor'],
+                'profile_id' => (int)$aSession['profile_id'],
+                'platform' => $aSession['platform'],
+                'ts' => $aHit['ts'],
+                'type' => $aHit['type'],
+                'name' => $aHit['name'],
+                'hostname' => $aHit['hostname'],
+                'path' => $aHit['path'],
+                'title' => $aHit['title'],
+                'module' => $aHit['module'],
+                'object_id' => $aHit['object_id'],
+                'context_id' => $aHit['context_id'],
+                'value' => $aHit['value'],
+                'engaged' => 0,
+                'props' => $aHit['props'],
+            ];
+        }
+
+        if(!empty($aRows))
+            $iStored += $this->_oDb->insertEvents($aRows);
+
+        // after the events: the exit page is the visit's latest pageview among them
+        $this->_oDb->addSessionHits($aSession['id'], $iPageviews, $iEvents, $bInteractive, $iLastSeen, $iPageviews > 0);
+
+        if(!empty($aErrors))
+            $iStored += $this->_storeErrors($aSession, $aErrors, $aClientRow);
+
+        return $iStored;
+    }
+
+    /**
+     * Whether an error happened in the visit: not before it started (a deferred error from an
+     * app's previous run, sent when it starts again, belongs to an earlier visit). A minute of
+     * slack covers an error buffered before the first pageview was sent.
+     */
+    protected function _isSessionError($aSession, $aHit)
+    {
+        return !empty($aSession) && $aHit['ts'] >= (int)$aSession['started'] - 60;
+    }
+
+    /**
+     * The visit an error that is not part of the running one happened in: the visitor's last
+     * visit on the platform that had started by then and had not timed out. An app that
+     * crashed sends the error on its next launch, often after that visit timed out.
+     *
+     * @return array the visit, or [] when it happened outside any
+     */
+    protected function _getErrorSession($aClientRow, $aHit)
+    {
+        if(empty($aClientRow['ids']))
+            return [];
+
+        $iTime = (int)$aHit['ts'];
+        $aSession = $this->_findSession($aClientRow['ids'], $aClientRow['platform'], $iTime - $this->_oConfig->getSessionTimeout(), $iTime + 60, $iTime);
+
+        return !empty($aSession) && $this->_isSessionError($aSession, $aHit) ? $aSession : [];
+    }
+
+    /**
+     * Store error hits under their groups. One that belongs to a visit (the running one, or
+     * an earlier one it happened in) raises that visit's error count, and is dropped past the
+     * limit per visit.
+     *
+     * @param array $aSession the running visit, or [] for none
+     * @param array $aErrors error hits from _getHits
+     * @param array $aClientRow visitor ids (ids, see _getVisitor), platform and client columns for errors outside a visit
+     * @return int hits stored
+     */
+    protected function _storeErrors($aSession, $aErrors, $aClientRow)
+    {
+        $aRows = [];
+        foreach($aErrors as $aHit) {
+            $aVisit = $this->_isSessionError($aSession, $aHit) ? $aSession : $this->_getErrorSession($aClientRow, $aHit);
+            $bLinked = !empty($aVisit);
+            if($bLinked && !$this->_oDb->addSessionErrors($aVisit['id'], 1, $this->_oConfig->CNF['MAX_ERRORS_PER_SESSION']))
+                continue;
+
+            $iErrorId = $this->_getErrorGroup($aHit['error'], $aHit['ts']);
+            if(!$iErrorId)
+                continue;
+
+            $aSource = $bLinked ? $aVisit : $aClientRow;
+            $aRows[] = [
+                'error_id' => $iErrorId,
+                'ts' => $aHit['ts'],
+                'session_id' => $bLinked ? (int)$aVisit['id'] : 0,
+                'visitor' => $aSource['visitor'],
+                'profile_id' => (int)$aSource['profile_id'],
+                'platform' => $aSource['platform'],
+                'app_version' => $aSource['app_version'],
+                'browser' => $aSource['browser'],
+                'os' => $aSource['os'],
+                'device' => $aSource['device'],
+                'country' => $aSource['country'],
+                'path' => $aHit['path'],
+                'route' => $aHit['route'],
+                'repeats' => $aHit['error']['repeats'],
+                'fatal' => $aHit['error']['fatal'],
+            ];
+        }
+
+        return $this->_oDb->insertErrorHits($aRows);
+    }
+
+    /**
+     * The id of an error's group, created when it is new. Past the limit of new groups an
+     * hour, a new error goes to one catch-all group, so a flood of random messages can't fill
+     * the table. A resolved error that happens again after it was resolved is open again.
+     */
+    protected function _getErrorGroup($aError, $iTime)
+    {
+        $sFingerprint = BxAnalyticsErrors::getFingerprint($aError);
+
+        $aGroup = $this->_oDb->getErrorGroupByFingerprint($sFingerprint);
+        if(empty($aGroup) && $this->_oDb->getErrorGroupsCreated($this->_iNow - 3600, $this->_iNow) >= $this->_oConfig->CNF['MAX_NEW_ERROR_GROUPS_PER_HOUR']) {
+            $aError = ['repeats' => $aError['repeats']] + BxAnalyticsErrors::getOverflowError();
+            $sFingerprint = BxAnalyticsErrors::getFingerprint($aError);
+            $aGroup = $this->_oDb->getErrorGroupByFingerprint($sFingerprint);
+        }
+
+        $iId = $this->_oDb->saveErrorGroup($sFingerprint, $aError, $iTime, $this->_iNow, !empty($aGroup) ? (int)$aGroup['id'] : 0);
+
+        if($iId && !empty($aGroup) && $aGroup['status'] == 'resolved' && $iTime > (int)$aGroup['status_changed'])
+            $this->_oDb->setErrorStatus($iId, 'open', $this->_iNow, 'resolved');
+
+        return $iId;
+    }
+
+    /**
+     * Validated hits from a payload. A hit without a usable URL, or from another site when
+     * `$bSameOrigin`, is dropped, and so is one on an excluded path.
+     */
+    protected function _getHits($aPayload, $bSameOrigin)
+    {
+        $aInput = isset($aPayload['hits']) && is_array($aPayload['hits']) ? array_values($aPayload['hits']) : [$aPayload];
+        $aInput = array_slice($aInput, 0, $this->_oConfig->CNF['MAX_HITS_PER_REQUEST']);
+
+        $aInternalHosts = $this->_oConfig->getInternalHosts();
+        $aExcludedPaths = $this->_oConfig->getExcludedPaths();
+        $sPlatform = isset($aPayload['platform']) ? $aPayload['platform'] : '';
+        $sApp = $this->_string($aPayload, 'app', 64, '/^[a-zA-Z0-9._ -]+$/');
+
+        $aTypes = [BX_ANALYTICS_HIT_PAGEVIEW, BX_ANALYTICS_HIT_EVENT, BX_ANALYTICS_HIT_LEAVE];
+        if($this->_oConfig->isTrackErrors())
+            $aTypes[] = BX_ANALYTICS_HIT_ERROR;
+        if($this->_oConfig->isTrackVitals())
+            $aTypes[] = BX_ANALYTICS_HIT_VITALS;
+        $aLimits = [
+            BX_ANALYTICS_HIT_ERROR => $this->_oConfig->CNF['MAX_ERRORS_PER_REQUEST'],
+            BX_ANALYTICS_HIT_VITALS => $this->_oConfig->CNF['MAX_VITALS_PER_REQUEST'],
+        ];
+
+        $aHits = [];
+        foreach($aInput as $aHit) {
+            if(!is_array($aHit))
+                continue;
+
+            $sType = isset($aHit['type']) && is_string($aHit['type']) ? $aHit['type'] : BX_ANALYTICS_HIT_PAGEVIEW;
+            if(!in_array($sType, $aTypes, true))
+                continue;
+
+            if(isset($aLimits[$sType]) && $aLimits[$sType]-- <= 0)
+                continue;
+
+            $aUrl = $this->_parseUrl(isset($aHit['url']) && is_string($aHit['url']) ? $aHit['url'] : '');
+            if($aUrl === false)
+                continue;
+
+            // native apps send paths; their "host" is the app
+            if($aUrl['hostname'] === '' && $this->_oConfig->isAppPlatform($sPlatform))
+                $aUrl['hostname'] = mb_strtolower($sApp);
+
+            if($bSameOrigin && !in_array($aUrl['hostname'], array_map('strtolower', $aInternalHosts), true))
+                continue;
+
+            if($this->_isExcludedPath($aUrl['path'], $aExcludedPaths))
+                continue;
+
+            $sName = '';
+            if($sType == BX_ANALYTICS_HIT_EVENT) {
+                $sName = $this->_string($aHit, 'name', 64, '/^[\p{L}\p{N} _.:\/-]+$/u');
+                if($sName === '')
+                    continue;
+            }
+
+            // an error may come late: an app sends the one that stopped it when it starts again
+            $iMaxAge = $sType == BX_ANALYTICS_HIT_ERROR ? $this->_oConfig->CNF['MAX_ERROR_AGE'] : 3600;
+            $iAge = isset($aHit['age']) && is_numeric($aHit['age']) ? (int)$aHit['age'] : 0;
+            if($sType == BX_ANALYTICS_HIT_ERROR && $iAge >= $iMaxAge * 1000)
+                continue;
+            $iAge = $iAge > 0 && $iAge < $iMaxAge * 1000 ? intdiv($iAge, 1000) : 0;
+
+            $aHealth = [];
+            if($sType == BX_ANALYTICS_HIT_ERROR) {
+                $aHealth = ['error' => $this->_getErrorInput($aHit)];
+                if($aHealth['error'] === false)
+                    continue;
+            }
+            elseif($sType == BX_ANALYTICS_HIT_VITALS) {
+                $aHealth = ['vitals' => $this->_getVitalsInput($aHit)];
+                if($aHealth['vitals'] === false)
+                    continue;
+            }
+            // errors and timings keep only the page parameters of a query string, whatever
+            // the client sent
+            if(!empty($aHealth)) {
+                $aUrl['path'] = get_mb_substr(BxAnalyticsErrors::getCleanPath($aUrl['path']), 0, 255);
+                $aHealth['route'] = BxAnalyticsErrors::getRoute($aUrl['path']);
+            }
+
+            $aProps = isset($aHit['props']) && is_array($aHit['props']) ? $this->_getProps($aHit['props']) : [];
+
+            $aHits[] = $aHealth + [
+                'type' => $sType,
+                'name' => $sName,
+                'url' => $aUrl['url'],
+                'hostname' => $aUrl['hostname'],
+                'path' => $aUrl['path'],
+                'title' => $this->_string($aHit, 'title', 255),
+                'module' => $this->_string($aHit, 'module', 32, '/^[a-z0-9_]+$/'),
+                'object_id' => isset($aHit['object_id']) ? max(0, (int)$aHit['object_id']) : 0,
+                'context_id' => isset($aHit['context_id']) ? abs((int)$aHit['context_id']) : 0,
+                'value' => isset($aHit['value']) && is_numeric($aHit['value']) ? (float)$aHit['value'] : 0,
+                'engaged' => isset($aHit['engaged']) ? min(max(0, (int)$aHit['engaged']), $this->_oConfig->CNF['MAX_ENGAGED_MS']) : 0,
+                'props' => empty($aProps) ? '' : json_encode($aProps, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
+                'non_interactive' => !empty($aHit['non_interactive']),
+                'ts' => $this->_iNow - $iAge,
+            ];
+        }
+
+        return $aHits;
+    }
+
+    /**
+     * An error hit's fields, sanitized, or false for noise, an ignored message or nothing usable.
+     */
+    protected function _getErrorInput($aHit)
+    {
+        $sKind = isset($aHit['kind']) && is_string($aHit['kind']) && $aHit['kind'] !== '' ? $aHit['kind'] : 'js';
+        if(!in_array($sKind, BxAnalyticsErrors::KINDS, true))
+            return false;
+
+        $aHosts = $this->_oConfig->getInternalHosts();
+        $fText = function($sKey, $iInput) use ($aHit) {
+            return isset($aHit[$sKey]) && is_string($aHit[$sKey]) ? get_mb_substr($aHit[$sKey], 0, $iInput) : '';
+        };
+
+        $iStatus = $sKind == 'http' && isset($aHit['status']) && is_numeric($aHit['status']) ? min(599, max(0, (int)$aHit['status'])) : 0;
+
+        $sMessage = BxAnalyticsErrors::sanitize($fText('message', BxAnalyticsErrors::MAX_MESSAGE_INPUT), BxAnalyticsErrors::MAX_MESSAGE, $aHosts);
+        if($sMessage === '' && $iStatus)
+            $sMessage = 'HTTP ' . $iStatus;
+        if($sMessage === '')
+            return false;
+
+        $sSource = BxAnalyticsErrors::sanitize($fText('source', 2048), BxAnalyticsErrors::MAX_SOURCE, $aHosts);
+        $sStack = BxAnalyticsErrors::sanitizeStack($fText('stack', BxAnalyticsErrors::MAX_STACK_INPUT), $fText('component_stack', BxAnalyticsErrors::MAX_STACK_INPUT), $aHosts);
+
+        if(BxAnalyticsErrors::isNoise($sMessage, $sStack, $sSource) || BxAnalyticsErrors::isIgnored($sMessage, $this->_oConfig->getErrorsIgnore()))
+            return false;
+
+        return [
+            'kind' => $sKind,
+            'message' => $sMessage,
+            'source' => $sSource,
+            'line' => isset($aHit['line']) && is_numeric($aHit['line']) ? min(10000000, max(0, (int)$aHit['line'])) : 0,
+            'col' => isset($aHit['col']) && is_numeric($aHit['col']) ? min(10000000, max(0, (int)$aHit['col'])) : 0,
+            'status' => $iStatus,
+            'stack' => $sStack,
+            'fatal' => !empty($aHit['fatal']) ? 1 : 0,
+            'repeats' => isset($aHit['repeats']) && is_numeric($aHit['repeats']) ? min(100, max(1, (int)$aHit['repeats'])) : 1,
+        ];
+    }
+
+    /**
+     * A vitals hit's navigation type and the metrics in range, or false when none is.
+     */
+    protected function _getVitalsInput($aHit)
+    {
+        if(!isset($aHit['metrics']) || !is_array($aHit['metrics']))
+            return false;
+
+        $aMetrics = [];
+        foreach(BxAnalyticsVitals::METRICS as $sMetric) {
+            $mixedValue = isset($aHit['metrics'][$sMetric]) ? BxAnalyticsVitals::getValue($sMetric, $aHit['metrics'][$sMetric]) : null;
+            if($mixedValue !== null)
+                $aMetrics[$sMetric] = $mixedValue;
+        }
+        if(empty($aMetrics))
+            return false;
+
+        $sNav = isset($aHit['nav']) && is_string($aHit['nav']) && in_array($aHit['nav'], BxAnalyticsVitals::NAVS, true) ? $aHit['nav'] : '';
+
+        return ['nav' => $sNav, 'metrics' => $aMetrics];
+    }
+
+    /**
+     * Browser, system and device of the client, or false for a bot. Browsers are read from the
+     * user agent; native apps report their system and device themselves.
+     */
+    protected function _getClient($sPlatform, $aPayload, $sUserAgent)
+    {
+        if(!$this->_oConfig->isAppPlatform($sPlatform)) {
+            $aUserAgent = BxAnalyticsUserAgent::parse($sUserAgent);
+            if($aUserAgent['bot'] && $sPlatform != BX_ANALYTICS_PLATFORM_API)
+                return false;
+
+            return $aUserAgent;
+        }
+
+        $sDevice = $this->_string($aPayload, 'device', 16, '/^[a-z]+$/');
+        if(!in_array($sDevice, [BxAnalyticsUserAgent::DEVICE_MOBILE, BxAnalyticsUserAgent::DEVICE_TABLET, BxAnalyticsUserAgent::DEVICE_DESKTOP, BxAnalyticsUserAgent::DEVICE_TV], true))
+            $sDevice = BxAnalyticsUserAgent::DEVICE_MOBILE;
+
+        $sOs = $this->_string($aPayload, 'os', 32, '/^[a-zA-Z0-9 ]+$/');
+        if($sOs === '')
+            $sOs = $sPlatform == BX_ANALYTICS_PLATFORM_RN_IOS ? ($sDevice == BxAnalyticsUserAgent::DEVICE_TABLET ? 'iPadOS' : 'iOS') : 'Android';
+
+        return [
+            'browser' => '',
+            'browser_version' => '',
+            'os' => $sOs,
+            'os_version' => $this->_string($aPayload, 'os_version', 16, '/^[0-9.]+$/'),
+            'device' => $sDevice,
+            'bot' => false,
+        ];
+    }
+
+    protected function _isExcludedProfile($iProfileId)
+    {
+        $aExcluded = $this->_oConfig->getExcludedLevels();
+        if(empty($aExcluded))
+            return false;
+
+        $aMembership = BxDolAcl::getInstance()->getMemberMembershipInfo($iProfileId);
+
+        return !empty($aMembership['id']) && in_array((int)$aMembership['id'], $aExcluded, true);
+    }
+
+    /**
+     * Excluded paths are prefixes, or patterns with * for any characters.
+     */
+    protected function _isExcludedPath($sPath, $aExcluded)
+    {
+        foreach($aExcluded as $sPattern) {
+            if(bx_mb_strpos($sPattern, '*') === false) {
+                if(bx_mb_strpos($sPath, $sPattern) === 0)
+                    return true;
+                continue;
+            }
+
+            if(preg_match('/^' . str_replace('\*', '.*', preg_quote($sPattern, '/')) . '$/', $sPath))
+                return true;
+        }
+
+        return false;
+    }
+
+    /**
+     * Host, path and full URL of a hit. The path keeps its query string, because classic
+     * pages are told apart by it (page.php?i=view-post&id=5), but drops tracking parameters
+     * and secrets (a reset key, a confirmation code, a token), and what is left is redacted
+     * as error paths are (addresses, tokens, long hashes).
+     *
+     * @return array|false
+     */
+    protected function _parseUrl($sUrl)
+    {
+        $sUrl = trim($sUrl);
+        if($sUrl === '' || get_mb_len($sUrl) > 2048)
+            return false;
+
+        $aParts = parse_url($sUrl);
+        if($aParts === false)
+            return false;
+
+        if(isset($aParts['scheme']) && !in_array(mb_strtolower($aParts['scheme']), ['http', 'https'], true))
+            return false;
+
+        $sPath = isset($aParts['path']) && $aParts['path'] !== '' ? $aParts['path'] : '/';
+        if($sPath[0] !== '/')
+            $sPath = '/' . $sPath;
+
+        if(!empty($aParts['query'])) {
+            $sIgnored = $this->_getIgnoredParamsPattern();
+            $aQuery = [];
+            foreach(explode('&', $aParts['query']) as $sPair) {
+                if($sPair === '')
+                    continue;
+                $sKey = mb_strtolower(urldecode(explode('=', $sPair, 2)[0]));
+                if(!in_array($sKey, $this->_aDropParams, true) && !$this->_isSecretParam($sKey) && ($sIgnored === '' || !preg_match($sIgnored, $sKey)))
+                    $aQuery[] = $sPair;
+            }
+            if(!empty($aQuery))
+                $sPath .= '?' . implode('&', $aQuery);
+        }
+
+        return [
+            'url' => $sUrl,
+            'hostname' => isset($aParts['host']) ? mb_strtolower($aParts['host']) : '',
+            'path' => get_mb_substr(BxAnalyticsErrors::redact(rawurldecode($sPath)), 0, 255),
+        ];
+    }
+
+    /**
+     * Whether a query parameter's name says it carries a secret or a person's identity.
+     */
+    protected function _isSecretParam($sKey)
+    {
+        return in_array($sKey, $this->_aSecretParams, true) || preg_match($this->_sSecretParamsPattern, $sKey);
+    }
+
+    /**
+     * One regular expression for the ignored query parameters setting, or ''.
+     */
+    protected function _getIgnoredParamsPattern()
+    {
+        if($this->_sIgnoredParams !== null)
+            return $this->_sIgnoredParams;
+
+        $aPatterns = [];
+        foreach($this->_oConfig->getIgnoredParams() as $sParam)
+            $aPatterns[] = str_replace('\\*', '.*', preg_quote(mb_strtolower($sParam), '/'));
+
+        return $this->_sIgnoredParams = empty($aPatterns) ? '' : '/^(' . implode('|', $aPatterns) . ')$/u';
+    }
+
+    /**
+     * Scalar properties only, with short keys, within the size limit.
+     */
+    protected function _getProps($aProps)
+    {
+        $aResult = [];
+        foreach($aProps as $sKey => $mixedValue) {
+            if(count($aResult) >= 30)
+                break;
+
+            if(!is_string($sKey) || !preg_match('/^[a-zA-Z0-9_.-]{1,40}$/', $sKey))
+                continue;
+
+            if(is_bool($mixedValue) || is_int($mixedValue) || is_float($mixedValue))
+                $aResult[$sKey] = $mixedValue;
+            elseif(is_string($mixedValue))
+                $aResult[$sKey] = get_mb_substr($mixedValue, 0, 255);
+        }
+
+        while(!empty($aResult) && get_mb_len(json_encode($aResult, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)) > $this->_oConfig->CNF['MAX_PROPS_LENGTH'])
+            array_pop($aResult);
+
+        return $aResult;
+    }
+
+    /**
+     * Count a request's hits against the address's limit for the minute. Past the limit the
+     * hits are dropped, and the request is answered as usual (the classic tracker's 204,
+     * `stored: 0` over the API) rather than with 429: NEO stops sending for the rest of the
+     * visit on any answer but 200, and a short burst (queued hits sent at once, an office or
+     * a school behind one address) shouldn't silence real visitors once it is over. The
+     * next minute counts anew. Hits dropped still count, so a flood stays over the limit for
+     * the whole minute.
+     *
+     * @return bool whether the hits may be stored
+     */
+    protected function _isWithinHitLimit($sIp, $iHits)
+    {
+        $iLimit = $this->_oConfig->getLimitHitsPerMinute();
+        if(!$iLimit || !$iHits)
+            return true;
+
+        // a counter that couldn't be written (0) doesn't stop tracking
+        $iCount = $this->_oDb->addLimitHits($this->_getLimitKey('hits', $sIp), $this->_getLimitBucket(60), $iHits);
+        if($iCount > $iLimit)
+            $this->_bLimited = true;
+
+        return !$this->_bLimited;
+    }
+
+    /**
+     * Count a new visit against the address's limit for the hour.
+     *
+     * @return bool whether the visit may start
+     */
+    protected function _isWithinVisitLimit($sIp)
+    {
+        $iLimit = $this->_oConfig->getLimitVisitsPerHour();
+        if(!$iLimit)
+            return true;
+
+        $iCount = $this->_oDb->addLimitHits($this->_getLimitKey('visits', $sIp), $this->_getLimitBucket(3600), 1);
+        if($iCount > $iLimit)
+            $this->_bLimited = true;
+
+        return !$this->_bLimited;
+    }
+
+    /**
+     * Whether an address is at a limit now: its next hit, or its next new visit, would be
+     * dropped. Reads the counters without adding to them.
+     */
+    protected function _isOverLimit($sIp)
+    {
+        $iHits = $this->_oConfig->getLimitHitsPerMinute();
+        if($iHits && $this->_oDb->getLimitHits($this->_getLimitKey('hits', $sIp), $this->_getLimitBucket(60)) >= $iHits)
+            return true;
+
+        $iVisits = $this->_oConfig->getLimitVisitsPerHour();
+
+        return $iVisits && $this->_oDb->getLimitHits($this->_getLimitKey('visits', $sIp), $this->_getLimitBucket(3600)) >= $iVisits;
+    }
+
+    /**
+     * Start of the running minute or hour.
+     */
+    protected function _getLimitBucket($iSeconds)
+    {
+        return $this->_iNow - $this->_iNow % $iSeconds;
+    }
+
+    /**
+     * 8-byte key of a rate limit counter: the kind and the address hashed with the day's
+     * salt, so the table never holds an address. An IPv6 address counts by its /64 network,
+     * which one device or home usually has to itself and can change addresses within at will.
+     */
+    protected function _getLimitKey($sKind, $sIp)
+    {
+        $sAddress = (string)$sIp;
+        if(filter_var($sAddress, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) {
+            $sBinary = inet_pton($sAddress);
+            // an IPv4 address written as IPv6 (::ffff:1.2.3.4) is that IPv4 address
+            if(substr($sBinary, 0, 12) === str_repeat("\0", 10) . "\xff\xff")
+                $sAddress = inet_ntop(substr($sBinary, 12));
+            else
+                $sAddress = bin2hex(substr($sBinary, 0, 8)) . '/64';
+        }
+
+        return substr($this->_hash('limit:' . $sKind . '|' . $sAddress, $this->_getSalt(gmdate('Y-m-d', $this->_iNow))), 0, 8);
+    }
+
+    protected function _getSalt($sDay)
+    {
+        $this->_loadSalts([$sDay]);
+
+        return $this->_aSalts[$sDay];
+    }
+
+    /**
+     * Read the salts of some days in one query, for this collector's request.
+     */
+    protected function _loadSalts($aDays)
+    {
+        $aMissing = array_values(array_diff($aDays, array_keys($this->_aSalts)));
+        if(!empty($aMissing))
+            $this->_aSalts = array_merge($this->_aSalts, $this->_oDb->getSalts($aMissing));
+    }
+
+    /**
+     * 16-byte visitor id.
+     */
+    protected function _hash($sValue, $sKey)
+    {
+        // a binary digest, not text
+        return substr(hash_hmac('sha256', $sValue, $sKey, true), 0, 16);
+    }
+
+    protected function _string($a, $sKey, $iMaxLength, $sPattern = '')
+    {
+        if(!isset($a[$sKey]) || !is_string($a[$sKey]))
+            return '';
+
+        $s = trim(preg_replace('/[\x00-\x1F\x7F]+/u', ' ', $a[$sKey]));
+        $s = get_mb_substr($s, 0, $iMaxLength);
+        if($s === '' || ($sPattern && !preg_match($sPattern, $s)))
+            return '';
+
+        return $s;
+    }
+}
+
+/** @} */

+ 385 - 0
modules/boonex/analytics/updates/15.0.1_15.0.2/source/classes/BxAnalyticsConfig.php

@@ -0,0 +1,385 @@
+<?php defined('BX_DOL') or die('hack attempt');
+/**
+ * Copyright (c) UNA, Inc - https://una.io
+ * MIT License - https://opensource.org/licenses/MIT
+ *
+ * @defgroup    Analytics Analytics
+ * @ingroup     UnaModules
+ *
+ * @{
+ */
+
+bx_import('BxDolModuleConfig');
+
+define('BX_ANALYTICS_PLATFORM_CORE_WEB', 'core-web');
+define('BX_ANALYTICS_PLATFORM_NEXT_WEB', 'next-web');
+define('BX_ANALYTICS_PLATFORM_RN_IOS', 'rn-ios');
+define('BX_ANALYTICS_PLATFORM_RN_ANDROID', 'rn-android');
+define('BX_ANALYTICS_PLATFORM_API', 'api');
+define('BX_ANALYTICS_PLATFORM_CORE', 'core');
+
+define('BX_ANALYTICS_IDENTITY_COOKIELESS', 'cookieless');
+define('BX_ANALYTICS_IDENTITY_PERSISTENT', 'persistent');
+
+define('BX_ANALYTICS_GEO_NONE', 'none');
+define('BX_ANALYTICS_GEO_HEADERS', 'headers');
+define('BX_ANALYTICS_GEO_CLOUDFLARE', 'cloudflare');
+define('BX_ANALYTICS_GEO_VERCEL', 'vercel');
+define('BX_ANALYTICS_GEO_CLOUDFRONT', 'cloudfront');
+
+// hit and event types: pageviews, custom events, page leaves (engaged time), site actions
+define('BX_ANALYTICS_HIT_PAGEVIEW', 'pageview');
+define('BX_ANALYTICS_HIT_EVENT', 'event');
+define('BX_ANALYTICS_HIT_LEAVE', 'leave');
+define('BX_ANALYTICS_HIT_UNA', 'una');
+
+// health hits: a client error, and a page's web vitals (or an app screen's load times); they
+// are stored apart from events and never start a visit
+define('BX_ANALYTICS_HIT_ERROR', 'error');
+define('BX_ANALYTICS_HIT_VITALS', 'vitals');
+
+class BxAnalyticsConfig extends BxBaseModGeneralConfig
+{
+    /**
+     * Where hits come from, as code => language key. Codes are stored with every session and
+     * event, so they never change; a new client is a new code.
+     * - core-web: the classic UNA web UI (PHP and HTML)
+     * - next-web, rn-ios, rn-android: NEO on the web (Next.js) and its React Native apps
+     * - api: any other client calling api.php directly
+     * - core: the server itself, with no client request behind it (cron, background jobs)
+     */
+    protected $_aPlatforms = [
+        BX_ANALYTICS_PLATFORM_CORE_WEB => '_bx_analytics_platform_core_web',
+        BX_ANALYTICS_PLATFORM_NEXT_WEB => '_bx_analytics_platform_next_web',
+        BX_ANALYTICS_PLATFORM_RN_IOS => '_bx_analytics_platform_rn_ios',
+        BX_ANALYTICS_PLATFORM_RN_ANDROID => '_bx_analytics_platform_rn_android',
+        BX_ANALYTICS_PLATFORM_API => '_bx_analytics_platform_api',
+        BX_ANALYTICS_PLATFORM_CORE => '_bx_analytics_platform_core',
+    ];
+
+    /**
+     * Platforms that run in a browser, where the user agent tells the browser and the device.
+     */
+    protected $_aBrowserPlatforms = [
+        BX_ANALYTICS_PLATFORM_CORE_WEB,
+        BX_ANALYTICS_PLATFORM_NEXT_WEB,
+    ];
+
+    /**
+     * Native apps, which report their system and device themselves.
+     */
+    protected $_aAppPlatforms = [
+        BX_ANALYTICS_PLATFORM_RN_IOS,
+        BX_ANALYTICS_PLATFORM_RN_ANDROID,
+    ];
+
+    function __construct($aModule)
+    {
+        parent::__construct($aModule);
+
+        $this->CNF = [
+            'TABLE_SESSIONS' => $aModule['db_prefix'] . 'sessions',
+            'TABLE_EVENTS' => $aModule['db_prefix'] . 'events',
+            'TABLE_SALTS' => $aModule['db_prefix'] . 'salts',
+            'TABLE_LIMITS' => $aModule['db_prefix'] . 'limits',
+            'TABLE_BOTS' => $aModule['db_prefix'] . 'bots',
+            'TABLE_BOT_PAGES' => $aModule['db_prefix'] . 'bot_pages',
+            'TABLE_GOALS' => $aModule['db_prefix'] . 'goals',
+            'TABLE_FUNNELS' => $aModule['db_prefix'] . 'funnels',
+            'TABLE_REVENUE' => $aModule['db_prefix'] . 'revenue',
+            'TABLE_SUBSCRIPTIONS' => $aModule['db_prefix'] . 'subscriptions',
+            'TABLE_ERRORS' => $aModule['db_prefix'] . 'errors',
+            'TABLE_ERROR_HITS' => $aModule['db_prefix'] . 'error_hits',
+            'TABLE_VITALS' => $aModule['db_prefix'] . 'vitals',
+
+            // import of past payments: the cursors, rows read per service call, and the time
+            // one step may take in the hourly cron and from the Studio button
+            'OPTION_BACKFILL' => 'bx_analytics_revenue_backfill',
+            'OPTION_BACKFILL_SBS' => 'bx_analytics_revenue_backfill_sbs',
+            'BACKFILL_PAGE' => 500,
+            'BACKFILL_SECONDS' => 10,
+
+            // funnel steps at most
+            'MAX_FUNNEL_STEPS' => 6,
+            'MAX_GOALS' => 20,
+
+            // Query: values drawn on the chart, and rows in a CSV download at most
+            'QUERY_SERIES_VALUES' => 5,
+            'MAX_EXPORT_ROWS' => 10000,
+
+            // report data is cached this many seconds per report, filters, parameters,
+            // language and kind of viewer (live reports aren't); goal, funnel and error
+            // status changes drop the copies
+            'REPORT_CACHE_TTL' => 180,
+            'REPORT_CACHE_PREFIX' => 'bx_analytics_report_',
+
+            // hits per request and their properties, to bound the work of one beacon, and the
+            // largest request body read at all (20 hits, 10 of them errors with long stacks)
+            'MAX_HITS_PER_REQUEST' => 20,
+            'MAX_PROPS_LENGTH' => 2048,
+            'MAX_COLLECT_BYTES' => 256 * 1024,
+
+            // health: error hits per request and per visit, new error groups per hour (more go
+            // to one catch-all group), how old a deferred error may be (an app that crashed
+            // sends it on the next launch), and vitals hits per request
+            'MAX_ERRORS_PER_REQUEST' => 10,
+            'MAX_ERRORS_PER_SESSION' => 50,
+            'MAX_NEW_ERROR_GROUPS_PER_HOUR' => 200,
+            'MAX_ERROR_AGE' => 7 * 86400,
+            'MAX_VITALS_PER_REQUEST' => 1,
+
+            // vitals thresholds, metric => [good up to, poor above]: milliseconds, and CLS in
+            // thousandths (the web.dev and Lighthouse values; app screens and starts by RN guides)
+            'VITALS' => [
+                'lcp' => [2500, 4000],
+                'inp' => [200, 500],
+                'cls' => [100, 250],
+                'fcp' => [1800, 3000],
+                'ttfb' => [800, 1800],
+                'screen_load' => [1000, 2500],
+                'app_start' => [2000, 4000],
+            ],
+
+            // engaged time above this on one page is a tab left open, not reading
+            'MAX_ENGAGED_MS' => 30 * 60 * 1000,
+
+            // rows deleted per query when pruning, and the time one pruning run may take
+            'PRUNING_BATCH' => 5000,
+            'PRUNING_SECONDS' => 20,
+        ];
+
+        $this->_aJsClasses = [
+            'analytics' => 'BxAnalytics',
+            'dashboard' => 'BxAnalyticsDashboard',
+            'tools' => 'BxAnalyticsStudioTools',
+        ];
+        $this->_aJsObjects = [
+            'analytics' => 'oAnalytics',
+            'dashboard' => 'oBxAnalyticsDashboard',
+            'tools' => 'oBxAnalyticsStudioTools',
+        ];
+    }
+
+    public function getPlatforms()
+    {
+        return $this->_aPlatforms;
+    }
+
+    public function isPlatform($sPlatform)
+    {
+        return is_string($sPlatform) && isset($this->_aPlatforms[$sPlatform]);
+    }
+
+    public function isBrowserPlatform($sPlatform)
+    {
+        return in_array($sPlatform, $this->_aBrowserPlatforms, true);
+    }
+
+    public function isAppPlatform($sPlatform)
+    {
+        return in_array($sPlatform, $this->_aAppPlatforms, true);
+    }
+
+    public function isTrackingEnabled()
+    {
+        return getParam('bx_analytics_tracking') == 'on';
+    }
+
+    public function isPlatformEnabled($sPlatform)
+    {
+        return $this->isPlatform($sPlatform) && in_array($sPlatform, $this->_getList('bx_analytics_platforms'), true);
+    }
+
+    public function getSessionTimeout()
+    {
+        return 60 * max(1, (int)getParam('bx_analytics_session_timeout'));
+    }
+
+    /**
+     * Hits the public collector takes from one address in a minute, 0 for no limit.
+     */
+    public function getLimitHitsPerMinute()
+    {
+        return max(0, (int)getParam('bx_analytics_limit_hits'));
+    }
+
+    /**
+     * Visits that may start from one address in an hour, 0 for no limit.
+     */
+    public function getLimitVisitsPerHour()
+    {
+        return max(0, (int)getParam('bx_analytics_limit_visits'));
+    }
+
+    public function getOnlineWindow()
+    {
+        return 60 * max(1, (int)getParam('bx_analytics_online_window'));
+    }
+
+    public function getIdentityMode()
+    {
+        return getParam('bx_analytics_identity') == BX_ANALYTICS_IDENTITY_PERSISTENT ? BX_ANALYTICS_IDENTITY_PERSISTENT : BX_ANALYTICS_IDENTITY_COOKIELESS;
+    }
+
+    public function isLinkMembers()
+    {
+        return getParam('bx_analytics_link_members') == 'on';
+    }
+
+    public function isTrackActions()
+    {
+        return getParam('bx_analytics_track_actions') == 'on';
+    }
+
+    public function isTrackBots()
+    {
+        return getParam('bx_analytics_track_bots') == 'on';
+    }
+
+    public function isRespectDnt()
+    {
+        return getParam('bx_analytics_respect_dnt') == 'on';
+    }
+
+    public function getExcludedLevels()
+    {
+        return array_map('intval', $this->_getList('bx_analytics_exclude_levels'));
+    }
+
+    public function getExcludedIps()
+    {
+        return $this->_getList('bx_analytics_exclude_ips');
+    }
+
+    public function getExcludedPaths()
+    {
+        return $this->_getList('bx_analytics_exclude_paths');
+    }
+
+    /**
+     * Query parameters left out of stored paths, as patterns with * for any characters.
+     */
+    public function getIgnoredParams()
+    {
+        return $this->_getList('bx_analytics_ignore_params');
+    }
+
+    public function getTrustedProxies()
+    {
+        return $this->_getList('bx_analytics_trusted_proxies');
+    }
+
+    /**
+     * Where the visitor's location comes from: none, the headers of any CDN (the first one
+     * found), or the headers of one CDN only (the others can be sent by the client, since an
+     * edge sets its own headers and passes the rest through).
+     */
+    public function getGeoSource()
+    {
+        $sSource = getParam('bx_analytics_geo_source');
+
+        return in_array($sSource, [BX_ANALYTICS_GEO_HEADERS, BX_ANALYTICS_GEO_CLOUDFLARE, BX_ANALYTICS_GEO_VERCEL, BX_ANALYTICS_GEO_CLOUDFRONT], true) ? $sSource : BX_ANALYTICS_GEO_NONE;
+    }
+
+    /**
+     * The CDN whose location headers are read ('' for any), or false when the location isn't
+     * read at all.
+     *
+     * @return string|false
+     */
+    public function getGeoEdge()
+    {
+        $sSource = $this->getGeoSource();
+        if($sSource == BX_ANALYTICS_GEO_NONE)
+            return false;
+
+        return $sSource == BX_ANALYTICS_GEO_HEADERS ? '' : $sSource;
+    }
+
+    public function getRetentionDays()
+    {
+        return max(1, (int)getParam('bx_analytics_retention_days'));
+    }
+
+    public function isTrackErrors()
+    {
+        return getParam('bx_analytics_track_errors') == 'on';
+    }
+
+    public function isTrackVitals()
+    {
+        return getParam('bx_analytics_track_vitals') == 'on';
+    }
+
+    /**
+     * Percent of page loads that measure vitals, 0 to 100; the client samples.
+     */
+    public function getVitalsSample()
+    {
+        return min(100, max(0, (int)getParam('bx_analytics_vitals_sample')));
+    }
+
+    /**
+     * Error messages that are not stored, one pattern per line with * for any characters.
+     * Lines, not commas: messages have spaces and commas.
+     */
+    public function getErrorsIgnore()
+    {
+        $sValue = trim((string)getParam('bx_analytics_errors_ignore'));
+        if($sValue === '')
+            return [];
+
+        $aLines = preg_split('/\R/u', $sValue);
+        if($aLines === false)
+            return [];
+
+        return array_values(array_filter(array_map('trim', $aLines), 'get_mb_len'));
+    }
+
+    /**
+     * Vitals are kept for this many days, at most as long as the rest.
+     */
+    public function getVitalsRetentionDays()
+    {
+        return min($this->getRetentionDays(), max(1, (int)getParam('bx_analytics_vitals_retention_days')));
+    }
+
+    /**
+     * Hosts that belong to the site: a referrer from one of them isn't a traffic source.
+     */
+    public function getInternalHosts()
+    {
+        $aHosts = [parse_url(BX_DOL_URL_ROOT, PHP_URL_HOST)];
+
+        /**
+         * @hooks
+         * @hookdef hook-bx_analytics-get_internal_hosts 'bx_analytics', 'get_internal_hosts' - hook to add the hosts of other clients (NEO web, white-label domains) whose referrers are internal
+         * - $unit_name - equals `bx_analytics`
+         * - $action - equals `get_internal_hosts`
+         * - $object_id - not used
+         * - $sender_id - not used
+         * - $extra_params - array of additional params with the following array keys:
+         *      - `hosts` - [array] by ref, list of host names, can be overridden in hook processing
+         * @hook @ref hook-bx_analytics-get_internal_hosts
+         */
+        bx_alert($this->getName(), 'get_internal_hosts', 0, 0, [
+            'hosts' => &$aHosts,
+        ]);
+
+        return array_filter($aHosts);
+    }
+
+    /**
+     * Values of a list setting, which Studio stores comma separated; text settings may use lines too.
+     */
+    protected function _getList($sName)
+    {
+        $sValue = trim((string)getParam($sName));
+        if($sValue === '')
+            return [];
+
+        return array_values(array_filter(array_map('trim', preg_split('/[\s,]+/', $sValue)), 'get_mb_len'));
+    }
+}
+
+/** @} */

+ 80 - 0
modules/boonex/analytics/updates/15.0.1_15.0.2/source/classes/BxAnalyticsCsv.php

@@ -0,0 +1,80 @@
+<?php defined('BX_DOL') or die('hack attempt');
+/**
+ * Copyright (c) UNA, Inc - https://una.io
+ * MIT License - https://opensource.org/licenses/MIT
+ *
+ * @defgroup    Analytics Analytics
+ * @ingroup     UnaModules
+ *
+ * @{
+ */
+
+/**
+ * CSV downloads of reports. Paths, titles, referrers and campaign tags come from visitors, so
+ * a text cell that a spreadsheet would read as a formula is turned into plain text.
+ */
+class BxAnalyticsCsv
+{
+    /**
+     * A character that makes a spreadsheet evaluate a cell it starts, where a cell can start:
+     * at the start of the text, and after a ; or a line break. Spreadsheets in many locales
+     * (Russian, German, French) split a CSV line on ;, and a quote in the middle of a field
+     * doesn't enclose anything, so 'x;=1+1' would be two cells, the second a formula.
+     * Every character here is ASCII, which never occurs inside a multibyte UTF-8 character,
+     * so the pattern works on bytes and never fails on broken text.
+     */
+    const FORMULA_PATTERN = '/(?:^|(?<=[;\r\n]))( *)([=+\-@\t\r])/';
+
+    /**
+     * A cell: numbers as they are, text with a ' before anything that would start a formula.
+     */
+    public static function cell($mixedValue)
+    {
+        if(is_int($mixedValue) || is_float($mixedValue))
+            return $mixedValue;
+
+        if(is_bool($mixedValue))
+            return (int)$mixedValue;
+
+        $sValue = (string)$mixedValue;
+        if($sValue === '')
+            return $sValue;
+
+        return preg_replace(self::FORMULA_PATTERN, "$1'$2", $sValue);
+    }
+
+    /**
+     * A download's file name from known parts only: analytics-<report>-<dimension>-<from>-<to>.csv.
+     * Anything but letters, digits, _ and - is dropped from each part.
+     */
+    public static function getFilename($aParts)
+    {
+        $aResult = ['analytics'];
+        foreach($aParts as $sPart) {
+            $sPart = preg_replace('/[^A-Za-z0-9_-]+/', '', (string)$sPart);
+            if($sPart !== '')
+                $aResult[] = $sPart;
+        }
+
+        return implode('-', $aResult) . '.csv';
+    }
+
+    /**
+     * Write a header row and rows to a stream: UTF-8 with a byte order mark (so spreadsheets
+     * read it as UTF-8), commas, quotes doubled inside quoted cells.
+     *
+     * @param resource $rStream
+     * @param array $aHeader column titles
+     * @param array $aRows rows of cells
+     */
+    public static function write($rStream, $aHeader, $aRows)
+    {
+        fwrite($rStream, "\xEF\xBB\xBF");
+
+        fputcsv($rStream, array_map([self::class, 'cell'], array_values($aHeader)), ',', '"', '');
+        foreach($aRows as $aRow)
+            fputcsv($rStream, array_map([self::class, 'cell'], array_values($aRow)), ',', '"', '');
+    }
+}
+
+/** @} */

+ 2722 - 0
modules/boonex/analytics/updates/15.0.1_15.0.2/source/classes/BxAnalyticsDb.php

@@ -0,0 +1,2722 @@
+<?php defined('BX_DOL') or die('hack attempt');
+/**
+* Copyright (c) UNA, Inc - https://una.io
+* MIT License - https://opensource.org/licenses/MIT
+*
+* @defgroup    Analytics Analytics
+* @ingroup     UnaModules
+*
+* @{
+*/
+
+/*
+ * Module database queries
+ */
+class BxAnalyticsDb extends BxBaseModGeneralDb
+{
+    /**
+     * Report dimensions stored on sessions, as name => column. Filters and top lists accept
+     * these names only, so no user input reaches SQL as an identifier.
+     */
+    protected $_aSessionDimensions = [
+        'platform' => 'platform',
+        'app' => 'app',
+        'app_version' => 'app_version',
+        'hostname' => 'hostname',
+        'entry_path' => 'entry_path',
+        'exit_path' => 'exit_path',
+        'referrer' => 'referrer',
+        'referrer_host' => 'referrer_host',
+        'channel' => 'channel',
+        'utm_source' => 'utm_source',
+        'utm_medium' => 'utm_medium',
+        'utm_campaign' => 'utm_campaign',
+        'utm_term' => 'utm_term',
+        'utm_content' => 'utm_content',
+        'country' => 'country',
+        'region' => 'region',
+        'city' => 'city',
+        'language' => 'language',
+        'browser' => 'browser',
+        'os' => 'os',
+        'device' => 'device',
+        'screen' => 'screen',
+    ];
+
+    /**
+     * Report dimensions stored on pageview events.
+     */
+    protected $_aEventDimensions = [
+        'path' => 'path',
+        'title' => 'title',
+    ];
+
+    /**
+     * What a breakdown of a session dimension may be sorted by, as metric => SQL over the
+     * sessions (alias `s`). Fixed expressions: the metric name from the request only picks one.
+     */
+    protected $_aSessionMetrics = [
+        'visitors' => 'COUNT(DISTINCT `s`.`visitor`)',
+        'sessions' => 'COUNT(*)',
+        'pageviews' => 'SUM(`s`.`pageviews`)',
+        'pages_per_session' => 'SUM(`s`.`pageviews`) / COUNT(*)',
+        'bounce_rate' => 'SUM(`s`.`bounce`) / COUNT(*)',
+        'duration' => 'SUM(`s`.`duration`) / COUNT(*)',
+    ];
+
+    /**
+     * The same for an event dimension, over pageview events (alias `e`).
+     */
+    protected $_aEventMetrics = [
+        'visitors' => 'COUNT(DISTINCT `e`.`visitor`)',
+        'pageviews' => 'COUNT(*)',
+        'engaged' => 'IFNULL(AVG(NULLIF(`e`.`engaged`, 0)), 0)',
+    ];
+
+    /**
+     * Pages and titles grouped with the number of their visitors: sorted first, then counted.
+     * Read in time order (the `type_ts` key, which the optimizer takes for a short period or
+     * with a visit filter) and grouped in a temporary table, a 255 character key with a
+     * distinct count per group spilled to disk: 160 s for 30 days of 10 million events, against
+     * 1 to 4 s sorted.
+     */
+    const SQL_GROUP_WIDE = 'SQL_BIG_RESULT';
+
+    /**
+     * Optimizer hint for an IN subquery: run it once into a temporary table (materialization),
+     * not as a join nor once per row of the outer query. Next to NO_SEMIJOIN, MariaDB
+     * ignores it as conflicting. See _getVisitsInSql.
+     */
+    const SQL_SUBQUERY_FIRST = '/*+ SUBQUERY(MATERIALIZATION) */';
+
+    /**
+     * Whether the report session settings were applied on this connection.
+     */
+    protected static $_bReportSession = false;
+
+    /**
+     * Session settings for report queries, once per connection. MariaDB, and MySQL before
+     * 8.0.20, sort rows wider than max_length_for_sort_data (1024 bytes) by row id and read
+     * every row again after the sort: a top list of pages (a 255 character utf8mb4 path)
+     * over 90 days then reads each of its pageviews twice, in random order. A larger limit
+     * keeps the columns in the sort. MySQL 8.0.20 and later always do that and deprecated
+     * the variable, so it is left alone there.
+     */
+    public function setReportSession()
+    {
+        if(self::$_bReportSession)
+            return;
+        self::$_bReportSession = true;
+
+        $sVersion = (string)BxDolDb::getLink()->getAttribute(PDO::ATTR_SERVER_VERSION);
+        if(stripos($sVersion, 'mariadb') === false && !version_compare($sVersion, '8.0.20', '<'))
+            return;
+
+        $this->query("SET SESSION `max_length_for_sort_data` = 8192");
+    }
+
+    public function isDimension($sName)
+    {
+        return isset($this->_aSessionDimensions[$sName]) || isset($this->_aEventDimensions[$sName]);
+    }
+
+    public function isEventDimension($sName)
+    {
+        return isset($this->_aEventDimensions[$sName]);
+    }
+
+    /**
+     * Every report dimension, as name => kind (event or session): pages first.
+     */
+    public function getDimensions()
+    {
+        $aResult = [];
+        foreach(array_keys($this->_aEventDimensions) as $sName)
+            $aResult[$sName] = 'event';
+        foreach(array_keys($this->_aSessionDimensions) as $sName)
+            $aResult[$sName] = 'session';
+
+        return $aResult;
+    }
+
+    /**
+     * Metrics a breakdown of a dimension may be sorted by, the default first; empty for an
+     * unknown dimension.
+     */
+    public function getBreakdownMetrics($sDimension)
+    {
+        if(isset($this->_aEventDimensions[$sDimension]))
+            return array_keys($this->_aEventMetrics);
+
+        return isset($this->_aSessionDimensions[$sDimension]) ? array_keys($this->_aSessionMetrics) : [];
+    }
+
+    /*
+     * Collecting
+     */
+
+    /**
+     * The salt for a day, created by the first hit of that day.
+     */
+    public function getSalt($sDay)
+    {
+        $aSalts = $this->getSalts([$sDay]);
+
+        return $aSalts[$sDay];
+    }
+
+    /**
+     * The salts of some days, [day => salt]. Every hit needs them and only the first hit of a
+     * day creates one, so they are read first, in one query, and written only when missing.
+     */
+    public function getSalts($aDays)
+    {
+        $sTable = $this->_oConfig->CNF['TABLE_SALTS'];
+
+        $aBindings = [];
+        $sIn = $this->_getInSql(array_values(array_unique($aDays)), 'd', $aBindings);
+        $aSalts = $this->getPairs("SELECT `day`, `salt` FROM `" . $sTable . "` WHERE `day` IN (" . $sIn . ")", 'day', 'salt', $aBindings);
+
+        $aResult = [];
+        foreach($aDays as $sDay) {
+            if(!isset($aSalts[$sDay])) {
+                $this->query("INSERT IGNORE INTO `" . $sTable . "` (`day`, `salt`) VALUES (:day, :salt)", [
+                    'day' => $sDay,
+                    'salt' => random_bytes(32),
+                ]);
+                $aSalts[$sDay] = $this->getOne("SELECT `salt` FROM `" . $sTable . "` WHERE `day` = :day", ['day' => $sDay]);
+            }
+
+            $aResult[$sDay] = (string)$aSalts[$sDay];
+        }
+
+        return $aResult;
+    }
+
+    /**
+     * Latest session of any of the visitor ids on a platform, active since a time.
+     */
+    public function getActiveSession($aVisitors, $sPlatform, $iSince, $iUntil = 0)
+    {
+        $aBindings = ['platform' => $sPlatform, 'since' => $iSince];
+        $sUntil = '';
+        if($iUntil) {
+            $sUntil = ' AND `started` <= :until';
+            $aBindings['until'] = (int)$iUntil;
+        }
+        $aIn = [];
+        foreach(array_values(array_unique($aVisitors)) as $i => $sVisitor) {
+            $aIn[] = ':v' . $i;
+            $aBindings['v' . $i] = $sVisitor;
+        }
+        if(empty($aIn))
+            return [];
+
+        return $this->getRow("SELECT * FROM `" . $this->_oConfig->CNF['TABLE_SESSIONS'] . "` WHERE `visitor` IN (" . implode(', ', $aIn) . ") AND `platform` = :platform AND `last_seen` >= :since" . $sUntil . " ORDER BY `last_seen` DESC LIMIT 1", $aBindings);
+    }
+
+    public function insertSession($aSession)
+    {
+        unset($aSession['id']);
+
+        $aBindings = [];
+        foreach($aSession as $sField => $mixedValue)
+            $aBindings[$sField] = $mixedValue;
+
+        $sFields = '`' . implode('`, `', array_keys($aBindings)) . '`';
+        $sValues = ':' . implode(', :', array_keys($aBindings));
+        if(!$this->query("INSERT INTO `" . $this->_oConfig->CNF['TABLE_SESSIONS'] . "` (" . $sFields . ") VALUES (" . $sValues . ")", $aBindings))
+            return 0;
+
+        return (int)$this->lastId();
+    }
+
+    public function updateSession($iId, $aSet)
+    {
+        $aBindings = ['id' => (int)$iId];
+        $aSql = [];
+        foreach($aSet as $sField => $mixedValue) {
+            $aSql[] = '`' . $sField . '` = :f_' . $sField;
+            $aBindings['f_' . $sField] = $mixedValue;
+        }
+
+        return (int)$this->query("UPDATE `" . $this->_oConfig->CNF['TABLE_SESSIONS'] . "` SET " . implode(', ', $aSql) . " WHERE `id` = :id", $aBindings) > 0;
+    }
+
+    /**
+     * Add a request's hits to a visit's counters in the database, not from a copy read before
+     * them: two requests of one visit at once (tabs opened together, an event and a pageview
+     * sent together) both count. The time only moves forward, and the exit page is the visit's
+     * latest pageview by time, whichever request is written last.
+     *
+     * Every expression reads only columns set after it in the statement, so the result is the
+     * same whether the server assigns left to right or all at once (MariaDB's
+     * SIMULTANEOUS_ASSIGNMENT).
+     *
+     * @param int $iPageviews pageviews in the request
+     * @param int $iEvents events in the request
+     * @param bool $bInteractive a hit that isn't a bounce on its own (an interactive event)
+     * @param int $iLastSeen the request's latest hit, 0 for none
+     * @param bool $bPageview the request has a pageview, so the exit page may change
+     */
+    public function addSessionHits($iId, $iPageviews, $iEvents, $bInteractive, $iLastSeen, $bPageview)
+    {
+        $sExit = '';
+        $aBindings = [
+            'id' => (int)$iId,
+            'interactive' => $bInteractive ? 1 : 0,
+            'pv' => (int)$iPageviews,
+            'pv2' => (int)$iPageviews,
+            'ev' => (int)$iEvents,
+            'ls' => (int)$iLastSeen,
+            'ls2' => (int)$iLastSeen,
+        ];
+        if($bPageview) {
+            // the `session_id` key, a visit has few rows
+            $sExit = ", `exit_path` = IFNULL((SELECT `path` FROM `" . $this->_oConfig->CNF['TABLE_EVENTS'] . "` WHERE `session_id` = :sid AND `type` = 'pageview' ORDER BY `ts` DESC, `id` DESC LIMIT 1), `exit_path`)";
+            $aBindings['sid'] = (int)$iId;
+        }
+
+        return (int)$this->query("UPDATE `" . $this->_oConfig->CNF['TABLE_SESSIONS'] . "` SET
+            `bounce` = IF(:interactive = 1 OR `pageviews` + :pv > 1, 0, `bounce`),
+            `duration` = GREATEST(`duration`, CAST(GREATEST(`last_seen`, :ls) AS SIGNED) - CAST(`started` AS SIGNED)),
+            `last_seen` = GREATEST(`last_seen`, :ls2),
+            `pageviews` = LEAST(`pageviews` + :pv2, 65535),
+            `events` = LEAST(`events` + :ev, 65535)" . $sExit . "
+            WHERE `id` = :id", $aBindings) > 0;
+    }
+
+    /**
+     * Take a named lock on the connection, waiting up to a number of seconds. MySQL releases it
+     * when the connection closes, so a request that dies can't keep it.
+     *
+     * @return bool the lock is held
+     */
+    public function lockVisit($sName, $iTimeout)
+    {
+        return (int)$this->getOne("SELECT GET_LOCK(:name, :timeout)", ['name' => $sName, 'timeout' => (int)$iTimeout]) === 1;
+    }
+
+    public function unlockVisit($sName)
+    {
+        return (int)$this->getOne("SELECT RELEASE_LOCK(:name)", ['name' => $sName]) === 1;
+    }
+
+    /**
+     * Give every event of a visit a visitor id and a profile: the member's, when they sign in
+     * during a guest's visit. A visit has few events, found by the `session_id` key.
+     */
+    public function assignSessionEvents($iSessionId, $sVisitor, $iProfileId)
+    {
+        return (int)$this->query("UPDATE `" . $this->_oConfig->CNF['TABLE_EVENTS'] . "` SET `visitor` = :visitor, `profile_id` = :profile WHERE `session_id` = :id", ['visitor' => $sVisitor, 'profile' => (int)$iProfileId, 'id' => (int)$iSessionId]);
+    }
+
+    /**
+     * Insert events in one query.
+     *
+     * @param array $aRows rows with the same keys
+     * @return int rows inserted
+     */
+    public function insertEvents($aRows)
+    {
+        if(empty($aRows))
+            return 0;
+
+        $aFields = array_keys(reset($aRows));
+        $aBindings = [];
+        $aValues = [];
+        foreach(array_values($aRows) as $i => $aRow) {
+            $aPlaceholders = [];
+            foreach($aFields as $sField) {
+                $aPlaceholders[] = ':' . $sField . '_' . $i;
+                $aBindings[$sField . '_' . $i] = $aRow[$sField];
+            }
+            $aValues[] = '(' . implode(', ', $aPlaceholders) . ')';
+        }
+
+        return (int)$this->query("INSERT INTO `" . $this->_oConfig->CNF['TABLE_EVENTS'] . "` (`" . implode('`, `', $aFields) . "`) VALUES " . implode(', ', $aValues), $aBindings);
+    }
+
+    /**
+     * Add engaged time to the latest view of a page in a session.
+     */
+    public function addEngagedTime($iSessionId, $sPath, $iMs, $iMaxMs)
+    {
+        return (int)$this->query("UPDATE `" . $this->_oConfig->CNF['TABLE_EVENTS'] . "` SET `engaged` = LEAST(`engaged` + :ms, :max) WHERE `session_id` = :session AND `type` = 'pageview' AND `path` = :path ORDER BY `id` DESC LIMIT 1", [
+            'ms' => (int)$iMs,
+            'max' => (int)$iMaxMs,
+            'session' => (int)$iSessionId,
+            'path' => $sPath,
+        ]) > 0;
+    }
+
+    /**
+     * Count one bot request: a daily total per bot and platform, and per bot category and page.
+     */
+    public function addBotHit($sDay, $sBot, $sCategory, $sPlatform, $sPath, $iTime)
+    {
+        $CNF = &$this->_oConfig->CNF;
+
+        $bResult = $this->query("INSERT INTO `" . $CNF['TABLE_BOTS'] . "` (`day`, `bot`, `category`, `platform`, `hits`, `last_seen`) VALUES (:day, :bot, :category, :platform, 1, :ts) ON DUPLICATE KEY UPDATE `hits` = `hits` + 1, `last_seen` = GREATEST(`last_seen`, VALUES(`last_seen`))", [
+            'day' => $sDay,
+            'bot' => $sBot,
+            'category' => $sCategory,
+            'platform' => $sPlatform,
+            'ts' => (int)$iTime,
+        ]) !== false;
+
+        if($sPath !== '')
+            $this->query("INSERT INTO `" . $CNF['TABLE_BOT_PAGES'] . "` (`day`, `category`, `path_hash`, `path`, `hits`) VALUES (:day, :category, :hash, :path, 1) ON DUPLICATE KEY UPDATE `hits` = `hits` + 1", [
+                'day' => $sDay,
+                'category' => $sCategory,
+                'hash' => substr(hash('sha256', $sPath, true), 0, 8),
+                'path' => $sPath,
+            ]);
+
+        return $bResult;
+    }
+
+    /**
+     * Replace the module's subscriptions to per-module actions (content added) with the given
+     * unit => [actions] list. Fixed subscriptions from enable.sql with the same action name
+     * (comment/added) are listed in `$aFixedUnits` and stay.
+     *
+     * @return int subscriptions written
+     */
+    public function setActionAlerts($sHandler, $aActions, $aUnits, $aFixedUnits = [])
+    {
+        $iHandlerId = (int)$this->getOne("SELECT `id` FROM `sys_alerts_handlers` WHERE `name` = :name LIMIT 1", ['name' => $sHandler]);
+        if(!$iHandlerId)
+            return 0;
+
+        $aIn = [];
+        $aBindings = ['handler' => $iHandlerId];
+        foreach(array_values($aActions) as $i => $sAction) {
+            $aIn[] = ':a' . $i;
+            $aBindings['a' . $i] = $sAction;
+        }
+        $sKeep = '';
+        foreach(array_values($aFixedUnits) as $i => $sUnit) {
+            $sKeep .= ($sKeep === '' ? '' : ', ') . ':k' . $i;
+            $aBindings['k' . $i] = $sUnit;
+        }
+        $this->query("DELETE FROM `sys_alerts` WHERE `handler_id` = :handler AND `action` IN (" . implode(', ', $aIn) . ")" . ($sKeep !== '' ? " AND `unit` NOT IN (" . $sKeep . ")" : ''), $aBindings);
+
+        $iWritten = 0;
+        foreach($aUnits as $sUnit => $aUnitActions)
+            foreach($aUnitActions as $sAction)
+                $iWritten += (int)$this->query("INSERT INTO `sys_alerts` (`unit`, `action`, `handler_id`) VALUES (:unit, :action, :handler)", [
+                    'unit' => $sUnit,
+                    'action' => $sAction,
+                    'handler' => $iHandlerId,
+                ]);
+
+        return $iWritten;
+    }
+
+    /*
+     * Maintenance
+     */
+
+    public function deleteBotsBefore($sDay)
+    {
+        $CNF = &$this->_oConfig->CNF;
+
+        return (int)$this->query("DELETE FROM `" . $CNF['TABLE_BOTS'] . "` WHERE `day` < :day", ['day' => $sDay]) + (int)$this->query("DELETE FROM `" . $CNF['TABLE_BOT_PAGES'] . "` WHERE `day` < :day", ['day' => $sDay]);
+    }
+
+    /**
+     * Add to a rate limit counter and return its new value. One upsert by the primary key;
+     * LAST_INSERT_ID(expr) hands the value back without reading the row again.
+     *
+     * @param string $sKey 8 bytes, see BxAnalyticsCollector::_getLimitKey
+     * @param int $iBucket the minute's or hour's start
+     * @param int $iHits how much to add
+     * @return int the counter after the addition, 0 when it couldn't be written
+     */
+    public function addLimitHits($sKey, $iBucket, $iHits)
+    {
+        $iHits = max(1, (int)$iHits);
+        $mixedResult = $this->query("INSERT INTO `" . $this->_oConfig->CNF['TABLE_LIMITS'] . "` (`key`, `bucket`, `hits`) VALUES (:key, :bucket, LAST_INSERT_ID(:hits)) ON DUPLICATE KEY UPDATE `hits` = LAST_INSERT_ID(`hits` + :add)", [
+            'key' => $sKey,
+            'bucket' => (int)$iBucket,
+            'hits' => $iHits,
+            'add' => $iHits,
+        ]);
+        if($mixedResult === false)
+            return 0;
+
+        return (int)$this->getOne("SELECT LAST_INSERT_ID()");
+    }
+
+    /**
+     * A rate limit counter's value, without adding to it.
+     */
+    public function getLimitHits($sKey, $iBucket)
+    {
+        return (int)$this->getOne("SELECT `hits` FROM `" . $this->_oConfig->CNF['TABLE_LIMITS'] . "` WHERE `key` = :key AND `bucket` = :bucket", ['key' => $sKey, 'bucket' => (int)$iBucket]);
+    }
+
+    /**
+     * Delete up to `$iLimit` rate limit counters of minutes and hours that started before a time.
+     */
+    public function deleteLimitsBefore($iTime, $iLimit)
+    {
+        return (int)$this->query("DELETE FROM `" . $this->_oConfig->CNF['TABLE_LIMITS'] . "` WHERE `bucket` < :ts ORDER BY `bucket` LIMIT " . (int)$iLimit, ['ts' => (int)$iTime]);
+    }
+
+    public function deleteSaltsBefore($sDay)
+    {
+        return (int)$this->query("DELETE FROM `" . $this->_oConfig->CNF['TABLE_SALTS'] . "` WHERE `day` < :day", ['day' => $sDay]);
+    }
+
+    /**
+     * Delete up to `$iLimit` events or sessions older than a time; returns how many went.
+     */
+    public function deleteEventsBefore($iTime, $iLimit)
+    {
+        return (int)$this->query("DELETE FROM `" . $this->_oConfig->CNF['TABLE_EVENTS'] . "` WHERE `ts` < :ts ORDER BY `ts` LIMIT " . (int)$iLimit, ['ts' => (int)$iTime]);
+    }
+
+    public function deleteSessionsBefore($iTime, $iLimit)
+    {
+        return (int)$this->query("DELETE FROM `" . $this->_oConfig->CNF['TABLE_SESSIONS'] . "` WHERE `last_seen` < :ts ORDER BY `last_seen` LIMIT " . (int)$iLimit, ['ts' => (int)$iTime]);
+    }
+
+    /**
+     * Unlink a deleted profile: its visits and events stay as anonymous visitors'. Each visit
+     * gets a visitor id of its own, so its visits aren't tied to one another, and the events
+     * of a visit recorded before signing in lose the guest's id too (in persistent mode the
+     * device's, which the device sends again on later visits). Site actions lose the account
+     * or profile they were about (sign-up, sign-in, the profile created).
+     *
+     * @param string $sSalt random bytes the new ids are made from, thrown away after
+     */
+    public function forgetProfile($iProfileId, $sSalt)
+    {
+        $CNF = &$this->_oConfig->CNF;
+        $aBindings = ['profile' => (int)$iProfileId, 'salt' => bin2hex($sSalt)];
+        $sAbout = "IF(`e`.`type` = 'una' AND (`e`.`module` = 'account' OR `e`.`name` = 'profile_add'), 0, `e`.`object_id`)";
+
+        // the events of its visits, through the visits; then its events outside them
+        $iEvents = (int)$this->query("UPDATE `" . $CNF['TABLE_EVENTS'] . "` AS `e` INNER JOIN `" . $CNF['TABLE_SESSIONS'] . "` AS `s` ON `s`.`id` = `e`.`session_id` SET `e`.`object_id` = " . $sAbout . ", `e`.`profile_id` = 0, `e`.`visitor` = UNHEX(MD5(CONCAT(:salt, 's', `e`.`session_id`))) WHERE `s`.`profile_id` = :profile", $aBindings);
+        $iEvents += (int)$this->query("UPDATE `" . $CNF['TABLE_EVENTS'] . "` AS `e` SET `e`.`object_id` = " . $sAbout . ", `e`.`profile_id` = 0, `e`.`visitor` = UNHEX(MD5(CONCAT(:salt, 'e', `e`.`id`))) WHERE `e`.`profile_id` = :profile", $aBindings);
+        // the profile's creation recorded while visits weren't tied to members
+        $this->query("UPDATE `" . $CNF['TABLE_EVENTS'] . "` SET `object_id` = 0 WHERE `type` = 'una' AND `name` = 'profile_add' AND `object_id` = :profile", ['profile' => (int)$iProfileId]);
+
+        $iErrors = (int)$this->query("UPDATE `" . $CNF['TABLE_ERROR_HITS'] . "` SET `profile_id` = 0, `visitor` = IF(`session_id` > 0, UNHEX(MD5(CONCAT(:salt, 's', `session_id`))), UNHEX(MD5(CONCAT(:salt_hit, 'h', `id`)))) WHERE `profile_id` = :profile", $aBindings + ['salt_hit' => $aBindings['salt']]);
+
+        // the visits last: the events above were found through them
+        $iSessions = (int)$this->query("UPDATE `" . $CNF['TABLE_SESSIONS'] . "` SET `profile_id` = 0, `visitor` = UNHEX(MD5(CONCAT(:salt, 's', `id`))) WHERE `profile_id` = :profile", $aBindings);
+
+        return $iSessions + $iEvents + $iErrors;
+    }
+
+    /**
+     * Delete up to `$iLimit` error hits or vitals older than a time; returns how many went.
+     */
+    public function deleteErrorHitsBefore($iTime, $iLimit)
+    {
+        return (int)$this->query("DELETE FROM `" . $this->_oConfig->CNF['TABLE_ERROR_HITS'] . "` WHERE `ts` < :ts ORDER BY `ts` LIMIT " . (int)$iLimit, ['ts' => (int)$iTime]);
+    }
+
+    public function deleteVitalsBefore($iTime, $iLimit)
+    {
+        return (int)$this->query("DELETE FROM `" . $this->_oConfig->CNF['TABLE_VITALS'] . "` WHERE `ts` < :ts ORDER BY `ts` LIMIT " . (int)$iLimit, ['ts' => (int)$iTime]);
+    }
+
+    /**
+     * Error groups last seen before a time: their hits are gone too.
+     */
+    public function deleteErrorGroupsBefore($iTime, $iLimit)
+    {
+        return (int)$this->query("DELETE FROM `" . $this->_oConfig->CNF['TABLE_ERRORS'] . "` WHERE `last_seen` < :ts ORDER BY `last_seen` LIMIT " . (int)$iLimit, ['ts' => (int)$iTime]);
+    }
+
+    /**
+     * Drop the sample stacks of error groups taken before a time: a group that keeps
+     * happening stays, but its stack, which may hold what a visitor typed, is kept no longer
+     * than the rest of the data. The next occurrence with a stack takes a new one.
+     */
+    public function clearErrorStacksBefore($iTime, $iLimit)
+    {
+        return (int)$this->query("UPDATE `" . $this->_oConfig->CNF['TABLE_ERRORS'] . "` SET `stack` = '', `stack_ts` = 0 WHERE `stack_ts` > 0 AND `stack_ts` < :ts LIMIT " . (int)$iLimit, ['ts' => (int)$iTime]);
+    }
+
+    /*
+     * Reports. A filter is an array from BxAnalyticsFilter::get(): from, to (unix times,
+     * `to` exclusive), platform ('' for all) and dims (dimension => value).
+     */
+
+    /**
+     * Visitors, sessions, pageviews, bounces and total duration in a period.
+     */
+    public function getTotals($aFilter)
+    {
+        $aBindings = [];
+        $sWhere = $this->_getSessionsWhere($aFilter, $aBindings);
+
+        $aRow = $this->getRow("SELECT COUNT(DISTINCT `s`.`visitor`) AS `visitors`, COUNT(*) AS `sessions`, IFNULL(SUM(`s`.`pageviews`), 0) AS `pageviews`, IFNULL(SUM(`s`.`bounce`), 0) AS `bounces`, IFNULL(SUM(`s`.`duration`), 0) AS `duration` FROM " . $this->_getSessionsFrom() . " WHERE " . $sWhere, $aBindings);
+
+        // filtered by a page: count that page's views, not all views of the sessions that saw it
+        if(!empty($aRow) && $this->_hasEventDimensions($aFilter))
+            $aRow['pageviews'] = $this->_getPageviews($aFilter);
+
+        return $aRow;
+    }
+
+    /**
+     * The same metrics per time bucket.
+     *
+     * @param array $aFilter report filter
+     * @param string $sBucket hour, day, week or month
+     * @param int $iOffset timezone offset in seconds, for bucket boundaries
+     * @param array $aStarts exact bucket starts (unix times, ascending), required for months
+     *      and used for any bucket when given
+     * @return array rows keyed by bucket start time
+     */
+    public function getSeries($aFilter, $sBucket, $iOffset, $aStarts = [])
+    {
+        $aBindings = [];
+        $sWhere = $this->_getSessionsWhere($aFilter, $aBindings);
+        $sBucketSql = $this->_getBucketSql('`s`.`started`', $sBucket, $iOffset, $aStarts, $aBindings);
+
+        $aRows = $this->getAllWithKey("SELECT " . $sBucketSql . " AS `bucket`, COUNT(DISTINCT `s`.`visitor`) AS `visitors`, COUNT(*) AS `sessions`, IFNULL(SUM(`s`.`pageviews`), 0) AS `pageviews`, IFNULL(SUM(`s`.`bounce`), 0) AS `bounces`, IFNULL(SUM(`s`.`duration`), 0) AS `duration` FROM " . $this->_getSessionsFrom() . " WHERE " . $sWhere . " GROUP BY `bucket` ORDER BY `bucket`", 'bucket', $aBindings);
+        if(!$this->_hasEventDimensions($aFilter))
+            return $aRows;
+
+        // filtered by a page: that page's views in each bucket, as getTotals counts them, not
+        // all views of the visits that saw it; a bucket may have views and no visit started in it
+        $aEventBindings = [];
+        $sEventWhere = $this->_getEventsWhere($aFilter, $aEventBindings);
+        $sEventBucketSql = $this->_getBucketSql('`e`.`ts`', $sBucket, $iOffset, $aStarts, $aEventBindings);
+        $aPageviews = $this->getPairs("SELECT " . $sEventBucketSql . " AS `bucket`, COUNT(*) AS `pageviews` FROM `" . $this->_oConfig->CNF['TABLE_EVENTS'] . "` AS `e` WHERE " . $sEventWhere . " GROUP BY `bucket`", 'bucket', 'pageviews', $aEventBindings);
+
+        foreach($aRows as $mixedBucket => $aRow)
+            $aRows[$mixedBucket]['pageviews'] = isset($aPageviews[$mixedBucket]) ? (int)$aPageviews[$mixedBucket] : 0;
+
+        foreach($aPageviews as $mixedBucket => $iPageviews)
+            if(!isset($aRows[$mixedBucket]))
+                $aRows[$mixedBucket] = ['bucket' => $mixedBucket, 'visitors' => 0, 'sessions' => 0, 'pageviews' => (int)$iPageviews, 'bounces' => 0, 'duration' => 0];
+
+        ksort($aRows);
+
+        return $aRows;
+    }
+
+    /**
+     * Top values of a dimension with visitors, sessions (session dimensions) and pageviews.
+     */
+    public function getTop($aFilter, $sDimension, $iLimit, $iStart = 0)
+    {
+        $sLimit = ' LIMIT ' . max(0, (int)$iStart) . ', ' . max(1, min(1000, (int)$iLimit));
+        $aBindings = [];
+
+        if(isset($this->_aEventDimensions[$sDimension])) {
+            $sColumn = $this->_aEventDimensions[$sDimension];
+            $sWhere = $this->_getEventsWhere($aFilter, $aBindings);
+
+            // grouped by the column: events also have a `value` column, which an alias would lose to
+            return $this->getAll("SELECT " . self::SQL_GROUP_WIDE . " `e`.`" . $sColumn . "` AS `value`, COUNT(DISTINCT `e`.`visitor`) AS `visitors`, COUNT(*) AS `pageviews` FROM `" . $this->_oConfig->CNF['TABLE_EVENTS'] . "` AS `e` WHERE " . $sWhere . " GROUP BY `e`.`" . $sColumn . "` ORDER BY `visitors` DESC, `pageviews` DESC" . $sLimit, $aBindings);
+        }
+
+        if(!isset($this->_aSessionDimensions[$sDimension]))
+            return [];
+
+        $sColumn = $this->_aSessionDimensions[$sDimension];
+        $sWhere = $this->_getSessionsWhere($aFilter, $aBindings);
+
+        return $this->getAll("SELECT `s`.`" . $sColumn . "` AS `value`, COUNT(DISTINCT `s`.`visitor`) AS `visitors`, COUNT(*) AS `sessions`, IFNULL(SUM(`s`.`pageviews`), 0) AS `pageviews`, IFNULL(SUM(`s`.`bounce`), 0) AS `bounces`, IFNULL(SUM(`s`.`duration`), 0) AS `duration` FROM " . $this->_getSessionsFrom() . " WHERE " . $sWhere . " GROUP BY `s`.`" . $sColumn . "` ORDER BY `visitors` DESC, `sessions` DESC" . $sLimit, $aBindings);
+    }
+
+    /**
+     * How many different values a dimension has in the period, for paging.
+     */
+    public function getTopCount($aFilter, $sDimension)
+    {
+        $aBindings = [];
+
+        if(isset($this->_aEventDimensions[$sDimension])) {
+            $sWhere = $this->_getEventsWhere($aFilter, $aBindings);
+            return (int)$this->getOne("SELECT COUNT(DISTINCT `e`.`" . $this->_aEventDimensions[$sDimension] . "`) FROM `" . $this->_oConfig->CNF['TABLE_EVENTS'] . "` AS `e` WHERE " . $sWhere, $aBindings);
+        }
+
+        if(!isset($this->_aSessionDimensions[$sDimension]))
+            return 0;
+
+        $sWhere = $this->_getSessionsWhere($aFilter, $aBindings);
+        return (int)$this->getOne("SELECT COUNT(DISTINCT `s`.`" . $this->_aSessionDimensions[$sDimension] . "`) FROM " . $this->_getSessionsFrom() . " WHERE " . $sWhere, $aBindings);
+    }
+
+    /**
+     * Values of any dimension with every metric it has, sorted by one of them, a page at a
+     * time: the Query report and its CSV. Session dimensions get the summed session columns
+     * (visitors, sessions, pageviews, bounces, duration), event dimensions what getPageStats
+     * returns. Ties go by the number of rows, then by the value, so pages don't overlap.
+     *
+     * @param string $sMetric one of getBreakdownMetrics($sDimension); anything else sorts by the first
+     * @param string $sDir ASC or DESC (anything else is DESC)
+     * @param int $iMax the most rows one call returns
+     * @param bool $bPathSessions pages get entries, bounces and exits (see getPageStats)
+     */
+    public function getBreakdown($aFilter, $sDimension, $sMetric, $sDir, $iLimit, $iStart = 0, $iMax = 1000, $bPathSessions = true)
+    {
+        $sDir = $sDir === 'ASC' ? 'ASC' : 'DESC';
+
+        if(isset($this->_aEventDimensions[$sDimension]))
+            return $this->getPageStats($aFilter, $sDimension, $iLimit, $iStart, isset($this->_aEventMetrics[$sMetric]) ? $sMetric : 'visitors', $sDir, $iMax, $bPathSessions);
+
+        if(!isset($this->_aSessionDimensions[$sDimension]))
+            return [];
+
+        $sColumn = $this->_aSessionDimensions[$sDimension];
+        $sOrder = isset($this->_aSessionMetrics[$sMetric]) ? $this->_aSessionMetrics[$sMetric] : $this->_aSessionMetrics['visitors'];
+        $aBindings = [];
+        $sWhere = $this->_getSessionsWhere($aFilter, $aBindings);
+
+        return $this->getAll("SELECT `s`.`" . $sColumn . "` AS `value`, COUNT(DISTINCT `s`.`visitor`) AS `visitors`, COUNT(*) AS `sessions`, IFNULL(SUM(`s`.`pageviews`), 0) AS `pageviews`, IFNULL(SUM(`s`.`bounce`), 0) AS `bounces`, IFNULL(SUM(`s`.`duration`), 0) AS `duration` FROM " . $this->_getSessionsFrom() . " WHERE " . $sWhere . " GROUP BY `s`.`" . $sColumn . "` ORDER BY " . $sOrder . " " . $sDir . ", COUNT(*) " . $sDir . ", `s`.`" . $sColumn . "` ASC LIMIT " . max(0, (int)$iStart) . ", " . max(1, min((int)$iMax, (int)$iLimit)), $aBindings);
+    }
+
+    /**
+     * The metrics of some values of a dimension per time bucket: rows with bucket, value and
+     * the columns getBreakdown returns for that dimension (without entries and exits).
+     * `value` is the asked value itself: the tables compare text without case and trailing
+     * spaces, so a group's own spelling ('facebook' for 'Facebook') can differ per bucket.
+     *
+     * @param array $aValues the values to follow, a few at most
+     * @see getSeries for the bucket parameters
+     */
+    public function getBreakdownSeries($aFilter, $sDimension, $aValues, $sBucket, $iOffset, $aStarts = [])
+    {
+        if(empty($aValues))
+            return [];
+
+        $aValues = array_values(array_map('strval', $aValues));
+        $aBindings = [];
+
+        if(isset($this->_aEventDimensions[$sDimension])) {
+            $sColumn = '`e`.`' . $this->_aEventDimensions[$sDimension] . '`';
+            $sWhere = $this->_getEventsWhere($aFilter, $aBindings);
+            $sIn = $this->_getInSql($aValues, 'qv', $aBindings);
+            $sValue = $this->_getValueCaseSql($sColumn, $aValues, $aBindings);
+            $sBucketSql = $this->_getBucketSql('`e`.`ts`', $sBucket, $iOffset, $aStarts, $aBindings);
+
+            // GROUP BY looks a name up in the table before the select list, and events have a
+            // `value` column: grouped by that, every value of a bucket was one row
+            $aRows = $this->getAll("SELECT " . $sBucketSql . " AS `bucket`, " . $sValue . " AS `asked`, COUNT(DISTINCT `e`.`visitor`) AS `visitors`, COUNT(*) AS `pageviews`, IFNULL(AVG(NULLIF(`e`.`engaged`, 0)), 0) / 1000 AS `engaged` FROM `" . $this->_oConfig->CNF['TABLE_EVENTS'] . "` AS `e` WHERE " . $sWhere . " AND " . $sColumn . " IN (" . $sIn . ") GROUP BY `bucket`, `asked` ORDER BY `bucket`", $aBindings);
+            foreach($aRows as $i => $aRow) {
+                $aRows[$i] = array_merge(['bucket' => $aRow['bucket'], 'value' => $aRow['asked']], $aRow);
+                unset($aRows[$i]['asked']);
+            }
+
+            return $aRows;
+        }
+
+        if(!isset($this->_aSessionDimensions[$sDimension]))
+            return [];
+
+        $sColumn = '`s`.`' . $this->_aSessionDimensions[$sDimension] . '`';
+        $sWhere = $this->_getSessionsWhere($aFilter, $aBindings);
+        $sIn = $this->_getInSql($aValues, 'qv', $aBindings);
+        $sValue = $this->_getValueCaseSql($sColumn, $aValues, $aBindings);
+        $sBucketSql = $this->_getBucketSql('`s`.`started`', $sBucket, $iOffset, $aStarts, $aBindings);
+
+        return $this->getAll("SELECT " . $sBucketSql . " AS `bucket`, " . $sValue . " AS `value`, COUNT(DISTINCT `s`.`visitor`) AS `visitors`, COUNT(*) AS `sessions`, IFNULL(SUM(`s`.`pageviews`), 0) AS `pageviews`, IFNULL(SUM(`s`.`bounce`), 0) AS `bounces`, IFNULL(SUM(`s`.`duration`), 0) AS `duration` FROM " . $this->_getSessionsFrom() . " WHERE " . $sWhere . " AND " . $sColumn . " IN (" . $sIn . ") GROUP BY `bucket`, `value` ORDER BY `bucket`", $aBindings);
+    }
+
+    /**
+     * CASE expression that turns a column into the first of some values it equals, in the
+     * column's collation.
+     */
+    protected function _getValueCaseSql($sColumn, $aValues, &$aBindings)
+    {
+        $sResult = 'CASE';
+        foreach(array_values($aValues) as $i => $sValue) {
+            $sResult .= ' WHEN ' . $sColumn . ' = :qw' . $i . ' THEN :qt' . $i;
+            $aBindings['qw' . $i] = $sValue;
+            $aBindings['qt' . $i] = $sValue;
+        }
+
+        return $sResult . ' END';
+    }
+
+    /**
+     * Pages (or titles) with visitors, pageviews and average engaged time in seconds; pages
+     * also get entries, bounces of the sessions that entered there, and exits.
+     *
+     * @param string $sMetric sort by this metric (visitors, pageviews, engaged), ties by the
+     *      row count and the value; empty for the usual order, visitors then pageviews
+     * @param string $sDir ASC or DESC, with a metric
+     * @param int $iMax the most rows one call returns
+     * @param bool $bPathSessions pages get entries, bounces and exits; false skips the two
+     *      queries on visits when only the values and their own figures are needed
+     */
+    public function getPageStats($aFilter, $sDimension, $iLimit, $iStart = 0, $sMetric = '', $sDir = 'DESC', $iMax = 1000, $bPathSessions = true)
+    {
+        if(!isset($this->_aEventDimensions[$sDimension]))
+            return [];
+
+        $sColumn = $this->_aEventDimensions[$sDimension];
+        $aBindings = [];
+        $sWhere = $this->_getEventsWhere($aFilter, $aBindings);
+
+        $sOrder = '`visitors` DESC, `pageviews` DESC';
+        if(isset($this->_aEventMetrics[$sMetric])) {
+            $sDir = $sDir === 'ASC' ? 'ASC' : 'DESC';
+            $sOrder = $this->_aEventMetrics[$sMetric] . ' ' . $sDir . ', COUNT(*) ' . $sDir . ', `e`.`' . $sColumn . '` ASC';
+        }
+
+        $aRows = $this->getAllWithKey("SELECT " . self::SQL_GROUP_WIDE . " `e`.`" . $sColumn . "` AS `value`, COUNT(DISTINCT `e`.`visitor`) AS `visitors`, COUNT(*) AS `pageviews`, IFNULL(AVG(NULLIF(`e`.`engaged`, 0)), 0) / 1000 AS `engaged` FROM `" . $this->_oConfig->CNF['TABLE_EVENTS'] . "` AS `e` WHERE " . $sWhere . " GROUP BY `e`.`" . $sColumn . "` ORDER BY " . $sOrder . " LIMIT " . max(0, (int)$iStart) . ", " . max(1, min((int)$iMax, (int)$iLimit)), 'value', $aBindings);
+        if(empty($aRows) || $sDimension != 'path' || !$bPathSessions)
+            return array_values($aRows);
+
+        foreach($aRows as $sPath => $aRow)
+            $aRows[$sPath] = array_merge($aRow, ['entries' => 0, 'bounces' => 0, 'bounce_rate' => 0, 'exits' => 0]);
+
+        // entries, bounces and exits come from the sessions that started in the period
+        $aSessionFilter = $aFilter;
+        unset($aSessionFilter['dims']['path']);
+
+        foreach(['entry_path' => ['entries', 'bounces'], 'exit_path' => ['exits', '']] as $sSessionColumn => list($sCount, $sBounces)) {
+            $aSessionBindings = [];
+            $sSessionWhere = $this->_getSessionsWhere($aSessionFilter, $aSessionBindings);
+
+            $aIn = [];
+            foreach(array_keys($aRows) as $i => $sPath) {
+                $aIn[] = ':p' . $i;
+                $aSessionBindings['p' . $i] = (string)$sPath;
+            }
+
+            $aCounts = $this->getAllWithKey("SELECT `s`.`" . $sSessionColumn . "` AS `value`, COUNT(*) AS `cnt`, SUM(`s`.`bounce`) AS `bounces` FROM " . $this->_getSessionsFrom() . " WHERE " . $sSessionWhere . " AND `s`.`" . $sSessionColumn . "` IN (" . implode(', ', $aIn) . ") GROUP BY `s`.`" . $sSessionColumn . "`", 'value', $aSessionBindings);
+            foreach($aCounts as $sPath => $aCount) {
+                if(!isset($aRows[$sPath]))
+                    continue;
+                $aRows[$sPath][$sCount] = (int)$aCount['cnt'];
+                if($sBounces) {
+                    $aRows[$sPath][$sBounces] = (int)$aCount['bounces'];
+                    $aRows[$sPath]['bounce_rate'] = $aCount['cnt'] ? round(100 * $aCount['bounces'] / $aCount['cnt'], 1) : 0;
+                }
+            }
+        }
+
+        return array_values($aRows);
+    }
+
+    /**
+     * Pageviews per minute since a time, keyed by the minute's start.
+     */
+    public function getPageviewsPerMinute($iSince, $sPlatform = '')
+    {
+        $aBindings = ['since' => (int)$iSince];
+        $sWhere = '';
+        if($sPlatform !== '') {
+            $sWhere = ' AND `platform` = :platform';
+            $aBindings['platform'] = $sPlatform;
+        }
+
+        return $this->getPairs("SELECT FLOOR(`ts` / 60) * 60 AS `minute`, COUNT(*) AS `pageviews` FROM `" . $this->_oConfig->CNF['TABLE_EVENTS'] . "` WHERE `ts` >= :since AND `type` = 'pageview'" . $sWhere . " GROUP BY `minute`", 'minute', 'pageviews', $aBindings);
+    }
+
+    /*
+     * Behavior: events, sessions, members
+     */
+
+    /**
+     * Events grouped by type, name and module, most frequent first.
+     */
+    public function getEventList($aFilter, $aTypes, $iLimit, $iStart = 0)
+    {
+        $aBindings = [];
+        $sWhere = $this->_getAnyEventsWhere($aFilter, $aTypes, $aBindings);
+
+        return $this->getAll("SELECT `e`.`type`, `e`.`name`, `e`.`module`, COUNT(*) AS `count`, COUNT(DISTINCT `e`.`visitor`) AS `visitors`, MAX(`e`.`ts`) AS `last` FROM `" . $this->_oConfig->CNF['TABLE_EVENTS'] . "` AS `e` WHERE " . $sWhere . " GROUP BY `e`.`type`, `e`.`name`, `e`.`module` ORDER BY `count` DESC LIMIT " . max(0, (int)$iStart) . ", " . max(1, min(1000, (int)$iLimit)), $aBindings);
+    }
+
+    public function getEventListCount($aFilter, $aTypes)
+    {
+        $aBindings = [];
+        $sWhere = $this->_getAnyEventsWhere($aFilter, $aTypes, $aBindings);
+
+        return (int)$this->getOne("SELECT COUNT(DISTINCT `e`.`type`, `e`.`name`, `e`.`module`) FROM `" . $this->_oConfig->CNF['TABLE_EVENTS'] . "` AS `e` WHERE " . $sWhere, $aBindings);
+    }
+
+    /**
+     * One event's count and visitors per time bucket, per platform, and the properties of
+     * its latest occurrences.
+     */
+    public function getEventSeries($aFilter, $aEvent, $sBucket, $iOffset, $aStarts = [])
+    {
+        $aBindings = [];
+        $sWhere = $this->_getAnyEventsWhere($aFilter, [$aEvent['type']], $aBindings, $aEvent, $this->_isShortPeriod($aFilter));
+        $sBucketSql = $this->_getBucketSql('`e`.`ts`', $sBucket, $iOffset, $aStarts, $aBindings);
+
+        return $this->getAllWithKey("SELECT " . $sBucketSql . " AS `bucket`, COUNT(*) AS `count`, COUNT(DISTINCT `e`.`visitor`) AS `visitors` FROM `" . $this->_oConfig->CNF['TABLE_EVENTS'] . "` AS `e` WHERE " . $sWhere . " GROUP BY `bucket` ORDER BY `bucket`", 'bucket', $aBindings);
+    }
+
+    public function getEventPlatforms($aFilter, $aEvent)
+    {
+        $aBindings = [];
+        $sWhere = $this->_getAnyEventsWhere($aFilter, [$aEvent['type']], $aBindings, $aEvent, $this->_isShortPeriod($aFilter));
+
+        return $this->getAll("SELECT `e`.`platform`, COUNT(*) AS `count`, COUNT(DISTINCT `e`.`visitor`) AS `visitors` FROM `" . $this->_oConfig->CNF['TABLE_EVENTS'] . "` AS `e` WHERE " . $sWhere . " GROUP BY `e`.`platform` ORDER BY `count` DESC", $aBindings);
+    }
+
+    public function getEventProps($aFilter, $aEvent, $iLimit = 2000)
+    {
+        $aBindings = [];
+        $sWhere = $this->_getAnyEventsWhere($aFilter, [$aEvent['type']], $aBindings, $aEvent, $this->_isShortPeriod($aFilter));
+
+        return $this->getColumn("SELECT `e`.`props` FROM `" . $this->_oConfig->CNF['TABLE_EVENTS'] . "` AS `e` WHERE " . $sWhere . " AND `e`.`props` != '' ORDER BY `e`.`id` DESC LIMIT " . max(1, min(10000, (int)$iLimit)), $aBindings);
+    }
+
+    /**
+     * Sessions that started in the period, latest first, optionally of one member.
+     */
+    public function getSessionList($aFilter, $iLimit, $iStart = 0, $iProfileId = 0)
+    {
+        $aBindings = [];
+        $sWhere = $this->_getSessionsWhere($aFilter, $aBindings);
+        if($iProfileId) {
+            $sWhere .= ' AND `s`.`profile_id` = :profile';
+            $aBindings['profile'] = (int)$iProfileId;
+        }
+
+        return $this->getAll("SELECT `s`.* FROM " . $this->_getSessionsFrom() . " WHERE " . $sWhere . " ORDER BY `s`.`started` DESC LIMIT " . max(0, (int)$iStart) . ", " . max(1, min(1000, (int)$iLimit)), $aBindings);
+    }
+
+    public function getSessionListCount($aFilter, $iProfileId = 0)
+    {
+        $aBindings = [];
+        $sWhere = $this->_getSessionsWhere($aFilter, $aBindings);
+        if($iProfileId) {
+            $sWhere .= ' AND `s`.`profile_id` = :profile';
+            $aBindings['profile'] = (int)$iProfileId;
+        }
+
+        return (int)$this->getOne("SELECT COUNT(*) FROM " . $this->_getSessionsFrom() . " WHERE " . $sWhere, $aBindings);
+    }
+
+    public function getSession($iId)
+    {
+        return $this->getRow("SELECT * FROM `" . $this->_oConfig->CNF['TABLE_SESSIONS'] . "` WHERE `id` = :id", ['id' => (int)$iId]);
+    }
+
+    public function getSessionEvents($iId, $iLimit = 500)
+    {
+        return $this->getAll("SELECT `ts`, `type`, `name`, `path`, `title`, `module`, `object_id`, `value`, `engaged`, `props` FROM `" . $this->_oConfig->CNF['TABLE_EVENTS'] . "` WHERE `session_id` = :id ORDER BY `id` LIMIT " . max(1, min(5000, (int)$iLimit)), ['id' => (int)$iId]);
+    }
+
+    /**
+     * Members who visited in the period with their visits, pageviews, time and platforms,
+     * the latest visitor first.
+     */
+    public function getMemberList($aFilter, $iLimit, $iStart = 0)
+    {
+        $aBindings = [];
+        $sWhere = $this->_getSessionsWhere($aFilter, $aBindings) . ' AND `s`.`profile_id` > 0';
+
+        // the page of members first, from the `profile_id` key alone (it holds the start, the
+        // last hit and the platform), then their figures: all of them at once read every
+        // visit of every member in the period (9 s for 375,000 visits at 90 days)
+        $aIds = $this->getColumn("SELECT `s`.`profile_id` FROM " . $this->_getSessionsFrom() . " WHERE " . $sWhere . " GROUP BY `s`.`profile_id` ORDER BY MAX(`s`.`last_seen`) DESC, `s`.`profile_id` DESC LIMIT " . max(0, (int)$iStart) . ", " . max(1, min(1000, (int)$iLimit)), $aBindings);
+        if(empty($aIds))
+            return [];
+
+        $sIn = $this->_getInSql(array_map('intval', $aIds), 'm', $aBindings);
+        $aRows = $this->getAllWithKey("SELECT `s`.`profile_id`, COUNT(*) AS `sessions`, SUM(`s`.`pageviews`) AS `pageviews`, SUM(`s`.`duration`) AS `duration`, MIN(`s`.`started`) AS `first`, MAX(`s`.`last_seen`) AS `last`, GROUP_CONCAT(DISTINCT `s`.`platform` ORDER BY `s`.`platform`) AS `platforms`, SUBSTRING_INDEX(GROUP_CONCAT(`s`.`country` ORDER BY `s`.`last_seen` DESC), ',', 1) AS `country` FROM " . $this->_getSessionsFrom() . " WHERE " . $sWhere . " AND `s`.`profile_id` IN (" . $sIn . ") GROUP BY `s`.`profile_id`", 'profile_id', $aBindings);
+
+        $aResult = [];
+        foreach($aIds as $iId)
+            if(isset($aRows[$iId]))
+                $aResult[] = $aRows[$iId];
+
+        return $aResult;
+    }
+
+    public function getMemberListCount($aFilter)
+    {
+        $aBindings = [];
+        $sWhere = $this->_getSessionsWhere($aFilter, $aBindings);
+
+        return (int)$this->getOne("SELECT COUNT(DISTINCT `s`.`profile_id`) FROM " . $this->_getSessionsFrom() . " WHERE " . $sWhere . " AND `s`.`profile_id` > 0", $aBindings);
+    }
+
+    /**
+     * A member's totals over all the data kept, and per platform.
+     */
+    public function getMemberTotals($iProfileId)
+    {
+        $aBindings = ['profile' => (int)$iProfileId];
+        $sTable = $this->_oConfig->CNF['TABLE_SESSIONS'];
+
+        return [
+            'totals' => $this->getRow("SELECT COUNT(*) AS `sessions`, IFNULL(SUM(`pageviews`), 0) AS `pageviews`, IFNULL(SUM(`duration`), 0) AS `duration`, MIN(`started`) AS `first`, MAX(`last_seen`) AS `last` FROM `" . $sTable . "` WHERE `profile_id` = :profile", $aBindings),
+            'platforms' => $this->getPairs("SELECT `platform`, COUNT(*) AS `sessions` FROM `" . $sTable . "` WHERE `profile_id` = :profile GROUP BY `platform` ORDER BY `sessions` DESC", 'platform', 'sessions', $aBindings),
+        ];
+    }
+
+    /**
+     * A member's events and site actions in the period, most frequent first.
+     */
+    public function getMemberActions($iProfileId, $aFilter, $iLimit = 20)
+    {
+        $aBindings = [];
+        $sWhere = $this->_getAnyEventsWhere(array_merge($aFilter, ['dims' => []]), [BX_ANALYTICS_HIT_EVENT, BX_ANALYTICS_HIT_UNA], $aBindings);
+        $aBindings['profile'] = (int)$iProfileId;
+
+        return $this->getAll("SELECT `e`.`type`, `e`.`name`, `e`.`module`, COUNT(*) AS `count`, MAX(`e`.`ts`) AS `last` FROM `" . $this->_oConfig->CNF['TABLE_EVENTS'] . "` AS `e` WHERE " . $sWhere . " AND `e`.`profile_id` = :profile GROUP BY `e`.`type`, `e`.`name`, `e`.`module` ORDER BY `count` DESC LIMIT " . max(1, min(100, (int)$iLimit)), $aBindings);
+    }
+
+    /*
+     * Conversion: journeys, retention
+     */
+
+    /**
+     * Each visit's pages in order, for journeys, a page reloaded in place once: [session id
+     * => [path, ...]] in session order, from at most `$iLimit` pageviews (the latest sessions
+     * win when there are more), and how many pageviews were read.
+     *
+     * The rows are folded into the visits as they are read, and every path is kept once and
+     * shared: a list of rows of the same size took over 100 MB.
+     *
+     * @return array sequences, rows
+     */
+    public function getPageSequences($aFilter, $iLimit = 200000)
+    {
+        $aBindings = [];
+        $sWhere = $this->_getEventsWhere($aFilter, $aBindings, true);
+
+        // one direction, the order of the `session_id` key (session, time, id), read backwards
+        // and stopped at the limit: sorted the other way within a visit, the server sorted every
+        // pageview of the period first (5 s for 7 million at 90 days)
+        $oStatement = $this->prepare("SELECT `e`.`session_id`, `e`.`path` FROM `" . $this->_oConfig->CNF['TABLE_EVENTS'] . "` AS `e` WHERE " . $sWhere . " ORDER BY `e`.`session_id` DESC, `e`.`ts` DESC, `e`.`id` DESC LIMIT " . max(1, min(1000000, (int)$iLimit)));
+        if(!$oStatement || !$this->res($oStatement, $aBindings))
+            return ['sequences' => [], 'rows' => 0];
+
+        $aPaths = [];
+        $aSequences = [];
+        $iRows = 0;
+        while(($aRow = $oStatement->fetch(PDO::FETCH_NUM)) !== false) {
+            $iRows++;
+            $sPath = (string)$aRow[1];
+            if(!isset($aPaths[$sPath]))
+                $aPaths[$sPath] = $sPath;
+            $sPath = $aPaths[$sPath];
+
+            // backwards: a visit's last page comes first
+            $iSession = (int)$aRow[0];
+            if(!isset($aSequences[$iSession]))
+                $aSequences[$iSession] = [$sPath];
+            elseif(end($aSequences[$iSession]) !== $sPath)
+                $aSequences[$iSession][] = $sPath;
+        }
+        $oStatement->closeCursor();
+
+        foreach($aSequences as $iSession => $aSequence)
+            $aSequences[$iSession] = array_reverse($aSequence);
+
+        return ['sequences' => array_reverse($aSequences, true), 'rows' => $iRows];
+    }
+
+    /**
+     * Weekly cohorts counted in the database, in one pass over the visits kept: visitors
+     * whose first visit (ever kept) falls in the period, by the week of that visit and the
+     * weeks they visited in, as a bit mask (bit n: week n of the period). Members only, or
+     * every visitor id. The period starts on a week boundary and weeks are 7 days long, as
+     * the week buckets of the reports. A visitor's own rows never reach PHP.
+     *
+     * The visits are read in the order of the `visitor` key, which holds every column used
+     * here, so the grouping needs neither a sort nor a temporary table.
+     *
+     * @param int $iFrom the first week's start
+     * @return array rows of cohort (the week of the first visit, 0 for the period's first),
+     *      weeks (the bit mask) and visitors
+     */
+    public function getRetentionCohorts($iFrom, $iTo, $sPlatform, $bMembersOnly)
+    {
+        $aBindings = ['to' => (int)$iTo, 'from_first' => (int)$iFrom, 'from_in' => (int)$iFrom, 'from_week' => (int)$iFrom, 'from_cohort' => (int)$iFrom];
+        $sWhere = '`s`.`started` < :to';
+        if($bMembersOnly)
+            $sWhere .= ' AND `s`.`profile_id` > 0';
+        if($sPlatform !== '') {
+            $sWhere .= ' AND `s`.`platform` = :platform';
+            $aBindings['platform'] = $sPlatform;
+        }
+
+        return $this->getAll("SELECT FLOOR((`f`.`first` - :from_cohort) / 604800) AS `cohort`, `f`.`weeks`, COUNT(*) AS `visitors`
+            FROM (SELECT MIN(`s`.`started`) AS `first`, BIT_OR(IF(`s`.`started` >= :from_in, 1 << FLOOR((`s`.`started` - :from_week) / 604800), 0)) AS `weeks` FROM " . $this->_getSessionsFrom() . " WHERE " . $sWhere . " GROUP BY `s`.`visitor` HAVING `first` >= :from_first) AS `f`
+            GROUP BY `cohort`, `f`.`weeks`", $aBindings);
+    }
+
+    /*
+     * Goals and funnels
+     */
+
+    /**
+     * Goals in their order, the first `$iLimit` only when given.
+     */
+    public function getGoals($iLimit = 0)
+    {
+        return $this->getAll("SELECT * FROM `" . $this->_oConfig->CNF['TABLE_GOALS'] . "` ORDER BY `order`, `id`" . ((int)$iLimit > 0 ? " LIMIT " . (int)$iLimit : ''));
+    }
+
+    public function getGoalsCount()
+    {
+        return (int)$this->getOne("SELECT COUNT(*) FROM `" . $this->_oConfig->CNF['TABLE_GOALS'] . "`");
+    }
+
+    public function getGoal($iId)
+    {
+        return $this->getRow("SELECT * FROM `" . $this->_oConfig->CNF['TABLE_GOALS'] . "` WHERE `id` = :id", ['id' => (int)$iId]);
+    }
+
+    public function saveGoal($iId, $aGoal)
+    {
+        $sTable = $this->_oConfig->CNF['TABLE_GOALS'];
+        $aBindings = ['name' => $aGoal['name'], 'type' => $aGoal['type'], 'pattern' => $aGoal['pattern']];
+        if($iId)
+            return $this->query("UPDATE `" . $sTable . "` SET `name` = :name, `type` = :type, `pattern` = :pattern WHERE `id` = :id", $aBindings + ['id' => (int)$iId]) !== false ? (int)$iId : 0;
+
+        if(!$this->query("INSERT INTO `" . $sTable . "` (`name`, `type`, `pattern`, `added`, `order`) VALUES (:name, :type, :pattern, UNIX_TIMESTAMP(), (SELECT IFNULL(MAX(`o`.`order`), 0) + 1 FROM `" . $sTable . "` AS `o`))", $aBindings))
+            return 0;
+
+        return (int)$this->lastId();
+    }
+
+    public function deleteGoal($iId)
+    {
+        return (int)$this->query("DELETE FROM `" . $this->_oConfig->CNF['TABLE_GOALS'] . "` WHERE `id` = :id", ['id' => (int)$iId]) > 0;
+    }
+
+    public function getFunnels()
+    {
+        return $this->getAll("SELECT * FROM `" . $this->_oConfig->CNF['TABLE_FUNNELS'] . "` ORDER BY `order`, `id`");
+    }
+
+    public function getFunnel($iId)
+    {
+        return $this->getRow("SELECT * FROM `" . $this->_oConfig->CNF['TABLE_FUNNELS'] . "` WHERE `id` = :id", ['id' => (int)$iId]);
+    }
+
+    public function saveFunnel($iId, $sName, $aSteps)
+    {
+        $sTable = $this->_oConfig->CNF['TABLE_FUNNELS'];
+        $aBindings = ['name' => $sName, 'steps' => json_encode(array_values($aSteps), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)];
+        if($iId)
+            return $this->query("UPDATE `" . $sTable . "` SET `name` = :name, `steps` = :steps WHERE `id` = :id", $aBindings + ['id' => (int)$iId]) !== false ? (int)$iId : 0;
+
+        if(!$this->query("INSERT INTO `" . $sTable . "` (`name`, `steps`, `added`, `order`) VALUES (:name, :steps, UNIX_TIMESTAMP(), (SELECT IFNULL(MAX(`o`.`order`), 0) + 1 FROM `" . $sTable . "` AS `o`))", $aBindings))
+            return 0;
+
+        return (int)$this->lastId();
+    }
+
+    public function deleteFunnel($iId)
+    {
+        return (int)$this->query("DELETE FROM `" . $this->_oConfig->CNF['TABLE_FUNNELS'] . "` WHERE `id` = :id", ['id' => (int)$iId]) > 0;
+    }
+
+    /**
+     * Visits and visitors that reached a goal (a page or an event) in the period. A site action
+     * outside a visit (session_id 0: a payment webhook, cron) is not a visit, but its visitor
+     * reached the goal.
+     */
+    public function getGoalTotals($aFilter, $aGoal)
+    {
+        $aBindings = [];
+        $sWhere = $this->_getAnyEventsWhere($aFilter, [BX_ANALYTICS_HIT_PAGEVIEW, BX_ANALYTICS_HIT_EVENT, BX_ANALYTICS_HIT_UNA], $aBindings, null, $this->_isShortPeriod($aFilter)) . ' AND ' . $this->_getMatchSql($aGoal, $aBindings, 'g');
+
+        // nothing but the columns of the `path` key (or `name` and the visit), so a page goal
+        // reads no row
+        return $this->getRow("SELECT COUNT(DISTINCT NULLIF(`e`.`session_id`, 0)) AS `sessions`, COUNT(DISTINCT `e`.`visitor`) AS `visitors` FROM `" . $this->_oConfig->CNF['TABLE_EVENTS'] . "` AS `e` WHERE " . $sWhere, $aBindings);
+    }
+
+    /**
+     * Visits that reached each step of a funnel in order, counted in the database in one
+     * query. A visit reaches the first step the first time it matches it in the period (and
+     * the filters), and each next step the first time it matches it after the event that
+     * reached the step before; one event counts for one step. One row per visit and step,
+     * never one per event, reaches the server's temporary tables, and none reaches PHP.
+     *
+     * Each step joins the visits of the step before: [session_id, t0, k0, ..., t(n-1), k(n-1)]
+     * with tn and kn, NULL when the visit didn't reach step n (and so no step after it). kn
+     * orders the events: by the second they happened, then by the order they were stored
+     * (ids follow the client's order in a request), so two hits in one second keep their order.
+     *
+     * @param array $aSteps steps of type and pattern, see _getMatchSql
+     * @return array visits per step, in step order
+     */
+    public function getFunnelCounts($aFilter, $aSteps)
+    {
+        $aSteps = array_values($aSteps);
+        if(empty($aSteps))
+            return [];
+
+        $sTable = $this->_oConfig->CNF['TABLE_EVENTS'];
+
+        $aBindings = [];
+        $sWhere = $this->_getAnyEventsWhere($aFilter, [BX_ANALYTICS_HIT_PAGEVIEW, BX_ANALYTICS_HIT_EVENT, BX_ANALYTICS_HIT_UNA], $aBindings, null, $this->_isShortPeriod($aFilter)) . ' AND ' . $this->_getMatchSql($aSteps[0], $aBindings, 's0') . ' AND `e`.`session_id` > 0';
+        // 1e20 is past the id range: the second first, then the id
+        $sKey = "MIN(CAST(`e`.`ts` AS DECIMAL(31,0)) * 100000000000000000000 + `e`.`id`)";
+        $sSql = "SELECT `e`.`session_id`, MIN(`e`.`ts`) AS `t0`, " . $sKey . " AS `k0` FROM `" . $sTable . "` AS `e` WHERE " . $sWhere . " GROUP BY `e`.`session_id`";
+
+        // the filters hold for every event of a visit that matched them, but the period
+        $aCounts = ['COUNT(*) AS `c0`'];
+        $aBefore = ['`p`.`t0`', '`p`.`k0`'];
+        for($i = 1; $i < count($aSteps); $i++) {
+            $aBindings['to' . $i] = (int)$aFilter['to'];
+            $sSql = "SELECT `p`.`session_id`, " . implode(', ', $aBefore) . ", MIN(`e`.`ts`) AS `t" . $i . "`, " . $sKey . " AS `k" . $i . "` FROM (" . $sSql . ") AS `p` LEFT JOIN `" . $sTable . "` AS `e` ON `e`.`session_id` = `p`.`session_id` AND `e`.`ts` >= `p`.`t" . ($i - 1) . "` AND `e`.`ts` < :to" . $i . " AND CAST(`e`.`ts` AS DECIMAL(31,0)) * 100000000000000000000 + `e`.`id` > `p`.`k" . ($i - 1) . "` AND " . $this->_getMatchSql($aSteps[$i], $aBindings, 's' . $i) . " GROUP BY `p`.`session_id`, " . implode(', ', $aBefore);
+
+            $aCounts[] = 'COUNT(`f`.`t' . $i . '`) AS `c' . $i . '`';
+            $aBefore[] = '`p`.`t' . $i . '`';
+            $aBefore[] = '`p`.`k' . $i . '`';
+        }
+
+        $aRow = $this->getRow("SELECT " . implode(', ', $aCounts) . " FROM (" . $sSql . ") AS `f`", $aBindings);
+
+        $aResult = [];
+        foreach(array_keys($aSteps) as $i)
+            $aResult[] = isset($aRow['c' . $i]) ? (int)$aRow['c' . $i] : 0;
+
+        return $aResult;
+    }
+
+    /**
+     * SQL that matches events to a goal or a funnel step: a page path (exact, or with * for
+     * any characters) or an event name (custom event or site action, `name@module` for one module).
+     */
+    protected function _getMatchSql($aMatch, &$aBindings, $sPrefix)
+    {
+        $sPattern = (string)$aMatch['pattern'];
+
+        if($aMatch['type'] == 'page') {
+            $aBindings[$sPrefix . '_pattern'] = str_replace('*', '%', addcslashes($sPattern, '\\%_'));
+            return "`e`.`type` = 'pageview' AND `e`.`path` LIKE :" . $sPrefix . "_pattern";
+        }
+
+        $aParts = explode('@', $sPattern, 2);
+        $aBindings[$sPrefix . '_name'] = $aParts[0];
+        $sSql = "`e`.`type` IN ('event', 'una') AND `e`.`name` = :" . $sPrefix . "_name";
+        if(isset($aParts[1]) && $aParts[1] !== '') {
+            $aBindings[$sPrefix . '_module'] = $aParts[1];
+            $sSql .= " AND `e`.`module` = :" . $sPrefix . "_module";
+        }
+
+        return $sSql;
+    }
+
+    /**
+     * WHERE for events (alias `e`) of some types in the period, optionally one event
+     * (type, name, module), with session filters through the event's session.
+     */
+    protected function _getAnyEventsWhere($aFilter, $aTypes, &$aBindings, $aEvent = null, $bCorrelated = false)
+    {
+        $aSql = ['`e`.`ts` >= :from', '`e`.`ts` < :to'];
+        $aBindings['from'] = (int)$aFilter['from'];
+        $aBindings['to'] = (int)$aFilter['to'];
+
+        $aIn = [];
+        foreach(array_values($aTypes) as $i => $sType) {
+            $aIn[] = ':t' . $i;
+            $aBindings['t' . $i] = $sType;
+        }
+        $aSql[] = '`e`.`type` IN (' . implode(', ', $aIn) . ')';
+
+        if($aEvent !== null) {
+            $aSql[] = '`e`.`name` = :ev_name';
+            $aSql[] = '`e`.`module` = :ev_module';
+            $aBindings['ev_name'] = $aEvent['name'];
+            $aBindings['ev_module'] = $aEvent['module'];
+        }
+
+        if($aFilter['platform'] !== '') {
+            $aSql[] = '`e`.`platform` = :platform';
+            $aBindings['platform'] = $aFilter['platform'];
+        }
+
+        // session dimensions go through the session; each page filter stands on its own
+        $aThrough = [];
+        foreach($aFilter['dims'] as $sName => $sValue) {
+            if(isset($this->_aSessionDimensions[$sName])) {
+                $aThrough[] = '`sf`.`' . $this->_aSessionDimensions[$sName] . '` = :d_' . $sName;
+                $aBindings['d_' . $sName] = $sValue;
+            }
+            elseif(isset($this->_aEventDimensions[$sName])) {
+                $aSql[] = $this->_getPageVisitsInSql('`e`.`session_id`', ['`ef`.`' . $this->_aEventDimensions[$sName] . '` = :d_' . $sName], $aFilter, $aBindings, '_' . $sName);
+                $aBindings['d_' . $sName] = $sValue;
+            }
+        }
+
+        if(!empty($aThrough))
+            $aSql[] = $this->_getVisitsInSql('`e`.`session_id`', $aThrough, $aFilter, $aBindings, $bCorrelated);
+
+        return implode(' AND ', $aSql);
+    }
+
+    /**
+     * Bot totals in a period: hits and distinct bots, optionally for some categories.
+     */
+    public function getBotTotals($aFilter, $aCategories = [])
+    {
+        $aBindings = [];
+        $sWhere = $this->_getBotsWhere($aFilter, $aCategories, $aBindings);
+
+        return $this->getRow("SELECT IFNULL(SUM(`hits`), 0) AS `hits`, COUNT(DISTINCT `bot`) AS `bots` FROM `" . $this->_oConfig->CNF['TABLE_BOTS'] . "` WHERE " . $sWhere, $aBindings);
+    }
+
+    /**
+     * Bot hits per day and category.
+     */
+    public function getBotSeries($aFilter)
+    {
+        $aBindings = [];
+        $sWhere = $this->_getBotsWhere($aFilter, [], $aBindings);
+
+        return $this->getAll("SELECT `day`, `category`, SUM(`hits`) AS `hits` FROM `" . $this->_oConfig->CNF['TABLE_BOTS'] . "` WHERE " . $sWhere . " GROUP BY `day`, `category` ORDER BY `day`", $aBindings);
+    }
+
+    /**
+     * Bots with their hits, last visit and platforms, busiest first.
+     */
+    public function getBotList($aFilter, $aCategories, $iLimit, $iStart = 0)
+    {
+        $aBindings = [];
+        $sWhere = $this->_getBotsWhere($aFilter, $aCategories, $aBindings);
+
+        return $this->getAll("SELECT `bot`, `category`, SUM(`hits`) AS `hits`, MAX(`last_seen`) AS `last_seen`, GROUP_CONCAT(DISTINCT `platform` ORDER BY `platform`) AS `platforms` FROM `" . $this->_oConfig->CNF['TABLE_BOTS'] . "` WHERE " . $sWhere . " GROUP BY `bot`, `category` ORDER BY `hits` DESC LIMIT " . max(0, (int)$iStart) . ", " . max(1, min(1000, (int)$iLimit)), $aBindings);
+    }
+
+    public function getBotListCount($aFilter, $aCategories)
+    {
+        $aBindings = [];
+        $sWhere = $this->_getBotsWhere($aFilter, $aCategories, $aBindings);
+
+        return (int)$this->getOne("SELECT COUNT(DISTINCT `bot`, `category`) FROM `" . $this->_oConfig->CNF['TABLE_BOTS'] . "` WHERE " . $sWhere, $aBindings);
+    }
+
+    /**
+     * Pages bots asked for most.
+     */
+    public function getBotPages($aFilter, $aCategories, $iLimit)
+    {
+        $aBindings = [];
+        $sWhere = $this->_getBotsWhere($aFilter, $aCategories, $aBindings, false);
+
+        return $this->getAll("SELECT `path`, SUM(`hits`) AS `hits` FROM `" . $this->_oConfig->CNF['TABLE_BOT_PAGES'] . "` WHERE " . $sWhere . " GROUP BY `path_hash`, `path` ORDER BY `hits` DESC LIMIT " . max(1, min(100, (int)$iLimit)), $aBindings);
+    }
+
+    /**
+     * Bot rows are per UTC day (the collector's gmdate), so a period in another timezone
+     * reads every UTC day it touches: from the UTC day of its first second to the UTC day
+     * of its last one. East of UTC that starts a day before the local first day, west of
+     * UTC it ends a day after the local last one. Reports chart these same days.
+     */
+    public function getBotDays($aFilter)
+    {
+        $iFrom = (int)$aFilter['from'];
+
+        return [
+            'from' => gmdate('Y-m-d', $iFrom),
+            'to' => gmdate('Y-m-d', max($iFrom, (int)$aFilter['to'] - 1)),
+        ];
+    }
+
+    /**
+     * Bot rows are per UTC day: the period's UTC days (getBotDays), its platform (the pages
+     * table has none) and the categories.
+     */
+    protected function _getBotsWhere($aFilter, $aCategories, &$aBindings, $bPlatform = true)
+    {
+        $aDays = $this->getBotDays($aFilter);
+        $aSql = ['`day` >= :day_from', '`day` <= :day_to'];
+        $aBindings['day_from'] = $aDays['from'];
+        $aBindings['day_to'] = $aDays['to'];
+
+        if($bPlatform && $aFilter['platform'] !== '') {
+            $aSql[] = '`platform` = :platform';
+            $aBindings['platform'] = $aFilter['platform'];
+        }
+
+        if(!empty($aCategories)) {
+            $aIn = [];
+            foreach(array_values($aCategories) as $i => $sCategory) {
+                $aIn[] = ':c' . $i;
+                $aBindings['c' . $i] = $sCategory;
+            }
+            $aSql[] = '`category` IN (' . implode(', ', $aIn) . ')';
+        }
+
+        return implode(' AND ', $aSql);
+    }
+
+    /**
+     * Visitors per rounded location (sessions with coordinates from the edge), for the map.
+     */
+    public function getMapPoints($aFilter, $iLimit = 500)
+    {
+        $aBindings = [];
+        $sWhere = $this->_getSessionsWhere($aFilter, $aBindings);
+
+        return $this->getAll("SELECT `s`.`lat`, `s`.`lng`, MAX(`s`.`city`) AS `city`, MAX(`s`.`country`) AS `country`, COUNT(DISTINCT `s`.`visitor`) AS `visitors` FROM " . $this->_getSessionsFrom() . " WHERE " . $sWhere . " AND `s`.`lat` IS NOT NULL GROUP BY `s`.`lat`, `s`.`lng` ORDER BY `visitors` DESC LIMIT " . max(1, min(2000, (int)$iLimit)), $aBindings);
+    }
+
+    /**
+     * Visitors active since a time, per platform.
+     */
+    public function getOnline($iSince)
+    {
+        return $this->getPairs("SELECT `platform`, COUNT(DISTINCT `visitor`) AS `visitors` FROM `" . $this->_oConfig->CNF['TABLE_SESSIONS'] . "` WHERE `last_seen` >= :since GROUP BY `platform`", 'platform', 'visitors', ['since' => (int)$iSince]);
+    }
+
+    /**
+     * Latest pageviews with their session's place and device, for the realtime list.
+     */
+    public function getRecentPageviews($iSince, $iLimit, $sPlatform = '')
+    {
+        $aBindings = ['since' => (int)$iSince];
+        $sWhere = '';
+        if($sPlatform !== '') {
+            $sWhere = ' AND `e`.`platform` = :platform';
+            $aBindings['platform'] = $sPlatform;
+        }
+
+        return $this->getAll("SELECT `e`.`ts`, `e`.`platform`, `e`.`path`, `e`.`title`, `e`.`profile_id`, `s`.`country`, `s`.`city`, `s`.`device`, `s`.`browser`, `s`.`os` FROM `" . $this->_oConfig->CNF['TABLE_EVENTS'] . "` AS `e` INNER JOIN `" . $this->_oConfig->CNF['TABLE_SESSIONS'] . "` AS `s` ON `s`.`id` = `e`.`session_id` WHERE `e`.`ts` >= :since AND `e`.`type` = 'pageview'" . $sWhere . " ORDER BY `e`.`id` DESC LIMIT " . max(1, min(100, (int)$iLimit)), $aBindings);
+    }
+
+    /**
+     * Whether anything was ever collected, per platform: the setup check in Studio.
+     */
+    public function getLastHits($iUntil)
+    {
+        return $this->getPairs("SELECT `platform`, MAX(`last_seen`) AS `last` FROM `" . $this->_oConfig->CNF['TABLE_SESSIONS'] . "` WHERE `last_seen` <= :until GROUP BY `platform`", 'platform', 'last', ['until' => (int)$iUntil]);
+    }
+
+    /**
+     * Hits in a time range per platform, from the index on events: platform => [hits, last].
+     */
+    public function getRecentHits($iSince, $iUntil)
+    {
+        return $this->getAllWithKey("SELECT `platform`, COUNT(*) AS `hits`, MAX(`ts`) AS `last` FROM `" . $this->_oConfig->CNF['TABLE_EVENTS'] . "` WHERE `ts` >= :since AND `ts` <= :until GROUP BY `platform`", 'platform', ['since' => (int)$iSince, 'until' => (int)$iUntil]);
+    }
+
+    /**
+     * The latest hit of a platform up to a time, visit or not (the server's own hits and
+     * actions outside a visit keep none): one lookup in the platform index.
+     */
+    public function getLastEvent($sPlatform, $iUntil)
+    {
+        return (int)$this->getOne("SELECT MAX(`ts`) FROM `" . $this->_oConfig->CNF['TABLE_EVENTS'] . "` WHERE `platform` = :platform AND `ts` <= :until", ['until' => (int)$iUntil, 'platform' => $sPlatform]);
+    }
+
+    /**
+     * Visitors in visits active in a time range, per platform.
+     */
+    public function getActiveVisitors($iSince, $iUntil)
+    {
+        return $this->getPairs("SELECT `platform`, COUNT(DISTINCT `visitor`) AS `visitors` FROM `" . $this->_oConfig->CNF['TABLE_SESSIONS'] . "` WHERE `last_seen` >= :since AND `started` <= :until GROUP BY `platform`", 'platform', 'visitors', ['since' => (int)$iSince, 'until' => (int)$iUntil]);
+    }
+
+    /**
+     * Visits started in a time range, per platform.
+     */
+    public function getStartedSessions($iSince, $iUntil)
+    {
+        return $this->getPairs("SELECT `platform`, COUNT(*) AS `sessions` FROM `" . $this->_oConfig->CNF['TABLE_SESSIONS'] . "` WHERE `started` >= :since AND `started` <= :until GROUP BY `platform`", 'platform', 'sessions', ['since' => (int)$iSince, 'until' => (int)$iUntil]);
+    }
+
+    /**
+     * App versions in visits started in a time range, as rows of platform and app_version.
+     */
+    public function getAppVersions($iSince, $iUntil, $aPlatforms)
+    {
+        if(empty($aPlatforms))
+            return [];
+
+        $aBindings = ['since' => (int)$iSince, 'until' => (int)$iUntil];
+        $sIn = $this->_getInSql($aPlatforms, 'platform', $aBindings);
+
+        return $this->getAll("SELECT DISTINCT `platform`, `app_version` FROM `" . $this->_oConfig->CNF['TABLE_SESSIONS'] . "` WHERE `started` >= :since AND `started` <= :until AND `platform` IN (" . $sIn . ") AND `app_version` <> '' LIMIT 1000", $aBindings);
+    }
+
+    /*
+     * Studio tools
+     */
+
+    /**
+     * Site pages with active analytics blocks: the module's own page, builder copies on any
+     * other page, and pages made with the Dashboards tool. Studio's own pages are left out.
+     * Each page with its number of blocks and how many of them NEO renders (active_api).
+     */
+    public function getPagesWithBlocks($sModule)
+    {
+        return $this->getAll("SELECT `p`.`object`, `p`.`uri`, `p`.`url`, `p`.`title`, `p`.`module`, `p`.`deletable`, COUNT(`b`.`id`) AS `blocks`, SUM(`b`.`active_api`) AS `api_blocks`
+            FROM `sys_pages_blocks` AS `b`
+            INNER JOIN `sys_objects_page` AS `p` ON `p`.`object` = `b`.`object`
+            WHERE `b`.`module` = :module AND `b`.`type` = 'service' AND `b`.`active` = 1 AND `b`.`object` <> '' AND `b`.`object` NOT LIKE 'sys\_std\_%'
+            GROUP BY `p`.`object`, `p`.`uri`, `p`.`url`, `p`.`title`, `p`.`module`, `p`.`deletable`
+            ORDER BY `p`.`id`", ['module' => $sModule]);
+    }
+
+    public function isPageObject($sObject)
+    {
+        return (bool)$this->getOne("SELECT 1 FROM `sys_objects_page` WHERE `object` = :object LIMIT 1", ['object' => $sObject]);
+    }
+
+    public function isPageUri($sUri)
+    {
+        return (bool)$this->getOne("SELECT 1 FROM `sys_objects_page` WHERE `uri` = :uri LIMIT 1", ['uri' => $sUri]);
+    }
+
+    /**
+     * A new page of the module, with copies of the blocks in some cells of another page.
+     * The copies are shown in NEO when their source is (active_api: a block NEO has no
+     * element for shows as raw data there) and can't be copied again from the builder.
+     *
+     * @param array $aPage object, uri, title_system, title, module, layout_id, author
+     * @param string $sFrom the page whose blocks are copied
+     * @param array $aCells cell ids to copy
+     * @return int blocks copied, or false when the page wasn't created
+     */
+    public function insertPageWithBlocks($aPage, $sFrom, $aCells)
+    {
+        $bInserted = $this->query("INSERT INTO `sys_objects_page` SET `author` = :author, `added` = :added, `object` = :object, `uri` = :uri, `title_system` = :title_system, `title` = :title, `module` = :module, `layout_id` = :layout_id, `visible_for_levels` = 2147483647, `visible_for_levels_editable` = 1, `url` = :url, `meta_robots` = 'noindex', `cache_lifetime` = 0, `cache_editable` = 1, `deletable` = 1", [
+            'author' => (int)$aPage['author'],
+            'added' => time(),
+            'object' => $aPage['object'],
+            'uri' => $aPage['uri'],
+            'title_system' => $aPage['title_system'],
+            'title' => $aPage['title'],
+            'module' => $aPage['module'],
+            'layout_id' => (int)$aPage['layout_id'],
+            'url' => 'page.php?i=' . $aPage['uri'],
+        ]);
+        if(!$bInserted)
+            return false;
+
+        $aBindings = ['object' => $aPage['object'], 'from' => $sFrom];
+        $sIn = $this->_getInSql(array_map('intval', $aCells), 'cell', $aBindings);
+
+        return (int)$this->query("INSERT INTO `sys_pages_blocks` (`object`, `cell_id`, `module`, `title_system`, `title`, `designbox_id`, `class`, `visible_for_levels`, `type`, `content`, `deletable`, `copyable`, `active`, `active_api`, `order`)
+            SELECT :object, `cell_id`, `module`, `title_system`, `title`, `designbox_id`, `class`, `visible_for_levels`, `type`, `content`, 1, 0, 1, `active_api`, `order`
+            FROM `sys_pages_blocks` WHERE `object` = :from AND `active` = 1 AND `cell_id` IN (" . $sIn . ") ORDER BY `cell_id`, `order`", $aBindings);
+    }
+
+    /**
+     * Whether the classic tracker is injected into the site's pages.
+     */
+    public function isInjectionActive($sName)
+    {
+        return (bool)$this->getOne("SELECT `active` FROM `sys_injections` WHERE `name` = :name LIMIT 1", ['name' => $sName]);
+    }
+
+    /**
+     * Alerts a handler listens to, as rows of unit and action.
+     */
+    public function getHandlerAlerts($sHandler)
+    {
+        return $this->getAll("SELECT `a`.`unit`, `a`.`action` FROM `sys_alerts` AS `a` INNER JOIN `sys_alerts_handlers` AS `h` ON `h`.`id` = `a`.`handler_id` WHERE `h`.`name` = :name ORDER BY `a`.`id`", ['name' => $sHandler]);
+    }
+
+    protected function _getPageviews($aFilter)
+    {
+        $aBindings = [];
+        $sWhere = $this->_getEventsWhere($aFilter, $aBindings);
+
+        return (int)$this->getOne("SELECT COUNT(*) FROM `" . $this->_oConfig->CNF['TABLE_EVENTS'] . "` AS `e` WHERE " . $sWhere, $aBindings);
+    }
+
+    protected function _hasEventDimensions($aFilter)
+    {
+        foreach($aFilter['dims'] as $sName => $sValue)
+            if(isset($this->_aEventDimensions[$sName]))
+                return true;
+
+        return false;
+    }
+
+    /**
+     * A visit's hits are taken to be at most this many seconds before its start or after its
+     * last hit, to bound the visits or pageviews a filter across the two tables reads.
+     */
+    const VISIT_SLACK = 86400;
+
+    /**
+     * `$sColumn` (a session id) is a visit that matches session conditions on `sf` and has
+     * hits in the period. The visits are found first, once, and the rows only look them up:
+     * a correlated EXISTS looked a visit up for every pageview, millions over 90 days, and lets
+     * the optimizer group the pageviews in a temporary table. The hint asks MariaDB 12 and
+     * MySQL for that order; older servers read it as a comment.
+     *
+     * @param bool $bCorrelated look each row's visit up instead: for a scan that stops after
+     *      a few rows (a LIMIT in key order), where finding every visit first costs more
+     */
+    protected function _getVisitsInSql($sColumn, $aConditions, $aFilter, &$aBindings, $bCorrelated = false)
+    {
+        if($bCorrelated)
+            return "EXISTS (SELECT 1 FROM `" . $this->_oConfig->CNF['TABLE_SESSIONS'] . "` AS `sf` WHERE `sf`.`id` = " . $sColumn . " AND " . implode(' AND ', $aConditions) . ")";
+
+        $aBindings['sf_from'] = (int)$aFilter['from'] - self::VISIT_SLACK;
+        $aBindings['sf_to'] = (int)$aFilter['to'] + self::VISIT_SLACK;
+
+        return $sColumn . " IN (SELECT " . self::SQL_SUBQUERY_FIRST . " `sf`.`id` FROM `" . $this->_oConfig->CNF['TABLE_SESSIONS'] . "` AS `sf` WHERE `sf`.`last_seen` >= :sf_from AND `sf`.`started` < :sf_to AND " . implode(' AND ', $aConditions) . ")";
+    }
+
+    /**
+     * `$sColumn` (a session id) is a visit with a pageview that matches conditions on `ef`,
+     * viewed from a day before the period on: the visits come from the `path` key (a page
+     * filter) and are looked up once each, see _getVisitsInSql.
+     *
+     * @param string $sSuffix tells the bindings of several such conditions apart
+     */
+    protected function _getPageVisitsInSql($sColumn, $aConditions, $aFilter, &$aBindings, $sSuffix = '')
+    {
+        $aBindings['ef_from' . $sSuffix] = (int)$aFilter['from'] - self::VISIT_SLACK;
+
+        return $sColumn . " IN (SELECT " . self::SQL_SUBQUERY_FIRST . " `ef`.`session_id` FROM `" . $this->_oConfig->CNF['TABLE_EVENTS'] . "` AS `ef` WHERE `ef`.`type` = 'pageview' AND `ef`.`ts` >= :ef_from" . $sSuffix . " AND " . implode(' AND ', $aConditions) . ")";
+    }
+
+    /**
+     * The sessions table for reports, alias `s`. With a platform filter the optimizer may read
+     * the visits of the platform through `platform_seen` (kept for Setup's last hit per
+     * platform) instead of the period's range of the covering `started` key; when the kept
+     * data is about as long as the period, it did: 7 s instead of 0.4 s for 90 days.
+     */
+    protected function _getSessionsFrom()
+    {
+        return "`" . $this->_oConfig->CNF['TABLE_SESSIONS'] . "` AS `s` IGNORE INDEX (`platform_seen`)";
+    }
+
+    /**
+     * Whether a period is short enough to look the visit of each row up (see _getVisitsInSql):
+     * a week of events of one kind are few rows, and finding every visit of the filter first
+     * costs a scan of the visits' table (1.5 s at 2.5 million visits).
+     */
+    protected function _isShortPeriod($aFilter)
+    {
+        return (int)$aFilter['to'] - (int)$aFilter['from'] <= 8 * 86400;
+    }
+
+    /**
+     * WHERE for sessions (alias `s`) that started in the period and match the filters. A page
+     * filter keeps the sessions that viewed that page.
+     */
+    protected function _getSessionsWhere($aFilter, &$aBindings)
+    {
+        $aSql = ['`s`.`started` >= :from', '`s`.`started` < :to'];
+        $aBindings['from'] = (int)$aFilter['from'];
+        $aBindings['to'] = (int)$aFilter['to'];
+
+        if($aFilter['platform'] !== '') {
+            $aSql[] = '`s`.`platform` = :platform';
+            $aBindings['platform'] = $aFilter['platform'];
+        }
+
+        $aEventSql = [];
+        foreach($aFilter['dims'] as $sName => $sValue) {
+            if(isset($this->_aSessionDimensions[$sName])) {
+                $aSql[] = '`s`.`' . $this->_aSessionDimensions[$sName] . '` = :d_' . $sName;
+                $aBindings['d_' . $sName] = $sValue;
+            }
+            elseif(isset($this->_aEventDimensions[$sName])) {
+                $aEventSql[] = '`ef`.`' . $this->_aEventDimensions[$sName] . '` = :d_' . $sName;
+                $aBindings['d_' . $sName] = $sValue;
+            }
+        }
+
+        if(!empty($aEventSql))
+            $aSql[] = $this->_getPageVisitsInSql('`s`.`id`', $aEventSql, $aFilter, $aBindings);
+
+        return implode(' AND ', $aSql);
+    }
+
+    /**
+     * WHERE for pageview events (alias `e`) in the period that match the filters. Session
+     * filters keep the events of matching sessions.
+     */
+    protected function _getEventsWhere($aFilter, &$aBindings, $bCorrelated = false)
+    {
+        $aSql = ['`e`.`ts` >= :from', '`e`.`ts` < :to', "`e`.`type` = 'pageview'"];
+        $aBindings['from'] = (int)$aFilter['from'];
+        $aBindings['to'] = (int)$aFilter['to'];
+
+        if($aFilter['platform'] !== '') {
+            $aSql[] = '`e`.`platform` = :platform';
+            $aBindings['platform'] = $aFilter['platform'];
+        }
+
+        $aSessionSql = [];
+        foreach($aFilter['dims'] as $sName => $sValue) {
+            if(isset($this->_aEventDimensions[$sName])) {
+                $aSql[] = '`e`.`' . $this->_aEventDimensions[$sName] . '` = :d_' . $sName;
+                $aBindings['d_' . $sName] = $sValue;
+            }
+            elseif(isset($this->_aSessionDimensions[$sName])) {
+                $aSessionSql[] = '`sf`.`' . $this->_aSessionDimensions[$sName] . '` = :d_' . $sName;
+                $aBindings['d_' . $sName] = $sValue;
+            }
+        }
+
+        if(!empty($aSessionSql))
+            $aSql[] = $this->_getVisitsInSql('`e`.`session_id`', $aSessionSql, $aFilter, $aBindings, $bCorrelated);
+
+        return implode(' AND ', $aSql);
+    }
+
+    /**
+     * Bucket start time of a timestamp column. Given a list of starts (months, or days across
+     * a daylight saving change), a row goes to the latest start before it. Otherwise hours,
+     * days and weeks (from Monday) are fixed lengths shifted by the timezone offset.
+     */
+    protected function _getBucketSql($sColumn, $sBucket, $iOffset, $aStarts, &$aBindings)
+    {
+        $iOffset = (int)$iOffset;
+
+        if(!empty($aStarts)) {
+            // INTERVAL() finds the last start up to the value by a binary search over the
+            // ascending list, ELT() returns that start; a CASE with a branch per start costs a
+            // comparison per bucket for every row (90 at 90 days). Before the first start: 0.
+            $aFind = [];
+            $aPick = [];
+            foreach(array_values($aStarts) as $i => $iStart) {
+                $aFind[] = ':b' . $i;
+                $aPick[] = ':b' . $i . 'v';
+                $aBindings['b' . $i] = (int)$iStart;
+                $aBindings['b' . $i . 'v'] = (int)$iStart;
+            }
+            return 'IFNULL(ELT(INTERVAL(' . $sColumn . ', ' . implode(', ', $aFind) . '), ' . implode(', ', $aPick) . '), 0)';
+        }
+
+        switch($sBucket) {
+            case 'hour':
+                return 'FLOOR((' . $sColumn . ' + ' . $iOffset . ') / 3600) * 3600 - ' . $iOffset;
+
+            case 'week':
+                // 1970-01-05 was a Monday
+                return 'FLOOR((' . $sColumn . ' + ' . $iOffset . ' - 345600) / 604800) * 604800 + 345600 - ' . $iOffset;
+
+            default:
+                return 'FLOOR((' . $sColumn . ' + ' . $iOffset . ') / 86400) * 86400 - ' . $iOffset;
+        }
+    }
+
+    /*
+     * Health: errors and vitals
+     */
+
+    /**
+     * Columns of an error hit that filters read directly; other dimensions go through its visit.
+     */
+    protected $_aErrorHitDimensions = ['app_version', 'browser', 'os', 'device', 'country', 'path'];
+
+    /**
+     * Columns an error breakdown may group by.
+     */
+    protected $_aErrorBreakdowns = ['platform', 'app_version', 'browser', 'os', 'device', 'country', 'route'];
+
+    /**
+     * Raise a visit's error count by `$iCount` unless that passes `$iMax`.
+     *
+     * @return bool whether the errors fit
+     */
+    public function addSessionErrors($iSessionId, $iCount, $iMax)
+    {
+        return (int)$this->query("UPDATE `" . $this->_oConfig->CNF['TABLE_SESSIONS'] . "` SET `errors` = `errors` + :n WHERE `id` = :id AND `errors` + :n2 <= :max", [
+            'id' => (int)$iSessionId,
+            'n' => (int)$iCount,
+            'n2' => (int)$iCount,
+            'max' => (int)$iMax,
+        ]) > 0;
+    }
+
+    public function getErrorGroupByFingerprint($sFingerprint)
+    {
+        return $this->getRow("SELECT `id`, `status`, `status_changed` FROM `" . $this->_oConfig->CNF['TABLE_ERRORS'] . "` WHERE `fingerprint` = :fingerprint", ['fingerprint' => $sFingerprint]);
+    }
+
+    /**
+     * Error groups created in a time span (server time, whatever the age of their first error).
+     */
+    public function getErrorGroupsCreated($iFrom, $iTo)
+    {
+        return (int)$this->getOne("SELECT COUNT(*) FROM `" . $this->_oConfig->CNF['TABLE_ERRORS'] . "` WHERE `created` >= :from AND `created` <= :to", ['from' => (int)$iFrom, 'to' => (int)$iTo]);
+    }
+
+    /**
+     * Add an occurrence to an error's group, creating the group with its sample stack when it
+     * is new. The stack is replaced by a newer one once a day, so it follows new releases.
+     * The message and the source are the latest occurrence's, so no visitor text in them
+     * outlives the group's last occurrence (pruning clears old stacks, see
+     * clearErrorStacksBefore). MySQL assigns left to right: `stack` comes before `stack_ts`,
+     * the message and the source before `last_seen`.
+     *
+     * @param string $sFingerprint 16 bytes, see BxAnalyticsErrors::getFingerprint
+     * @param array $aError kind, message, source, line, col, status, stack, fatal, repeats
+     * @param int $iTime when it happened
+     * @param int $iNow server time
+     * @param int $iId the group's id when it is known to exist: an update, which doesn't use
+     *      up an auto-increment id as INSERT ... ON DUPLICATE KEY UPDATE does
+     * @return int the group's id, 0 on failure
+     */
+    public function saveErrorGroup($sFingerprint, $aError, $iTime, $iNow, $iId = 0)
+    {
+        if($iId) {
+            $bResult = $this->query("UPDATE `" . $this->_oConfig->CNF['TABLE_ERRORS'] . "` SET
+                `message` = IF(:message_ts >= `last_seen`, :message, `message`),
+                `source` = IF(:source_ts >= `last_seen`, :source, `source`),
+                `first_seen` = LEAST(`first_seen`, :first_seen),
+                `last_seen` = GREATEST(`last_seen`, :last_seen),
+                `occurrences` = `occurrences` + :occurrences,
+                `fatal` = GREATEST(`fatal`, :fatal),
+                `stack` = IF(:stack != '' AND `stack_ts` < :stack_ts - 86400, :stack_new, `stack`),
+                `stack_ts` = IF(:stack_check != '' AND `stack_ts` < :stack_ts_check - 86400, :stack_ts_new, `stack_ts`)
+                WHERE `id` = :id", [
+                'id' => (int)$iId,
+                'message' => $aError['message'],
+                'message_ts' => (int)$iTime,
+                'source' => $aError['source'],
+                'source_ts' => (int)$iTime,
+                'first_seen' => (int)$iTime,
+                'last_seen' => (int)$iTime,
+                'occurrences' => isset($aError['repeats']) ? (int)$aError['repeats'] : 1,
+                'fatal' => (int)$aError['fatal'],
+                'stack' => $aError['stack'],
+                'stack_new' => $aError['stack'],
+                'stack_check' => $aError['stack'],
+                'stack_ts' => (int)$iNow,
+                'stack_ts_check' => (int)$iNow,
+                'stack_ts_new' => (int)$iNow,
+            ]);
+
+            return $bResult !== false ? (int)$iId : 0;
+        }
+
+        $bResult = $this->query("INSERT INTO `" . $this->_oConfig->CNF['TABLE_ERRORS'] . "` (`fingerprint`, `kind`, `message`, `source`, `line`, `col`, `status_code`, `stack`, `stack_ts`, `first_seen`, `last_seen`, `occurrences`, `fatal`, `created`) VALUES (:fingerprint, :kind, :message, :source, :line, :col, :status_code, :stack, :stack_ts, :first_seen, :last_seen, :occurrences, :fatal, :created)
+            ON DUPLICATE KEY UPDATE `id` = LAST_INSERT_ID(`id`),
+                `message` = IF(VALUES(`last_seen`) >= `last_seen`, VALUES(`message`), `message`),
+                `source` = IF(VALUES(`last_seen`) >= `last_seen`, VALUES(`source`), `source`),
+                `first_seen` = LEAST(`first_seen`, VALUES(`first_seen`)),
+                `last_seen` = GREATEST(`last_seen`, VALUES(`last_seen`)),
+                `occurrences` = `occurrences` + VALUES(`occurrences`),
+                `fatal` = GREATEST(`fatal`, VALUES(`fatal`)),
+                `stack` = IF(VALUES(`stack`) != '' AND `stack_ts` < VALUES(`stack_ts`) - 86400, VALUES(`stack`), `stack`),
+                `stack_ts` = IF(VALUES(`stack`) != '' AND `stack_ts` < VALUES(`stack_ts`) - 86400, VALUES(`stack_ts`), `stack_ts`)", [
+            'fingerprint' => $sFingerprint,
+            'kind' => $aError['kind'],
+            'message' => $aError['message'],
+            'source' => $aError['source'],
+            'line' => (int)$aError['line'],
+            'col' => (int)$aError['col'],
+            'status_code' => (int)$aError['status'],
+            'stack' => $aError['stack'],
+            'stack_ts' => $aError['stack'] !== '' ? (int)$iNow : 0,
+            'first_seen' => (int)$iTime,
+            'last_seen' => (int)$iTime,
+            'occurrences' => isset($aError['repeats']) ? (int)$aError['repeats'] : 1,
+            'fatal' => (int)$aError['fatal'],
+            'created' => (int)$iNow,
+        ]);
+
+        return $bResult !== false ? (int)$this->lastId() : 0;
+    }
+
+    /**
+     * Set an error group's status (open, resolved, ignored), optionally only from another one.
+     */
+    public function setErrorStatus($iId, $sStatus, $iTime, $sFrom = '')
+    {
+        $aBindings = ['id' => (int)$iId, 'status' => $sStatus, 'time' => (int)$iTime];
+        $sWhere = '';
+        if($sFrom !== '') {
+            $sWhere = ' AND `status` = :from';
+            $aBindings['from'] = $sFrom;
+        }
+
+        return $this->query("UPDATE `" . $this->_oConfig->CNF['TABLE_ERRORS'] . "` SET `status` = :status, `status_changed` = :time WHERE `id` = :id" . $sWhere, $aBindings) !== false;
+    }
+
+    public function getErrorGroup($iId)
+    {
+        return $this->getRow("SELECT `id`, `kind`, `message`, `source`, `line`, `col`, `status_code`, `stack`, `first_seen`, `last_seen`, `occurrences`, `fatal`, `status`, `status_changed` FROM `" . $this->_oConfig->CNF['TABLE_ERRORS'] . "` WHERE `id` = :id", ['id' => (int)$iId]);
+    }
+
+    public function getErrorGroups($aIds)
+    {
+        if(empty($aIds))
+            return [];
+
+        $aBindings = [];
+        $aIn = [];
+        foreach(array_values($aIds) as $i => $iId) {
+            $aIn[] = ':id' . $i;
+            $aBindings['id' . $i] = (int)$iId;
+        }
+
+        return $this->getAllWithKey("SELECT `id`, `kind`, `message`, `source`, `line`, `col`, `status_code`, `first_seen`, `last_seen`, `occurrences`, `fatal`, `status` FROM `" . $this->_oConfig->CNF['TABLE_ERRORS'] . "` WHERE `id` IN (" . implode(', ', $aIn) . ")", 'id', $aBindings);
+    }
+
+    /**
+     * Insert error hits in one query.
+     *
+     * @return int rows inserted
+     */
+    public function insertErrorHits($aRows)
+    {
+        return $this->_insertRows($this->_oConfig->CNF['TABLE_ERROR_HITS'], $aRows);
+    }
+
+    /**
+     * One page view's vitals; metrics not measured are left NULL.
+     */
+    public function insertVitals($aRow)
+    {
+        return $this->_insertRows($this->_oConfig->CNF['TABLE_VITALS'], [$aRow]);
+    }
+
+    /**
+     * Error hits, visits with errors, visitors, and visits that ended in a fatal error on some
+     * platforms, for error groups in some statuses.
+     *
+     * @param array $aFilter report filter
+     * @param array $aStatuses group statuses
+     * @param array $aFatalPlatforms platforms whose fatal errors are crashes (the apps)
+     */
+    public function getErrorTotals($aFilter, $aStatuses, $aFatalPlatforms = [])
+    {
+        $aBindings = [];
+        $sWhere = $this->_getErrorHitsWhere($aFilter, $aStatuses, $aBindings);
+
+        // a crash outside any visit known (an app that stopped before its first page, or whose
+        // visit could not be found when it reported the crash on its next launch) is a visit too
+        $sFatal = '0';
+        if(!empty($aFatalPlatforms))
+            $sFatal = "COUNT(DISTINCT CASE WHEN `h`.`fatal` = 1 AND `h`.`session_id` > 0 AND `h`.`platform` IN (" . $this->_getInSql($aFatalPlatforms, 'fp', $aBindings) . ") THEN `h`.`session_id` END) + COUNT(CASE WHEN `h`.`fatal` = 1 AND `h`.`session_id` = 0 AND `h`.`platform` IN (" . $this->_getInSql($aFatalPlatforms, 'fu', $aBindings) . ") THEN `h`.`id` END)";
+
+        return $this->getRow("SELECT IFNULL(SUM(`h`.`repeats`), 0) AS `hits`, COUNT(DISTINCT NULLIF(`h`.`session_id`, 0)) AS `sessions`, COUNT(DISTINCT `h`.`visitor`) AS `users`, " . $sFatal . " AS `fatal_sessions` FROM `" . $this->_oConfig->CNF['TABLE_ERROR_HITS'] . "` AS `h` INNER JOIN `" . $this->_oConfig->CNF['TABLE_ERRORS'] . "` AS `g` ON `g`.`id` = `h`.`error_id` WHERE " . $sWhere, $aBindings);
+    }
+
+    /**
+     * Visits that started in the period per platform.
+     */
+    public function getSessionsPerPlatform($aFilter)
+    {
+        $aBindings = [];
+        $sWhere = $this->_getSessionsWhere($aFilter, $aBindings);
+
+        return $this->getPairs("SELECT `s`.`platform`, COUNT(*) AS `sessions` FROM " . $this->_getSessionsFrom() . " WHERE " . $sWhere . " GROUP BY `s`.`platform`", 'platform', 'sessions', $aBindings);
+    }
+
+    /**
+     * Error groups with their hits in the period, most frequent first.
+     *
+     * @param int $iFirstSeenFrom only groups first seen since this time (new errors), 0 for any
+     */
+    public function getErrorList($aFilter, $aStatuses, $iFirstSeenFrom, $iLimit, $iStart = 0)
+    {
+        $aBindings = [];
+        $sWhere = $this->_getErrorHitsWhere($aFilter, $aStatuses, $aBindings, $iFirstSeenFrom);
+
+        return $this->getAll("SELECT `h`.`error_id`, SUM(`h`.`repeats`) AS `count`, COUNT(DISTINCT NULLIF(`h`.`session_id`, 0)) AS `sessions`, COUNT(DISTINCT `h`.`visitor`) AS `users`, GROUP_CONCAT(DISTINCT `h`.`platform` ORDER BY `h`.`platform`) AS `platforms`, MAX(`h`.`fatal`) AS `fatal`, MAX(`h`.`ts`) AS `last_in` FROM `" . $this->_oConfig->CNF['TABLE_ERROR_HITS'] . "` AS `h` INNER JOIN `" . $this->_oConfig->CNF['TABLE_ERRORS'] . "` AS `g` ON `g`.`id` = `h`.`error_id` WHERE " . $sWhere . " GROUP BY `h`.`error_id` ORDER BY `count` DESC, `h`.`error_id` DESC LIMIT " . max(0, (int)$iStart) . ", " . max(1, min(1000, (int)$iLimit)), $aBindings);
+    }
+
+    public function getErrorListCount($aFilter, $aStatuses, $iFirstSeenFrom = 0)
+    {
+        $aBindings = [];
+        $sWhere = $this->_getErrorHitsWhere($aFilter, $aStatuses, $aBindings, $iFirstSeenFrom);
+
+        return (int)$this->getOne("SELECT COUNT(DISTINCT `h`.`error_id`) FROM `" . $this->_oConfig->CNF['TABLE_ERROR_HITS'] . "` AS `h` INNER JOIN `" . $this->_oConfig->CNF['TABLE_ERRORS'] . "` AS `g` ON `g`.`id` = `h`.`error_id` WHERE " . $sWhere, $aBindings);
+    }
+
+    /**
+     * Hits per time bucket of some error groups, or of one with visitors too.
+     *
+     * @return array [error id => [bucket => count]]
+     */
+    public function getErrorTrends($aFilter, $aIds, $sBucket, $iOffset, $aStarts = [])
+    {
+        if(empty($aIds))
+            return [];
+
+        $aBindings = [];
+        $sWhere = $this->_getErrorHitsWhere($aFilter, [], $aBindings) . " AND `h`.`error_id` IN (" . $this->_getInSql($aIds, 'e', $aBindings) . ")";
+        $sBucketSql = $this->_getBucketSql('`h`.`ts`', $sBucket, $iOffset, $aStarts, $aBindings);
+
+        $aResult = [];
+        foreach($this->getAll("SELECT `h`.`error_id`, " . $sBucketSql . " AS `bucket`, SUM(`h`.`repeats`) AS `count` FROM `" . $this->_oConfig->CNF['TABLE_ERROR_HITS'] . "` AS `h` WHERE " . $sWhere . " GROUP BY `h`.`error_id`, `bucket`", $aBindings) as $aRow)
+            $aResult[(int)$aRow['error_id']][(int)$aRow['bucket']] = (int)$aRow['count'];
+
+        return $aResult;
+    }
+
+    public function getErrorSeries($aFilter, $iId, $sBucket, $iOffset, $aStarts = [])
+    {
+        $aBindings = ['error' => (int)$iId];
+        $sWhere = $this->_getErrorHitsWhere($aFilter, [], $aBindings) . " AND `h`.`error_id` = :error";
+        $sBucketSql = $this->_getBucketSql('`h`.`ts`', $sBucket, $iOffset, $aStarts, $aBindings);
+
+        return $this->getAllWithKey("SELECT " . $sBucketSql . " AS `bucket`, SUM(`h`.`repeats`) AS `count`, COUNT(DISTINCT `h`.`visitor`) AS `users` FROM `" . $this->_oConfig->CNF['TABLE_ERROR_HITS'] . "` AS `h` WHERE " . $sWhere . " GROUP BY `bucket` ORDER BY `bucket`", 'bucket', $aBindings);
+    }
+
+    /**
+     * One error group's hits, visits and visitors in the period.
+     */
+    public function getErrorStats($aFilter, $iId)
+    {
+        $aBindings = ['error' => (int)$iId];
+        $sWhere = $this->_getErrorHitsWhere($aFilter, [], $aBindings) . " AND `h`.`error_id` = :error";
+
+        return $this->getRow("SELECT IFNULL(SUM(`h`.`repeats`), 0) AS `count`, COUNT(DISTINCT NULLIF(`h`.`session_id`, 0)) AS `sessions`, COUNT(DISTINCT `h`.`visitor`) AS `users` FROM `" . $this->_oConfig->CNF['TABLE_ERROR_HITS'] . "` AS `h` WHERE " . $sWhere, $aBindings);
+    }
+
+    /**
+     * One error group's hits by a client column: platform, app version, browser, OS, device,
+     * country or route.
+     */
+    public function getErrorBreakdown($aFilter, $iId, $sColumn, $iLimit = 10)
+    {
+        if(!in_array($sColumn, $this->_aErrorBreakdowns, true))
+            return [];
+
+        $aBindings = ['error' => (int)$iId];
+        $sWhere = $this->_getErrorHitsWhere($aFilter, [], $aBindings) . " AND `h`.`error_id` = :error";
+
+        return $this->getAll("SELECT `h`.`" . $sColumn . "` AS `value`, SUM(`h`.`repeats`) AS `count`, COUNT(DISTINCT `h`.`visitor`) AS `users` FROM `" . $this->_oConfig->CNF['TABLE_ERROR_HITS'] . "` AS `h` WHERE " . $sWhere . " GROUP BY `h`.`" . $sColumn . "` ORDER BY `count` DESC LIMIT " . max(1, min(100, (int)$iLimit)), $aBindings);
+    }
+
+    /**
+     * One error group's latest hits in the period.
+     */
+    public function getErrorOccurrences($aFilter, $iId, $iLimit = 20)
+    {
+        $aBindings = ['error' => (int)$iId];
+        $sWhere = $this->_getErrorHitsWhere($aFilter, [], $aBindings) . " AND `h`.`error_id` = :error";
+
+        return $this->getAll("SELECT `h`.`ts`, `h`.`session_id`, `h`.`platform`, `h`.`app_version`, `h`.`browser`, `h`.`os`, `h`.`device`, `h`.`country`, `h`.`path`, `h`.`repeats`, `h`.`fatal` FROM `" . $this->_oConfig->CNF['TABLE_ERROR_HITS'] . "` AS `h` WHERE " . $sWhere . " ORDER BY `h`.`ts` DESC, `h`.`id` DESC LIMIT " . max(1, min(100, (int)$iLimit)), $aBindings);
+    }
+
+    /**
+     * A log-scale histogram of a metric (see BxAnalyticsVitals::getBucket), optionally per
+     * platform, page (route or path) or time bucket.
+     *
+     * @param array $aFilter report filter
+     * @param string $sMetric a BxAnalyticsVitals::METRICS column
+     * @param string $sDevice all, mobile (with tablets) or desktop
+     * @param string $sGroup '', platform, route, path or bucket
+     * @param array $aIn values of the group to keep (pages)
+     * @param array $aBucket [bucket, offset, starts] when grouped by time
+     * @return array rows of grp, hb, side (0 up to the good threshold, 1 between, 2 above
+     *      the poor one), n
+     */
+    public function getVitalsHistogram($aFilter, $sMetric, $sDevice, $sGroup = '', $aIn = [], $aBucket = [])
+    {
+        if(!in_array($sMetric, BxAnalyticsVitals::METRICS, true))
+            return [];
+
+        $aBindings = [
+            'side_good' => (int)$this->_oConfig->CNF['VITALS'][$sMetric][0],
+            'side_poor' => (int)$this->_oConfig->CNF['VITALS'][$sMetric][1],
+        ];
+        $sWhere = $this->_getVitalsWhere($aFilter, $sDevice, $aBindings) . " AND `v`.`" . $sMetric . "` IS NOT NULL";
+
+        switch($sGroup) {
+            case 'platform':
+            case 'route':
+            case 'path':
+                $sGroupSql = "`v`.`" . $sGroup . "`";
+                if(!empty($aIn))
+                    $sWhere .= " AND `v`.`" . $sGroup . "` IN (" . $this->_getInSql($aIn, 'g', $aBindings) . ")";
+                break;
+
+            case 'bucket':
+                $sGroupSql = $this->_getBucketSql('`v`.`ts`', $aBucket[0], $aBucket[1], $aBucket[2], $aBindings);
+                break;
+
+            default:
+                $sGroupSql = "''";
+        }
+
+        return $this->getAll("SELECT " . $sGroupSql . " AS `grp`, " . $this->_getVitalsBucketSql($sMetric) . " AS `hb`, (`v`.`" . $sMetric . "` > :side_good) + (`v`.`" . $sMetric . "` > :side_poor) AS `side`, COUNT(*) AS `n` FROM `" . $this->_oConfig->CNF['TABLE_VITALS'] . "` AS `v` WHERE " . $sWhere . " GROUP BY `grp`, `hb`, `side`", $aBindings);
+    }
+
+    /**
+     * Log-scale histograms of several metrics in one pass over the period: each row is
+     * paired with every metric (a derived table of their names), and the metric's own
+     * column is bucketed.
+     *
+     * @return array rows of grp (the metric), hb, n
+     */
+    public function getVitalsHistograms($aFilter, $aMetrics, $sDevice)
+    {
+        $aMetrics = array_values(array_intersect(BxAnalyticsVitals::METRICS, $aMetrics));
+        if(empty($aMetrics))
+            return [];
+
+        $aBindings = [];
+        $sWhere = $this->_getVitalsWhere($aFilter, $sDevice, $aBindings);
+
+        $aNames = [];
+        $aValue = [];
+        $aBucket = [];
+        foreach($aMetrics as $sMetric) {
+            // names from BxAnalyticsVitals::METRICS only
+            $aNames[] = "SELECT '" . $sMetric . "' AS `metric`";
+            $aValue[] = "WHEN '" . $sMetric . "' THEN `v`.`" . $sMetric . "`";
+            $aBucket[] = "WHEN '" . $sMetric . "' THEN " . $this->_getVitalsBucketSql($sMetric);
+        }
+
+        // the vitals table is read first and once; the metric names join to each row
+        return $this->getAll("SELECT `m`.`metric` AS `grp`, CASE `m`.`metric` " . implode(' ', $aBucket) . " END AS `hb`, COUNT(*) AS `n` FROM `" . $this->_oConfig->CNF['TABLE_VITALS'] . "` AS `v` STRAIGHT_JOIN (" . implode(' UNION ALL ', $aNames) . ") AS `m` WHERE " . $sWhere . " AND CASE `m`.`metric` " . implode(' ', $aValue) . " END IS NOT NULL GROUP BY `grp`, `hb`", $aBindings);
+    }
+
+    /**
+     * A metric's histogram bucket, see BxAnalyticsVitals::getBucket.
+     */
+    protected function _getVitalsBucketSql($sMetric)
+    {
+        return "IF(`v`.`" . $sMetric . "` = 0, -1, FLOOR(LN(`v`.`" . $sMetric . "`) * " . BxAnalyticsVitals::BUCKETS . "))";
+    }
+
+    /**
+     * Samples, good and poor values of every metric in one pass.
+     *
+     * @param array $aThresholds metric => [good up to, poor above], stored units
+     * @return array metric => [samples, good, poor]
+     */
+    public function getVitalsDistribution($aFilter, $sDevice, $aThresholds)
+    {
+        $aBindings = [];
+        $sWhere = $this->_getVitalsWhere($aFilter, $sDevice, $aBindings);
+
+        $aSql = [];
+        foreach(BxAnalyticsVitals::METRICS as $sMetric) {
+            $aSql[] = "SUM(`v`.`" . $sMetric . "` IS NOT NULL) AS `" . $sMetric . "_n`, SUM(`v`.`" . $sMetric . "` <= :" . $sMetric . "_good) AS `" . $sMetric . "_good`, SUM(`v`.`" . $sMetric . "` > :" . $sMetric . "_poor) AS `" . $sMetric . "_poor`";
+            $aBindings[$sMetric . '_good'] = (int)$aThresholds[$sMetric][0];
+            $aBindings[$sMetric . '_poor'] = (int)$aThresholds[$sMetric][1];
+        }
+
+        $aRow = $this->getRow("SELECT " . implode(', ', $aSql) . " FROM `" . $this->_oConfig->CNF['TABLE_VITALS'] . "` AS `v` WHERE " . $sWhere, $aBindings);
+
+        $aResult = [];
+        foreach(BxAnalyticsVitals::METRICS as $sMetric)
+            $aResult[$sMetric] = [
+                'samples' => isset($aRow[$sMetric . '_n']) ? (int)$aRow[$sMetric . '_n'] : 0,
+                'good' => isset($aRow[$sMetric . '_good']) ? (int)$aRow[$sMetric . '_good'] : 0,
+                'poor' => isset($aRow[$sMetric . '_poor']) ? (int)$aRow[$sMetric . '_poor'] : 0,
+            ];
+
+        return $aResult;
+    }
+
+    /**
+     * Pages (routes or paths) with the most samples of a metric.
+     */
+    public function getVitalsPages($aFilter, $sMetric, $sDevice, $sGroup, $iLimit, $iStart = 0)
+    {
+        if(!in_array($sMetric, BxAnalyticsVitals::METRICS, true) || !in_array($sGroup, ['route', 'path'], true))
+            return [];
+
+        $aBindings = [];
+        $sWhere = $this->_getVitalsWhere($aFilter, $sDevice, $aBindings) . " AND `v`.`" . $sMetric . "` IS NOT NULL";
+
+        return $this->getPairs("SELECT `v`.`" . $sGroup . "` AS `value`, COUNT(*) AS `n` FROM `" . $this->_oConfig->CNF['TABLE_VITALS'] . "` AS `v` WHERE " . $sWhere . " GROUP BY `v`.`" . $sGroup . "` ORDER BY `n` DESC, `value` LIMIT " . max(0, (int)$iStart) . ", " . max(1, min(1000, (int)$iLimit)), 'value', 'n', $aBindings);
+    }
+
+    public function getVitalsPagesCount($aFilter, $sMetric, $sDevice, $sGroup)
+    {
+        if(!in_array($sMetric, BxAnalyticsVitals::METRICS, true) || !in_array($sGroup, ['route', 'path'], true))
+            return 0;
+
+        $aBindings = [];
+        $sWhere = $this->_getVitalsWhere($aFilter, $sDevice, $aBindings) . " AND `v`.`" . $sMetric . "` IS NOT NULL";
+
+        return (int)$this->getOne("SELECT COUNT(DISTINCT `v`.`" . $sGroup . "`) FROM `" . $this->_oConfig->CNF['TABLE_VITALS'] . "` AS `v` WHERE " . $sWhere, $aBindings);
+    }
+
+    /**
+     * WHERE for error hits (alias `h`, joined to their group `g` when statuses are given) in
+     * the period. Filters use the hit's own client columns; other dimensions go through its
+     * visit, so errors outside a visit drop out of those.
+     */
+    protected function _getErrorHitsWhere($aFilter, $aStatuses, &$aBindings, $iFirstSeenFrom = 0)
+    {
+        $sWhere = $this->_getHealthWhere('h', $this->_aErrorHitDimensions, $aFilter, $aBindings);
+
+        if(!empty($aStatuses))
+            $sWhere .= " AND `g`.`status` IN (" . $this->_getInSql($aStatuses, 'st', $aBindings) . ")";
+
+        if($iFirstSeenFrom) {
+            $sWhere .= " AND `g`.`first_seen` >= :first_seen";
+            $aBindings['first_seen'] = (int)$iFirstSeenFrom;
+        }
+
+        return $sWhere;
+    }
+
+    /**
+     * WHERE for vitals (alias `v`) in the period, on a kind of device.
+     */
+    protected function _getVitalsWhere($aFilter, $sDevice, &$aBindings)
+    {
+        $sWhere = $this->_getHealthWhere('v', ['device', 'path'], $aFilter, $aBindings);
+
+        if($sDevice == 'mobile')
+            $sWhere .= " AND `v`.`device` IN ('mobile', 'tablet')";
+        elseif($sDevice == 'desktop')
+            $sWhere .= " AND `v`.`device` = 'desktop'";
+
+        return $sWhere;
+    }
+
+    /**
+     * Period, platform and dimension filters for a health table with `ts`, `platform` and
+     * `session_id` columns. Dimensions in `$aOwn` are columns of the table.
+     */
+    protected function _getHealthWhere($sAlias, $aOwn, $aFilter, &$aBindings)
+    {
+        $aSql = ['`' . $sAlias . '`.`ts` >= :from', '`' . $sAlias . '`.`ts` < :to'];
+        $aBindings['from'] = (int)$aFilter['from'];
+        $aBindings['to'] = (int)$aFilter['to'];
+
+        if($aFilter['platform'] !== '') {
+            $aSql[] = '`' . $sAlias . '`.`platform` = :platform';
+            $aBindings['platform'] = $aFilter['platform'];
+        }
+
+        $aThrough = [];
+        foreach($aFilter['dims'] as $sName => $sValue) {
+            if(in_array($sName, $aOwn, true)) {
+                $aSql[] = '`' . $sAlias . '`.`' . $sName . '` = :d_' . $sName;
+                // health hits keep only the page parameters of a path, a pageview's path its whole query
+                $aBindings['d_' . $sName] = $sName == 'path' ? get_mb_substr(BxAnalyticsErrors::getCleanPath($sValue), 0, 255) : $sValue;
+            }
+            elseif(isset($this->_aSessionDimensions[$sName])) {
+                $aThrough[] = '`sf`.`' . $this->_aSessionDimensions[$sName] . '` = :d_' . $sName;
+                $aBindings['d_' . $sName] = $sValue;
+            }
+            elseif(isset($this->_aEventDimensions[$sName])) {
+                $aSql[] = $this->_getPageVisitsInSql('`' . $sAlias . '`.`session_id`', ['`ef`.`' . $this->_aEventDimensions[$sName] . '` = :d_' . $sName], $aFilter, $aBindings, '_' . $sName);
+                $aBindings['d_' . $sName] = $sValue;
+            }
+        }
+
+        // errors and vitals are few rows next to pageviews: each looks its visit up
+        if(!empty($aThrough))
+            $aSql[] = $this->_getVisitsInSql('`' . $sAlias . '`.`session_id`', $aThrough, $aFilter, $aBindings, true);
+
+        return implode(' AND ', $aSql);
+    }
+
+    /**
+     * Placeholders for an IN list, with their bindings.
+     */
+    protected function _getInSql($aValues, $sPrefix, &$aBindings)
+    {
+        $aIn = [];
+        foreach(array_values($aValues) as $i => $mixedValue) {
+            $aIn[] = ':' . $sPrefix . $i;
+            $aBindings[$sPrefix . $i] = $mixedValue;
+        }
+
+        return implode(', ', $aIn);
+    }
+
+    /**
+     * Insert rows with the same keys in one query.
+     */
+    protected function _insertRows($sTable, $aRows)
+    {
+        if(empty($aRows))
+            return 0;
+
+        $aFields = array_keys(reset($aRows));
+        $aBindings = [];
+        $aValues = [];
+        foreach(array_values($aRows) as $i => $aRow) {
+            $aPlaceholders = [];
+            foreach($aFields as $sField) {
+                $aPlaceholders[] = ':' . $sField . '_' . $i;
+                $aBindings[$sField . '_' . $i] = $aRow[$sField];
+            }
+            $aValues[] = '(' . implode(', ', $aPlaceholders) . ')';
+        }
+
+        return (int)$this->query("INSERT INTO `" . $sTable . "` (`" . implode('`, `', $aFields) . "`) VALUES " . implode(', ', $aValues), $aBindings);
+    }
+
+    /*
+     * Revenue: the ledger and subscriptions
+     */
+
+    /**
+     * Ledger columns a revenue split may group by, as name => column.
+     */
+    protected $_aRevenueDimensions = [
+        'module' => 'module',
+        'provider' => 'provider',
+        'platform' => 'platform',
+        'first_channel' => 'first_channel',
+        'first_referrer_host' => 'first_referrer_host',
+        'first_utm_campaign' => 'first_utm_campaign',
+        'last_channel' => 'last_channel',
+        'last_referrer_host' => 'last_referrer_host',
+        'last_utm_campaign' => 'last_utm_campaign',
+        'seller_id' => 'seller_id',
+    ];
+
+    /**
+     * Add a money movement unless the same movement (payment module, transaction, kind) is
+     * there already; returns 1 when it was added.
+     */
+    public function insertRevenue($aRow)
+    {
+        $aBindings = [];
+        foreach($aRow as $sField => $mixedValue)
+            $aBindings[$sField] = $mixedValue;
+
+        $sFields = '`' . implode('`, `', array_keys($aBindings)) . '`';
+        $sValues = ':' . implode(', :', array_keys($aBindings));
+
+        return (int)$this->query("INSERT IGNORE INTO `" . $this->_oConfig->CNF['TABLE_REVENUE'] . "` (" . $sFields . ") VALUES (" . $sValues . ")", $aBindings);
+    }
+
+    /**
+     * Ledger rows of an order, of some kinds, in transaction order.
+     */
+    public function getRevenueRows($sPayment, $iPendingId, $aKinds = [])
+    {
+        $aBindings = ['payment' => $sPayment, 'pending' => (int)$iPendingId];
+
+        return $this->getAll("SELECT * FROM `" . $this->_oConfig->CNF['TABLE_REVENUE'] . "` WHERE `payment` = :payment AND `pending_id` = :pending" . $this->_getKindsSql($aKinds, $aBindings) . " ORDER BY `transaction_id`, `id`", $aBindings);
+    }
+
+    /**
+     * The ledger row of a transaction, of some kinds.
+     */
+    public function getRevenueRow($sPayment, $iTransactionId, $aKinds = [])
+    {
+        $aBindings = ['payment' => $sPayment, 'transaction' => (int)$iTransactionId];
+
+        return $this->getRow("SELECT * FROM `" . $this->_oConfig->CNF['TABLE_REVENUE'] . "` WHERE `payment` = :payment AND `transaction_id` = :transaction" . $this->_getKindsSql($aKinds, $aBindings) . " ORDER BY `id` LIMIT 1", $aBindings);
+    }
+
+    /**
+     * Time of the first paid charge of each order the ledger has (a trial's registration is
+     * not one): [pending id => time].
+     */
+    public function getRevenueFirstCharges($sPayment, $aPendingIds)
+    {
+        $aPendingIds = array_values(array_unique(array_map('intval', $aPendingIds)));
+        if(empty($aPendingIds))
+            return [];
+
+        return $this->getPairs("SELECT `pending_id`, MIN(`ts`) AS `first` FROM `" . $this->_oConfig->CNF['TABLE_REVENUE'] . "` WHERE `payment` = :payment AND `pending_id` IN (" . $this->implode_escape($aPendingIds) . ") AND `kind` IN ('sale', 'renewal') GROUP BY `pending_id`", 'pending_id', 'first', ['payment' => $sPayment]);
+    }
+
+    /**
+     * Transactions of a list the ledger has as a charge (a sale, a renewal or a trial's
+     * registration): [transaction id, ...].
+     */
+    public function getRevenueCharged($sPayment, $aTransactionIds)
+    {
+        $aTransactionIds = array_values(array_unique(array_map('intval', $aTransactionIds)));
+        if(empty($aTransactionIds))
+            return [];
+
+        return array_map('intval', $this->getColumn("SELECT DISTINCT `transaction_id` FROM `" . $this->_oConfig->CNF['TABLE_REVENUE'] . "` WHERE `payment` = :payment AND `transaction_id` IN (" . $this->implode_escape($aTransactionIds) . ") AND `kind` IN ('sale', 'renewal', 'trial')", ['payment' => $sPayment]));
+    }
+
+    /**
+     * A charge got its kind once its subscription's trial was known (`kind` is part of the
+     * unique key: a transaction has one charge row, so the change never meets another row).
+     */
+    public function updateRevenueKind($iId, $sKind)
+    {
+        return (int)$this->query("UPDATE IGNORE `" . $this->_oConfig->CNF['TABLE_REVENUE'] . "` SET `kind` = :kind WHERE `id` = :id", ['id' => (int)$iId, 'kind' => $sKind]);
+    }
+
+    /**
+     * Paid charges of an order in the ledger from a time on (a trial's registration is not
+     * one): first and last time, and how many (the rows of one charge share their time).
+     */
+    public function getRevenuePaid($sPayment, $iPendingId, $iSince = 0)
+    {
+        return $this->getRow("SELECT IFNULL(MIN(`ts`), 0) AS `first`, IFNULL(MAX(`ts`), 0) AS `last`, COUNT(DISTINCT `ts`) AS `count` FROM `" . $this->_oConfig->CNF['TABLE_REVENUE'] . "` WHERE `payment` = :payment AND `pending_id` = :pending AND `kind` IN ('sale', 'renewal') AND `ts` >= :since", [
+            'payment' => $sPayment,
+            'pending' => (int)$iPendingId,
+            'since' => (int)$iSince,
+        ]);
+    }
+
+    /**
+     * Give an order's rows that have no visit yet the buyer's platform, visit and sources.
+     */
+    public function updateRevenueContext($sPayment, $iPendingId, $aSet)
+    {
+        $aBindings = ['payment' => $sPayment, 'pending' => (int)$iPendingId];
+        $aSql = [];
+        foreach($aSet as $sField => $mixedValue) {
+            $aSql[] = '`' . $sField . '` = :f_' . $sField;
+            $aBindings['f_' . $sField] = $mixedValue;
+        }
+
+        return (int)$this->query("UPDATE `" . $this->_oConfig->CNF['TABLE_REVENUE'] . "` SET " . implode(', ', $aSql) . " WHERE `payment` = :payment AND `pending_id` = :pending AND `session_id` = 0", $aBindings);
+    }
+
+    /**
+     * The traffic source of a member's first visit, and of the latest one that started by a time.
+     *
+     * @return array ['first' => row or [], 'last' => row or []] with channel, referrer_host, utm_campaign
+     */
+    public function getSessionSources($iProfileId, $iTime)
+    {
+        $sSql = "SELECT `channel`, `referrer_host`, `utm_campaign` FROM `" . $this->_oConfig->CNF['TABLE_SESSIONS'] . "` WHERE `profile_id` = :profile AND `started` <= :time ORDER BY `started` %s LIMIT 1";
+        $aBindings = ['profile' => (int)$iProfileId, 'time' => (int)$iTime];
+
+        return [
+            'first' => $this->getRow(sprintf($sSql, 'ASC'), $aBindings),
+            'last' => $this->getRow(sprintf($sSql, 'DESC'), $aBindings),
+        ];
+    }
+
+    /**
+     * A deleted profile: its money stays in the ledger, its visit and traffic sources don't.
+     */
+    public function forgetRevenueProfile($iProfileId)
+    {
+        return (int)$this->query("UPDATE `" . $this->_oConfig->CNF['TABLE_REVENUE'] . "` SET `session_id` = 0, `first_channel` = '', `first_referrer_host` = '', `first_utm_campaign` = '', `last_channel` = '', `last_referrer_host` = '', `last_utm_campaign` = '' WHERE `client_id` = :profile", ['profile' => (int)$iProfileId]);
+    }
+
+    /**
+     * Rows the import of past payments wrote (they have no platform).
+     */
+    public function getRevenueImportedCount($sPayment)
+    {
+        return (int)$this->getOne("SELECT COUNT(*) FROM `" . $this->_oConfig->CNF['TABLE_REVENUE'] . "` WHERE `payment` = :payment AND `platform` = ''", ['payment' => $sPayment]);
+    }
+
+    public function hasRevenue($sPayment)
+    {
+        return (bool)$this->getOne("SELECT 1 FROM `" . $this->_oConfig->CNF['TABLE_REVENUE'] . "` WHERE `payment` = :payment LIMIT 1", ['payment' => $sPayment]);
+    }
+
+    public function getSubscriptionRow($sPayment, $iPendingId)
+    {
+        return $this->getRow("SELECT * FROM `" . $this->_oConfig->CNF['TABLE_SUBSCRIPTIONS'] . "` WHERE `payment` = :payment AND `pending_id` = :pending", [
+            'payment' => $sPayment,
+            'pending' => (int)$iPendingId,
+        ]);
+    }
+
+    /**
+     * Start and trial (days) of the subscriptions with a trial among some orders:
+     * [pending id => ['started' => time, 'trial' => days]].
+     */
+    public function getSubscriptionTrials($sPayment, $aPendingIds)
+    {
+        $aPendingIds = array_values(array_unique(array_map('intval', $aPendingIds)));
+        if(empty($aPendingIds))
+            return [];
+
+        return $this->getAllWithKey("SELECT `pending_id`, `started`, `trial` FROM `" . $this->_oConfig->CNF['TABLE_SUBSCRIPTIONS'] . "` WHERE `payment` = :payment AND `pending_id` IN (" . $this->implode_escape($aPendingIds) . ") AND `trial` > 0", 'pending_id', ['payment' => $sPayment]);
+    }
+
+    /**
+     * A new subscription, or new terms for one the ledger has (it keeps its start, payments
+     * and cancellation).
+     */
+    public function saveSubscriptionRow($aRow)
+    {
+        $aBindings = [];
+        foreach($aRow as $sField => $mixedValue)
+            $aBindings[$sField] = $mixedValue;
+
+        $aUpdate = [];
+        foreach(['subscription_id', 'client_id', 'seller_id', 'module_id', 'module', 'item_id', 'provider', 'period', 'period_unit', 'trial', 'amount_default', 'mrr_default'] as $sField)
+            if(array_key_exists($sField, $aBindings))
+                $aUpdate[] = '`' . $sField . '` = VALUES(`' . $sField . '`)';
+        if(array_key_exists('status', $aBindings))
+            $aUpdate[] = "`status` = IF(`canceled` > 0, `status`, VALUES(`status`))";
+
+        $sFields = '`' . implode('`, `', array_keys($aBindings)) . '`';
+        $sValues = ':' . implode(', :', array_keys($aBindings));
+
+        return $this->query("INSERT INTO `" . $this->_oConfig->CNF['TABLE_SUBSCRIPTIONS'] . "` (" . $sFields . ") VALUES (" . $sValues . ") ON DUPLICATE KEY UPDATE " . implode(', ', $aUpdate), $aBindings) !== false;
+    }
+
+    /**
+     * A subscription read from the payment module's history: fills what the ledger doesn't
+     * know yet and leaves what it recorded live.
+     */
+    public function importSubscriptionRow($aRow)
+    {
+        $aBindings = [];
+        foreach($aRow as $sField => $mixedValue)
+            $aBindings[$sField] = $mixedValue;
+
+        $sFields = '`' . implode('`, `', array_keys($aBindings)) . '`';
+        $sValues = ':' . implode(', :', array_keys($aBindings));
+
+        return $this->query("INSERT INTO `" . $this->_oConfig->CNF['TABLE_SUBSCRIPTIONS'] . "` (" . $sFields . ") VALUES (" . $sValues . ") ON DUPLICATE KEY UPDATE
+            `first_paid` = IF(`first_paid` = 0, VALUES(`first_paid`), LEAST(`first_paid`, IF(VALUES(`first_paid`) > 0, VALUES(`first_paid`), `first_paid`))),
+            `last_paid` = GREATEST(`last_paid`, VALUES(`last_paid`)),
+            `paid_count` = GREATEST(`paid_count`, VALUES(`paid_count`)),
+            `reason` = IF(`canceled` = 0, VALUES(`reason`), `reason`),
+            `status` = IF(`canceled` = 0 AND VALUES(`canceled`) > 0, VALUES(`status`), `status`),
+            `canceled` = IF(`canceled` = 0, VALUES(`canceled`), `canceled`)", $aBindings) !== false;
+    }
+
+    public function updateSubscriptionRow($sPayment, $iPendingId, $aSet)
+    {
+        $aBindings = ['payment' => $sPayment, 'pending' => (int)$iPendingId];
+        $aSql = [];
+        foreach($aSet as $sField => $mixedValue) {
+            $aSql[] = '`' . $sField . '` = :f_' . $sField;
+            $aBindings['f_' . $sField] = $mixedValue;
+        }
+
+        return (int)$this->query("UPDATE `" . $this->_oConfig->CNF['TABLE_SUBSCRIPTIONS'] . "` SET " . implode(', ', $aSql) . " WHERE `payment` = :payment AND `pending_id` = :pending", $aBindings);
+    }
+
+    /**
+     * Charges of a subscription were recorded (`$iCount` charges, from `$iFirst` to `$iLast`):
+     * it is active (unless cancelled) since its first.
+     */
+    public function addSubscriptionPayments($sPayment, $iPendingId, $iCount, $iFirst, $iLast)
+    {
+        return (int)$this->query("UPDATE `" . $this->_oConfig->CNF['TABLE_SUBSCRIPTIONS'] . "` SET `first_paid` = IF(`first_paid` = 0, :first, LEAST(`first_paid`, :first)), `last_paid` = GREATEST(`last_paid`, :last), `paid_count` = `paid_count` + :count, `status` = IF(`canceled` > 0, `status`, 'active') WHERE `payment` = :payment AND `pending_id` = :pending", [
+            'payment' => $sPayment,
+            'pending' => (int)$iPendingId,
+            'count' => (int)$iCount,
+            'first' => (int)$iFirst,
+            'last' => (int)$iLast,
+        ]);
+    }
+
+    /**
+     * Gross (sales and renewals), refunds (refunds and reversals, positive), net, orders,
+     * paying customers, renewals and movements without an exchange rate in a period.
+     *
+     * @param array $aFilter report filter with `payment`, the payment module to read
+     * @param int $iCash 1 for money, 0 for orders paid with credits, null for both
+     */
+    public function getRevenueTotals($aFilter, $iCash = 1)
+    {
+        $aBindings = [];
+        $sWhere = $this->_getRevenueWhere($aFilter, $iCash, $aBindings);
+
+        return $this->getRow("SELECT " . $this->_getRevenueSums() . ", COUNT(*) AS `movements` FROM `" . $this->_oConfig->CNF['TABLE_REVENUE'] . "` AS `r` WHERE " . $sWhere, $aBindings);
+    }
+
+    /**
+     * The same sums per time bucket: rows keyed by bucket start.
+     */
+    public function getRevenueSeries($aFilter, $iCash = 1)
+    {
+        $aBindings = [];
+        $sWhere = $this->_getRevenueWhere($aFilter, $iCash, $aBindings);
+        $sBucket = $this->_getBucketSql('`r`.`ts`', $aFilter['bucket'], $aFilter['offset'], $aFilter['starts'], $aBindings);
+
+        return $this->getAllWithKey("SELECT " . $sBucket . " AS `bucket`, " . $this->_getRevenueSums() . " FROM `" . $this->_oConfig->CNF['TABLE_REVENUE'] . "` AS `r` WHERE " . $sWhere . " GROUP BY `bucket` ORDER BY `bucket`", 'bucket', $aBindings);
+    }
+
+    /**
+     * Net and gross revenue, customers and orders per value of a ledger column, the largest first.
+     */
+    public function getRevenueSplit($aFilter, $sDimension, $iLimit, $iCash = 1)
+    {
+        if(!isset($this->_aRevenueDimensions[$sDimension]))
+            return [];
+
+        $aBindings = [];
+        $sWhere = $this->_getRevenueWhere($aFilter, $iCash, $aBindings);
+        $sColumn = '`r`.`' . $this->_aRevenueDimensions[$sDimension] . '`';
+
+        return $this->getAll("SELECT " . $sColumn . " AS `value`, " . $this->_getRevenueSums() . " FROM `" . $this->_oConfig->CNF['TABLE_REVENUE'] . "` AS `r` WHERE " . $sWhere . " GROUP BY " . $sColumn . " ORDER BY `net` DESC LIMIT " . max(1, min(1000, (int)$iLimit)), $aBindings);
+    }
+
+    /**
+     * Items that brought the most net revenue, with units sold and orders.
+     */
+    public function getRevenueTopItems($aFilter, $iLimit)
+    {
+        $aBindings = [];
+        $sWhere = $this->_getRevenueWhere($aFilter, 1, $aBindings);
+
+        return $this->getAll("SELECT `r`.`module_id`, MAX(`r`.`module`) AS `module`, `r`.`item_id`, IFNULL(SUM(`r`.`amount_default`), 0) AS `net`, IFNULL(SUM(IF(`r`.`kind` = 'sale', `r`.`item_count`, 0)), 0) AS `units`, COUNT(DISTINCT IF(`r`.`kind` = 'sale', `r`.`pending_id`, NULL)) AS `orders`, SUM(`r`.`kind` = 'renewal') AS `renewals` FROM `" . $this->_oConfig->CNF['TABLE_REVENUE'] . "` AS `r` WHERE " . $sWhere . " GROUP BY `r`.`module_id`, `r`.`item_id` ORDER BY `net` DESC LIMIT " . max(1, min(100, (int)$iLimit)), $aBindings);
+    }
+
+    /**
+     * Revenue the site sold itself (`$iSiteAdmin`, 0 when unknown), revenue of other sellers,
+     * and commission invoices paid to the site (rows of the payment module's own items).
+     */
+    public function getRevenueSellers($aFilter, $iSiteAdmin)
+    {
+        $aBindings = ['admin' => (int)$iSiteAdmin, 'payment_module' => isset($aFilter['payment']) ? $aFilter['payment'] : ''];
+        $sWhere = $this->_getRevenueWhere($aFilter, 1, $aBindings);
+
+        return $this->getRow("SELECT IFNULL(SUM(IF(`r`.`module` <> :payment_module AND `r`.`seller_id` = :admin, `r`.`amount_default`, 0)), 0) AS `site`, IFNULL(SUM(IF(`r`.`module` <> :payment_module AND `r`.`seller_id` <> :admin, `r`.`amount_default`, 0)), 0) AS `vendors`, IFNULL(SUM(IF(`r`.`module` = :payment_module, `r`.`amount_default`, 0)), 0) AS `commissions`, COUNT(DISTINCT IF(`r`.`module` <> :payment_module AND `r`.`seller_id` <> :admin, `r`.`seller_id`, NULL)) AS `vendor_count` FROM `" . $this->_oConfig->CNF['TABLE_REVENUE'] . "` AS `r` WHERE " . $sWhere, $aBindings);
+    }
+
+    /**
+     * Net revenue of members over all the ledger, trials' registrations aside (they took no
+     * money): [profile id => amount].
+     */
+    public function getLifetimeValues($aProfileIds, $sPayment)
+    {
+        $aProfileIds = array_values(array_unique(array_filter(array_map('intval', $aProfileIds))));
+        if(empty($aProfileIds))
+            return [];
+
+        return $this->getPairs("SELECT `client_id`, IFNULL(SUM(`amount_default`), 0) AS `ltv` FROM `" . $this->_oConfig->CNF['TABLE_REVENUE'] . "` WHERE `cash` = 1 AND `payment` = :payment AND `kind` <> 'trial' AND `client_id` IN (" . $this->implode_escape($aProfileIds) . ") GROUP BY `client_id`", 'client_id', 'ltv', ['payment' => $sPayment]);
+    }
+
+    /**
+     * Subscriptions that were paying or in trial at some time in a period: paid or started
+     * with a trial before its end, and not cancelled or lapsed before its start.
+     *
+     * @param array $aEnd when an unpaid subscription stops paying: unit_seconds [period unit =>
+     *      seconds], unpaid (the status), grace (seconds); none to read lapsed ones too
+     */
+    public function getSubscriptionRows($aFilter, $aEnd = [])
+    {
+        $aBindings = ['from' => (int)$aFilter['from'], 'to' => (int)$aFilter['to'], 'payment' => isset($aFilter['payment']) ? $aFilter['payment'] : ''];
+        $sPlatform = '';
+        if($aFilter['platform'] !== '') {
+            $sPlatform = ' AND `platform` = :platform';
+            $aBindings['platform'] = $aFilter['platform'];
+        }
+
+        // an unpaid subscription stopped paying when its last paid period and the grace time
+        // ended, without a cancellation: those that stopped before the period stay out, or
+        // every lapsed one would be read forever (see BxAnalyticsReports::_getSubscriptionEnd)
+        $sLapsed = '';
+        if(!empty($aEnd['unit_seconds'])) {
+            $aUnits = [];
+            foreach($aEnd['unit_seconds'] as $sUnit => $iSeconds) {
+                $sBinding = 'unit' . count($aUnits);
+                $aUnits[] = 'WHEN :' . $sBinding . ' THEN ' . (int)$iSeconds;
+                $aBindings[$sBinding] = $sUnit;
+            }
+            $sLapsed = " AND NOT (`canceled` = 0 AND `status` = :unpaid AND `last_paid` > 0 AND `last_paid` + :grace + GREATEST(1, `period`) * (CASE `period_unit` " . implode(' ', $aUnits) . " ELSE 0 END) < :from)";
+            $aBindings['unpaid'] = $aEnd['unpaid'];
+            $aBindings['grace'] = (int)$aEnd['grace'];
+        }
+
+        return $this->getAll("SELECT `pending_id`, `module`, `item_id`, `period`, `period_unit`, `trial`, `mrr_default`, `started`, `first_paid`, `last_paid`, `canceled`, `status` FROM `" . $this->_oConfig->CNF['TABLE_SUBSCRIPTIONS'] . "` WHERE `payment` = :payment" . $sPlatform . " AND (`canceled` = 0 OR `canceled` >= :from)" . $sLapsed . " AND ((`first_paid` > 0 AND `first_paid` < :to) OR ((`trial` > 0 OR `status` = 'trial') AND `started` < :to))", $aBindings);
+    }
+
+    /**
+     * Why subscriptions were cancelled in a period: [reason => count], the most frequent first.
+     * `cancel` is the payment module's default for no reason, counted with the empty one.
+     */
+    public function getSubscriptionReasons($aFilter, $iLimit)
+    {
+        $aBindings = ['from' => (int)$aFilter['from'], 'to' => (int)$aFilter['to'], 'payment' => isset($aFilter['payment']) ? $aFilter['payment'] : ''];
+        $sPlatform = '';
+        if($aFilter['platform'] !== '') {
+            $sPlatform = ' AND `platform` = :platform';
+            $aBindings['platform'] = $aFilter['platform'];
+        }
+
+        return $this->getPairs("SELECT IF(`reason` = 'cancel', '', `reason`) AS `cause`, COUNT(*) AS `count` FROM `" . $this->_oConfig->CNF['TABLE_SUBSCRIPTIONS'] . "` WHERE `payment` = :payment" . $sPlatform . " AND `canceled` >= :from AND `canceled` < :to GROUP BY `cause` ORDER BY `count` DESC LIMIT " . max(1, min(100, (int)$iLimit)), 'cause', 'count', $aBindings);
+    }
+
+    protected function _getRevenueSums()
+    {
+        return "IFNULL(SUM(IF(`r`.`kind` IN ('sale', 'renewal'), `r`.`amount_default`, 0)), 0) AS `gross`, IFNULL(-SUM(IF(`r`.`kind` IN ('refund', 'reversal'), `r`.`amount_default`, 0)), 0) AS `refunds`, IFNULL(SUM(`r`.`amount_default`), 0) AS `net`, COUNT(DISTINCT IF(`r`.`kind` = 'sale', `r`.`pending_id`, NULL)) AS `orders`, COUNT(DISTINCT IF(`r`.`kind` IN ('sale', 'renewal'), `r`.`client_id`, NULL)) AS `customers`, COUNT(DISTINCT IF(`r`.`kind` = 'renewal', CONCAT(`r`.`pending_id`, ':', `r`.`ts`), NULL)) AS `renewals`, SUM(`r`.`kind` IN ('refund', 'reversal')) AS `refund_count`, IFNULL(SUM(`r`.`amount_default` IS NULL), 0) AS `unconverted`";
+    }
+
+    /**
+     * WHERE for ledger rows (alias `r`) of one payment module in the period, money or credits
+     * (null: both), on the filter's platform. A trial's registration took no money: no revenue
+     * sum, count or chart reads it.
+     */
+    protected function _getRevenueWhere($aFilter, $iCash, &$aBindings)
+    {
+        $aSql = ['`r`.`ts` >= :from', '`r`.`ts` < :to', '`r`.`payment` = :payment', "`r`.`kind` <> 'trial'"];
+        if($iCash !== null) {
+            array_unshift($aSql, '`r`.`cash` = :cash');
+            $aBindings['cash'] = (int)$iCash ? 1 : 0;
+        }
+        $aBindings['from'] = (int)$aFilter['from'];
+        $aBindings['to'] = (int)$aFilter['to'];
+        $aBindings['payment'] = isset($aFilter['payment']) ? (string)$aFilter['payment'] : '';
+
+        if($aFilter['platform'] !== '') {
+            $aSql[] = '`r`.`platform` = :platform';
+            $aBindings['platform'] = $aFilter['platform'];
+        }
+
+        return implode(' AND ', $aSql);
+    }
+
+    protected function _getKindsSql($aKinds, &$aBindings)
+    {
+        if(empty($aKinds))
+            return '';
+
+        $aIn = [];
+        foreach(array_values($aKinds) as $i => $sKind) {
+            $aIn[] = ':k' . $i;
+            $aBindings['k' . $i] = $sKind;
+        }
+
+        return ' AND `kind` IN (' . implode(', ', $aIn) . ')';
+    }
+
+    /*
+     * Content reports
+     */
+
+    public function getGrowth($sTableName, $sColumnAdded, $iDateFrom, $iDateTo)
+    {
+        $aBindings = array(
+            'datefrom' => $iDateFrom,
+            'dateto' => $iDateTo
+        );
+        $sQuery = "SELECT DATE(FROM_UNIXTIME(`" . $sColumnAdded . "`)) AS `period`, YEAR(FROM_UNIXTIME(`" . $sColumnAdded . "`)) AS `year`, COUNT(*) AS `count` FROM " . $sTableName . " WHERE `" . $sColumnAdded . "` >= :datefrom AND `" . $sColumnAdded . "` <= :dateto GROUP BY `period`, `year` ORDER BY `year`, `period` ASC";
+        return $this->getAll($sQuery, $aBindings);
+    }
+
+    public function getGrowthInitValue($sTableName, $sColumnAdded, $iDateFrom)
+    {
+        $aBindings = array(
+            'datefrom' => $iDateFrom
+        );
+        $sQuery = "SELECT COUNT(*) AS `count` FROM " . $sTableName . " WHERE `" . $sColumnAdded . "` < :datefrom ";
+        return $this->getOne($sQuery, $aBindings);
+    }
+
+    public function getTopContentByLikes($sModuleName, $sTableName, $iDateFrom, $iDateTo)
+    {
+        $aBindings = array(
+            'datefrom' => $iDateFrom,
+            'dateto' => $iDateTo
+        );
+        $sQuery = "SELECT `object_id`,'" . $sModuleName . "' AS `module`, COUNT(*) AS `value` FROM `" . $sTableName . "` WHERE `date` >= :datefrom AND `date` <= :dateto GROUP BY `object_id` ORDER BY COUNT(*) DESC LIMIT 0, " . intval(getParam('bx_analytics_items_count'));
+        return $this->getAll($sQuery, $aBindings);
+    }
+
+    public function getMostFollowedProfiles($sModuleName, $iDateFrom, $iDateTo)
+    {
+        $aBindings = array(
+            'datefrom' => $iDateFrom,
+            'dateto' => $iDateTo
+        );
+        $sQuery = "SELECT `sys_profiles`.`content_id` AS `object_id`, `sys_profiles`.`type` , COUNT(`sys_profiles_conn_subscriptions`.`id`) AS `value` FROM `sys_profiles_conn_subscriptions`
+INNER JOIN `sys_profiles` ON `sys_profiles`.`id` = `sys_profiles_conn_subscriptions`.`content` WHERE sys_profiles.type='" . $sModuleName . "' AND `sys_profiles_conn_subscriptions`.`added` >= :datefrom AND `sys_profiles_conn_subscriptions`.`added` <= :dateto GROUP BY `sys_profiles`.`content_id`, `sys_profiles`.`type` ORDER BY COUNT(`sys_profiles_conn_subscriptions`.`id`) DESC LIMIT 0," . intval(getParam('bx_analytics_items_count'));
+        return $this->getAll($sQuery, $aBindings);
+    }
+
+    public function getMostActiveProfiles($sModuleName, $sContentModuleName, $sTableName, $sColumnAuthor, $sColumnAdded, $iDateFrom, $iDateTo)
+    {
+        $aBindings = array(
+            'datefrom' => $iDateFrom,
+            'dateto' => $iDateTo
+        );
+        $sQuery = "SELECT `sys_profiles`.`content_id` AS `object_id`, `sys_profiles`.`type`, '" . $sContentModuleName . "' , COUNT(`" . $sTableName . "`.`id`) AS `value` FROM `" . $sTableName . "`
+INNER JOIN `sys_profiles` ON `sys_profiles`.`id` = `" . $sTableName . "`.`" . $sColumnAuthor . "` WHERE sys_profiles.type='" . $sModuleName . "' AND `" . $sTableName . "`.`" . $sColumnAdded . "` >= :datefrom AND `" . $sTableName . "`.`" . $sColumnAdded . "` <= :dateto GROUP BY `sys_profiles`.`content_id`, `sys_profiles`.`type` ORDER BY COUNT(`" . $sTableName . "`.`id`) DESC LIMIT 0," . intval(getParam('bx_analytics_items_count'));
+        return $this->getAll($sQuery, $aBindings);
+    }
+}
+
+/** @} */

+ 352 - 0
modules/boonex/analytics/updates/15.0.1_15.0.2/source/classes/BxAnalyticsErrors.php

@@ -0,0 +1,352 @@
+<?php defined('BX_DOL') or die('hack attempt');
+/**
+ * Copyright (c) UNA, Inc - https://una.io
+ * MIT License - https://opensource.org/licenses/MIT
+ *
+ * @defgroup    Analytics Analytics
+ * @ingroup     UnaModules
+ *
+ * @{
+ */
+
+/**
+ * Client errors before they are stored: what is kept of a message and a stack (no query
+ * strings, no tokens, no addresses), the fingerprint that groups repeats of one error, and
+ * the route a page belongs to.
+ *
+ * Messages and stacks come from visitors' browsers and apps, so everything here treats them
+ * as untrusted text: control characters go, lengths are capped, and reports escape them.
+ */
+class BxAnalyticsErrors
+{
+    /**
+     * Kinds a client may send: uncaught JS errors, unhandled promise rejections, React render
+     * errors (error boundaries), native app errors (the RN global handler), failed requests
+     * (status 500 and up), and scripts or styles that failed to load.
+     */
+    const KINDS = ['js', 'promise', 'react', 'native', 'http', 'resource'];
+
+    /**
+     * The catch-all group for errors past the new groups limit.
+     */
+    const KIND_OVERFLOW = 'overflow';
+
+    const MAX_MESSAGE_INPUT = 500;
+    const MAX_MESSAGE = 255;
+    const MAX_SOURCE = 255;
+    const MAX_STACK_INPUT = 8000;
+    const MAX_STACK = 4000;
+    const MAX_FRAMES = 30;
+
+    const REDACTED = '<redacted>';
+
+    /**
+     * Text without control characters, secrets, query strings or the site's own host in
+     * URLs, cut to a length.
+     *
+     * @param string $s the text
+     * @param int $iMax characters kept
+     * @param array $aOwnHosts the site's hosts, removed from URLs (other hosts stay)
+     * @param bool $bLines keep line breaks (stacks)
+     */
+    public static function sanitize($s, $iMax, $aOwnHosts = [], $bLines = false)
+    {
+        $s = (string)$s;
+        if($s === '')
+            return '';
+
+        if(!mb_check_encoding($s, 'UTF-8'))
+            $s = mb_convert_encoding($s, 'UTF-8', 'UTF-8');
+
+        $s = str_replace("\r\n", "\n", $s);
+        $s = preg_replace($bLines ? '/[\x00-\x09\x0B-\x1F\x7F]+/u' : '/[\x00-\x1F\x7F]+/u', ' ', $s);
+
+        $aHosts = array_map('mb_strtolower', array_filter((array)$aOwnHosts));
+
+        // URLs: no query string or fragment, and no host when it is the site's own; a
+        // stack frame's :line:column after the URL stays
+        $s = preg_replace_callback('~\b(?:https?|wss?)://[^\s()<>"\'`]+~i', function($aMatch) use ($aHosts) {
+            $sUrl = $aMatch[0];
+            $sPosition = '';
+            if(preg_match('/^(.*?)((?::\d+){1,2})$/su', $sUrl, $aPosition)) {
+                $sUrl = $aPosition[1];
+                $sPosition = $aPosition[2];
+            }
+
+            $sUrl = preg_replace('/[?#].*$/s', '', $sUrl);
+
+            if(preg_match('~^[a-z]+://([^/:]+)(:\d+)?(/.*)?$~i', $sUrl, $aParts) && in_array(mb_strtolower($aParts[1]), $aHosts, true))
+                $sUrl = isset($aParts[3]) && $aParts[3] !== '' ? $aParts[3] : '/';
+
+            return $sUrl . $sPosition;
+        }, $s);
+
+        // a site's own relative URLs (an http error's "HTTP 502 /page.php?i=x&id=5"): only
+        // the page parameters stay of their query strings
+        $s = preg_replace_callback('~(?<![\w.:/-])/[^\s()<>"\'`?#]*\?[^\s()<>"\'`#:]*~u', function($aMatch) {
+            return self::getCleanPath($aMatch[0], false);
+        }, $s);
+
+        $s = self::redact($s);
+
+        if($bLines)
+            $s = preg_replace('/[ ]+\n/u', "\n", $s);
+
+        return trim(get_mb_substr(trim($s), 0, $iMax));
+    }
+
+    /**
+     * Tokens, passwords, emails and long random strings replaced.
+     */
+    public static function redact($s)
+    {
+        $aPatterns = [
+            // JSON web tokens
+            '/\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/' => self::REDACTED,
+            '/\b(Bearer|Basic)\s+[^\s"\'`,;]+/i' => '$1 ' . self::REDACTED,
+            // values of names that hold secrets: token=..., api_key=..., "password":"...",
+            // otp: ... (a serialized object or form in a message)
+            '/(["\']?)\b([A-Za-z_]*(?:token|key|secret|password|passwd|pass|pwd|auth|sid|session|otp))\1(\s*[:=]\s*)(?:"[^"]*"|\'[^\']*\'|[^&\s"\'`,;}]+)/i' => '$1$2$1$3' . self::REDACTED,
+            '/[A-Za-z0-9._%+-]+@[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)*\.[A-Za-z]{2,}/' => self::REDACTED,
+            // long hex (hashes, ids) and long base64-like runs with digits and both cases
+            '/(?<![A-Za-z0-9])[0-9a-fA-F]{24,}(?![A-Za-z0-9])/' => self::REDACTED,
+            '/(?<![A-Za-z0-9+_-])(?=[A-Za-z0-9+_-]*[0-9])(?=[A-Za-z0-9+_-]*[A-Z])(?=[A-Za-z0-9+_-]*[a-z])[A-Za-z0-9+_-]{24,}={0,2}/' => self::REDACTED,
+        ];
+
+        foreach($aPatterns as $sPattern => $sReplacement) {
+            $sResult = preg_replace($sPattern, $sReplacement, $s);
+            if($sResult !== null)
+                $s = $sResult;
+        }
+
+        return $s;
+    }
+
+    /**
+     * A stack trace (and a React component stack after it) sanitized, at most 30 frames.
+     */
+    public static function sanitizeStack($sStack, $sComponentStack = '', $aOwnHosts = [])
+    {
+        $sStack = self::_frames(self::sanitize(get_mb_substr((string)$sStack, 0, self::MAX_STACK_INPUT), self::MAX_STACK_INPUT, $aOwnHosts, true));
+
+        $sComponentStack = self::_frames(self::sanitize(get_mb_substr((string)$sComponentStack, 0, self::MAX_STACK_INPUT), self::MAX_STACK_INPUT, $aOwnHosts, true));
+        if($sComponentStack !== '')
+            $sStack .= ($sStack !== '' ? "\n--\n" : '') . $sComponentStack;
+
+        return get_mb_substr($sStack, 0, self::MAX_STACK);
+    }
+
+    /**
+     * Noise that is never stored: "Script error." without a stack (a cross-origin script
+     * the browser hides), and errors of browser extensions.
+     */
+    public static function isNoise($sMessage, $sStack, $sSource)
+    {
+        if($sStack === '' && preg_match('/^Script error\.?$/i', trim($sMessage)))
+            return true;
+
+        $aFrame = self::getTopFrame($sStack);
+        foreach([$sSource, $aFrame ? $aFrame['file'] : ''] as $sFile)
+            if(preg_match('~^[a-z-]*extension://~i', (string)$sFile))
+                return true;
+
+        return false;
+    }
+
+    /**
+     * Whether a message matches one of the ignore patterns (* for any characters, case
+     * doesn't matter).
+     */
+    public static function isIgnored($sMessage, $aPatterns)
+    {
+        foreach($aPatterns as $sPattern) {
+            $sPattern = trim((string)$sPattern);
+            if($sPattern === '')
+                continue;
+
+            if(preg_match('/^' . str_replace('\*', '.*', preg_quote($sPattern, '/')) . '$/isu', trim($sMessage)))
+                return true;
+        }
+
+        return false;
+    }
+
+    /**
+     * The message with what changes between repeats replaced: UUIDs, hex and numbers,
+     * quoted values. Line breaks and runs of spaces become one space.
+     */
+    public static function normalizeMessage($sMessage)
+    {
+        $s = (string)$sMessage;
+        $s = preg_replace('/\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/i', '<uuid>', $s);
+        $s = preg_replace('/\b0x[0-9a-f]+\b/i', '0x0', $s);
+        $s = preg_replace('/\b(?=[0-9a-f]*[0-9])(?=[0-9a-f]*[a-f])[0-9a-f]{8,}\b/i', '<hex>', $s);
+        // quotes that open a value, not an apostrophe inside a word
+        $s = preg_replace('/(?<![\p{L}\p{N}])(["\'`])[^"\'`\n]*\1/u', '""', $s);
+        $s = preg_replace('/\d+(\.\d+)?/', '0', $s);
+
+        return trim(preg_replace('/\s+/u', ' ', $s));
+    }
+
+    /**
+     * The top frame of a stack in the Chrome/V8/Hermes (`at fn (file:1:2)`) or the
+     * Firefox/Safari (`fn@file:1:2`) format.
+     *
+     * @return array|null with `function`, `file`, `line`, `col`
+     */
+    public static function getTopFrame($sStack)
+    {
+        foreach(explode("\n", (string)$sStack) as $sLine) {
+            $sLine = trim($sLine);
+            if($sLine === '' || $sLine === '--')
+                continue;
+
+            if(preg_match('/^at\s+(?:(.+?)\s+\()?(.+?)(?::(\d+))?(?::(\d+))?\)?$/', $sLine, $a) && (isset($a[3]) && $a[3] !== '' || bx_mb_strpos($a[2], '/') !== false))
+                return self::_frame(isset($a[1]) ? $a[1] : '', $a[2], isset($a[3]) ? $a[3] : 0, isset($a[4]) ? $a[4] : 0);
+
+            if(preg_match('/^([^@\s]*)@(.+?)(?::(\d+))?(?::(\d+))?$/', $sLine, $a))
+                return self::_frame($a[1], $a[2], isset($a[3]) ? $a[3] : 0, isset($a[4]) ? $a[4] : 0);
+        }
+
+        return null;
+    }
+
+    /**
+     * A script's path with what changes between deployments replaced by *: the content
+     * hashes of Next.js chunks (app/[...path]/page-*.js, 5123-*.js and *.js keep apart) and
+     * of UNA's combined files and other bundles.
+     */
+    public static function normalizeFile($sFile)
+    {
+        $s = preg_replace('/[?#].*$/s', '', trim((string)$sFile));
+        $s = preg_replace('~(?<=[/._-])(?=[0-9a-z]*[0-9])[0-9a-z]{8,}(?=\.m?js$)~i', '*', $s);
+        $s = preg_replace('/(?<=[._-])(?=[0-9a-z]*[0-9])[0-9a-z]{8,}(?=[._-])/i', '*', $s);
+        $s = str_replace(self::REDACTED, '*', $s);
+
+        return $s;
+    }
+
+    /**
+     * 16 bytes that are the same for every repeat of an error: kind, normalized message,
+     * the file and function of the top frame (the source file without a stack), and the
+     * HTTP status. Line and column are left out: they change with every build of a
+     * minified script.
+     *
+     * @param array $aError with `kind`, `message`, `stack`, `source`, `status`
+     */
+    public static function getFingerprint($aError)
+    {
+        $aFrame = self::getTopFrame(isset($aError['stack']) ? $aError['stack'] : '');
+
+        return substr(hash('sha256', implode('|', [
+            isset($aError['kind']) ? $aError['kind'] : '',
+            self::normalizeMessage(isset($aError['message']) ? $aError['message'] : ''),
+            self::normalizeFile($aFrame ? $aFrame['file'] : (isset($aError['source']) ? $aError['source'] : '')),
+            $aFrame ? $aFrame['function'] : '',
+            isset($aError['status']) ? (int)$aError['status'] : 0,
+        ]), true), 0, 16);
+    }
+
+    /**
+     * The group past the new groups limit takes.
+     */
+    public static function getOverflowError()
+    {
+        return ['kind' => self::KIND_OVERFLOW, 'message' => '', 'source' => '', 'line' => 0, 'col' => 0, 'status' => 0, 'stack' => '', 'fatal' => 0];
+    }
+
+    /**
+     * A page's path as error and vitals hits keep it: of the query string only the page
+     * parameters `i` and `r` stay (the trackers send no more, this strips a client that
+     * does), and tokens and emails are redacted. Report filters on the path of these hits
+     * pass a pageview's path through it too.
+     *
+     * @param string $sPath a path with its query string, decoded
+     * @param bool $bRedact also redact secrets
+     */
+    public static function getCleanPath($sPath, $bRedact = true)
+    {
+        $aParts = explode('?', preg_replace('/#.*$/s', '', (string)$sPath), 2);
+
+        $aKeep = [];
+        if(isset($aParts[1]))
+            foreach(explode('&', $aParts[1]) as $sPair) {
+                $aPair = explode('=', $sPair, 2);
+                if(in_array($aPair[0], ['i', 'r'], true) && isset($aPair[1]) && $aPair[1] !== '' && !isset($aKeep[$aPair[0]]))
+                    $aKeep[$aPair[0]] = $aPair[0] . '=' . $aPair[1];
+            }
+        ksort($aKeep);
+
+        $sPath = $aParts[0] . (!empty($aKeep) ? '?' . implode('&', $aKeep) : '');
+
+        return $bRedact ? self::redact($sPath) : $sPath;
+    }
+
+    /**
+     * The page a path belongs to, so one route collects the errors and timings of all its
+     * items: the query string keeps only `i` (classic pages: page.php?i=view-post), and
+     * ids, UUIDs, hashes and long tokens in the path become *.
+     */
+    public static function getRoute($sPath)
+    {
+        $aParts = explode('?', (string)$sPath, 2);
+
+        $aSegments = [];
+        foreach(explode('/', $aParts[0]) as $sSegment)
+            $aSegments[] = self::_isVariableSegment($sSegment) ? '*' : $sSegment;
+        $sRoute = implode('/', $aSegments);
+        if($sRoute === '')
+            $sRoute = '/';
+
+        if(isset($aParts[1]))
+            foreach(explode('&', $aParts[1]) as $sPair) {
+                $aPair = explode('=', $sPair, 2);
+                if($aPair[0] === 'i' && isset($aPair[1]) && $aPair[1] !== '') {
+                    $sRoute .= '?i=' . $aPair[1];
+                    break;
+                }
+            }
+
+        return get_mb_substr($sRoute, 0, 128);
+    }
+
+    protected static function _isVariableSegment($sSegment)
+    {
+        if($sSegment === '')
+            return false;
+
+        return preg_match('/^\d+$/', $sSegment)
+            || preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i', $sSegment)
+            || preg_match('/^(?=[0-9a-f]*[0-9])[0-9a-f]{12,}$/i', $sSegment)
+            || (get_mb_len($sSegment) >= 20 && preg_match('/^(?=.*[0-9])[A-Za-z0-9_-]+$/', $sSegment) && preg_match('/[A-Z]/', $sSegment))
+            || $sSegment === self::REDACTED;
+    }
+
+    protected static function _frame($sFunction, $sFile, $iLine, $iCol)
+    {
+        $sFile = trim(preg_replace('/^address at\s+/i', '', trim($sFile)));
+        $sFunction = trim(preg_replace('/^(async|new)\s+/', '', trim($sFunction)));
+
+        return ['function' => $sFunction, 'file' => $sFile, 'line' => (int)$iLine, 'col' => (int)$iCol];
+    }
+
+    protected static function _frames($sStack)
+    {
+        if($sStack === '')
+            return '';
+
+        $aLines = [];
+        foreach(explode("\n", $sStack) as $sLine) {
+            $sLine = rtrim($sLine);
+            if(trim($sLine) === '')
+                continue;
+            $aLines[] = $sLine;
+            if(count($aLines) >= self::MAX_FRAMES)
+                break;
+        }
+
+        return implode("\n", $aLines);
+    }
+}
+
+/** @} */

+ 297 - 0
modules/boonex/analytics/updates/15.0.1_15.0.2/source/classes/BxAnalyticsFilter.php

@@ -0,0 +1,297 @@
+<?php defined('BX_DOL') or die('hack attempt');
+/**
+ * Copyright (c) UNA, Inc - https://una.io
+ * MIT License - https://opensource.org/licenses/MIT
+ *
+ * @defgroup    Analytics Analytics
+ * @ingroup     UnaModules
+ *
+ * @{
+ */
+
+/**
+ * The period, timezone, platform and dimension filters of a report, read from request
+ * parameters. Every report block on a page reads the same parameters, so one filter bar
+ * drives them all, in the classic UI, in Studio and in NEO.
+ *
+ * Parameters: range (today, yesterday, 24h, 7d, 30d, 90d, 12m, custom), from and to
+ * (YYYY-MM-DD, for custom), tz (IANA timezone), platform, f (dimension => value).
+ */
+class BxAnalyticsFilter
+{
+    const RANGE_DEFAULT = '7d';
+
+    /**
+     * Longest custom period, in days.
+     */
+    const RANGE_MAX_DAYS = 731;
+
+    /**
+     * The viewer's IANA timezone, kept by the dashboard (js/dashboard.js) for requests
+     * without `tz`.
+     */
+    const TZ_COOKIE = 'bx_analytics_tz';
+
+    public static $aRanges = ['today', 'yesterday', '24h', '7d', '30d', '90d', '12m', 'custom'];
+
+    protected $_oDb;
+    protected $_oConfig;
+    protected $_iNow;
+
+    protected $_aFilter;
+
+    /**
+     * Start of the previous period, a calendar step back in the filter's timezone.
+     */
+    protected $_iPreviousFrom;
+
+    /**
+     * @param object $oModule the Analytics module
+     * @param array $aInput request parameters
+     * @param int $iNow current time, for tests
+     */
+    public function __construct($oModule, $aInput, $iNow = null)
+    {
+        $this->_oDb = $oModule->_oDb;
+        $this->_oConfig = $oModule->_oConfig;
+        $this->_iNow = $iNow ? (int)$iNow : time();
+
+        $this->_aFilter = $this->_parse(is_array($aInput) ? $aInput : []);
+    }
+
+    /**
+     * The filter for Db report methods: from, to (exclusive), platform, dims, and for
+     * series: bucket, offset, starts (exact bucket starts when fixed lengths won't do) and
+     * buckets (every bucket start, to fill gaps with zeros).
+     */
+    public function get()
+    {
+        return $this->_aFilter;
+    }
+
+    /**
+     * The same filter for the period just before, for comparisons: as many local days (or
+     * calendar months, or hours for 24h) as this one, so a daylight saving change or a leap
+     * year doesn't shift it by an hour or a day.
+     */
+    public function getPrevious()
+    {
+        $aFilter = $this->_aFilter;
+        $aFilter['to'] = $aFilter['from'];
+        $aFilter['from'] = $this->_iPreviousFrom;
+
+        return $aFilter;
+    }
+
+    /**
+     * Parameters that reproduce this filter, for links, exports and NEO requests.
+     */
+    public function getParams()
+    {
+        $a = [
+            'range' => $this->_aFilter['range'],
+            'tz' => $this->_aFilter['tz'],
+        ];
+        if($this->_aFilter['range'] == 'custom') {
+            $a['from'] = $this->_aFilter['from_date'];
+            $a['to'] = $this->_aFilter['to_date'];
+        }
+        if($this->_aFilter['platform'] !== '')
+            $a['platform'] = $this->_aFilter['platform'];
+        if(!empty($this->_aFilter['dims']))
+            $a['f'] = $this->_aFilter['dims'];
+
+        return $a;
+    }
+
+    protected function _parse($aInput)
+    {
+        $sRange = isset($aInput['range']) && in_array($aInput['range'], self::$aRanges, true) ? $aInput['range'] : self::RANGE_DEFAULT;
+
+        // with the backward-compatible names (Asia/Calcutta, Europe/Kiev), which Intl still
+        // reports on some runtimes; without one in the request, the viewer's own from the
+        // dashboard's cookie, so a page opened from a menu isn't drawn in UTC and then again
+        $aZones = timezone_identifiers_list(DateTimeZone::ALL_WITH_BC);
+        $sTz = 'UTC';
+        if(isset($aInput['tz']) && is_string($aInput['tz']) && in_array($aInput['tz'], $aZones, true))
+            $sTz = $aInput['tz'];
+        elseif(!isset($aInput['tz']) && isset($_COOKIE[self::TZ_COOKIE]) && is_string($_COOKIE[self::TZ_COOKIE]) && in_array($_COOKIE[self::TZ_COOKIE], $aZones, true))
+            $sTz = $_COOKIE[self::TZ_COOKIE];
+        $oTz = new DateTimeZone($sTz);
+
+        $oNow = (new DateTime('@' . $this->_iNow))->setTimezone($oTz);
+        $oToday = (clone $oNow)->setTime(0, 0);
+        $oTomorrow = (clone $oToday)->modify('+1 day');
+
+        $sBucket = 'day';
+        switch($sRange) {
+            case 'today':
+                $oFrom = $oToday;
+                $oTo = $oTomorrow;
+                $sBucket = 'hour';
+                break;
+
+            case 'yesterday':
+                $oFrom = (clone $oToday)->modify('-1 day');
+                $oTo = $oToday;
+                $sBucket = 'hour';
+                break;
+
+            case '24h':
+                $oTo = (clone $oNow)->setTime((int)$oNow->format('G'), 0)->modify('+1 hour');
+                $oFrom = (clone $oTo)->modify('-24 hours');
+                $sBucket = 'hour';
+                break;
+
+            case '30d':
+                $oFrom = (clone $oToday)->modify('-29 days');
+                $oTo = $oTomorrow;
+                break;
+
+            case '90d':
+                $oFrom = (clone $oToday)->modify('-89 days');
+                $oTo = $oTomorrow;
+                $sBucket = 'week';
+                break;
+
+            case '12m':
+                $oFrom = (clone $oToday)->modify('first day of this month')->modify('-11 months');
+                $oTo = (clone $oToday)->modify('first day of next month');
+                $sBucket = 'month';
+                break;
+
+            case 'custom':
+                $oFrom = $this->_date($aInput, 'from', $oTz);
+                $oTo = $this->_date($aInput, 'to', $oTz);
+                if($oFrom && $oTo && $oFrom <= $oTo) {
+                    $oTo = (clone $oTo)->modify('+1 day');
+                    $iDays = (int)round(($oTo->getTimestamp() - $oFrom->getTimestamp()) / 86400);
+                    if($iDays > self::RANGE_MAX_DAYS)
+                        $oFrom = (clone $oTo)->modify('-' . self::RANGE_MAX_DAYS . ' days');
+
+                    $sBucket = $iDays <= 2 ? 'hour' : ($iDays <= 62 ? 'day' : ($iDays <= 180 ? 'week' : 'month'));
+                    break;
+                }
+                $sRange = self::RANGE_DEFAULT;
+                // invalid dates: the default period
+
+            case '7d':
+            default:
+                $oFrom = (clone $oToday)->modify('-6 days');
+                $oTo = $oTomorrow;
+                break;
+        }
+
+        $aBack = ['today' => '-1 day', 'yesterday' => '-1 day', '24h' => '-24 hours', '7d' => '-7 days', '30d' => '-30 days', '90d' => '-90 days', '12m' => '-12 months'];
+        $sBack = isset($aBack[$sRange]) ? $aBack[$sRange] : '-' . (int)round(($oTo->getTimestamp() - $oFrom->getTimestamp()) / 86400) . ' days';
+        $this->_iPreviousFrom = (clone $oFrom)->modify($sBack)->getTimestamp();
+
+        // the bucket may be overridden to anything that keeps the chart readable
+        if(isset($aInput['bucket']) && in_array($aInput['bucket'], ['hour', 'day', 'week', 'month'], true)) {
+            $iHours = ($oTo->getTimestamp() - $oFrom->getTimestamp()) / 3600;
+            $aMaxHours = ['hour' => 24 * 7, 'day' => 24 * 190, 'week' => 24 * 740, 'month' => PHP_INT_MAX];
+            if($iHours <= $aMaxHours[$aInput['bucket']])
+                $sBucket = $aInput['bucket'];
+        }
+
+        $sPlatform = isset($aInput['platform']) && $this->_oConfig->isPlatform($aInput['platform']) ? $aInput['platform'] : '';
+
+        $aDims = [];
+        if(isset($aInput['f']) && is_array($aInput['f']))
+            foreach($aInput['f'] as $sName => $sValue)
+                if(is_string($sName) && is_string($sValue) && $this->_oDb->isDimension($sName) && $sName != 'platform')
+                    $aDims[$sName] = get_mb_substr($sValue, 0, 255);
+
+        $iFrom = $oFrom->getTimestamp();
+        $iTo = $oTo->getTimestamp();
+        $iOffset = $oFrom->getOffset();
+
+        // fixed-length buckets drift by an hour across a daylight saving change inside the period
+        // (two changes leave the ends at the same offset); then the exact local starts go to the
+        // database as a list, as months always do. getTransitions() gives the state at the
+        // start, then one entry per change.
+        $aStarts = [];
+        if($sBucket == 'month')
+            $aStarts = $this->_months($oFrom, $oTo);
+        elseif(in_array($sBucket, ['day', 'week'], true) && count($oTz->getTransitions($iFrom, $iTo - 1)) > 1)
+            $aStarts = $this->_localStarts($oFrom, $oTo, $sBucket);
+
+        return [
+            'range' => $sRange,
+            'tz' => $sTz,
+            'from' => $iFrom,
+            'to' => $iTo,
+            'from_date' => $oFrom->format('Y-m-d'),
+            'to_date' => (clone $oTo)->modify('-1 second')->format('Y-m-d'),
+            'now' => $this->_iNow,
+            'platform' => $sPlatform,
+            'dims' => $aDims,
+            'bucket' => $sBucket,
+            'offset' => $iOffset,
+            'starts' => $aStarts,
+            'buckets' => !empty($aStarts) ? $aStarts : $this->_buckets($iFrom, $iTo, $sBucket, $iOffset),
+        ];
+    }
+
+    /**
+     * Every bucket start in the period, computed the way BxAnalyticsDb buckets rows.
+     */
+    protected function _buckets($iFrom, $iTo, $sBucket, $iOffset)
+    {
+        $aSizes = ['hour' => 3600, 'day' => 86400, 'week' => 604800];
+        $iSize = $aSizes[$sBucket];
+        $iShift = $sBucket == 'week' ? 345600 : 0;
+
+        $iStart = (int)(floor(($iFrom + $iOffset - $iShift) / $iSize) * $iSize + $iShift - $iOffset);
+
+        $aResult = [];
+        for($i = $iStart; $i < $iTo && count($aResult) < 2000; $i += $iSize)
+            $aResult[] = $i;
+
+        return $aResult;
+    }
+
+    protected function _months($oFrom, $oTo)
+    {
+        $aResult = [];
+        $oMonth = (clone $oFrom)->modify('first day of this month')->setTime(0, 0);
+        while($oMonth < $oTo && count($aResult) < 100) {
+            $aResult[] = $oMonth->getTimestamp();
+            $oMonth->modify('first day of next month');
+        }
+
+        return $aResult;
+    }
+
+    /**
+     * Local midnights of every day, or of every Monday, in the period.
+     */
+    protected function _localStarts($oFrom, $oTo, $sBucket)
+    {
+        $oStart = (clone $oFrom)->setTime(0, 0);
+        if($sBucket == 'week')
+            $oStart->modify('-' . ((int)$oStart->format('N') - 1) . ' days');
+
+        $sStep = $sBucket == 'week' ? '+1 week' : '+1 day';
+
+        $aResult = [];
+        while($oStart < $oTo && count($aResult) < 800) {
+            $aResult[] = $oStart->getTimestamp();
+            $oStart->modify($sStep)->setTime(0, 0);
+        }
+
+        return $aResult;
+    }
+
+    protected function _date($aInput, $sKey, $oTz)
+    {
+        if(!isset($aInput[$sKey]) || !is_string($aInput[$sKey]) || !preg_match('/^\d{4}-\d{2}-\d{2}$/', $aInput[$sKey]))
+            return null;
+
+        $oDate = DateTime::createFromFormat('!Y-m-d', $aInput[$sKey], $oTz);
+
+        return $oDate ? $oDate : null;
+    }
+}
+
+/** @} */

+ 82 - 0
modules/boonex/analytics/updates/15.0.1_15.0.2/source/classes/BxAnalyticsGeo.php

@@ -0,0 +1,82 @@
+<?php defined('BX_DOL') or die('hack attempt');
+/**
+ * Copyright (c) UNA, Inc - https://una.io
+ * MIT License - https://opensource.org/licenses/MIT
+ *
+ * @defgroup    Analytics Analytics
+ * @ingroup     UnaModules
+ *
+ * @{
+ */
+
+/**
+ * Approximate centre of each country (ISO 3166-1 alpha-2), where the map puts a country's
+ * visitors when a hit has no coordinates of its own.
+ */
+class BxAnalyticsGeo
+{
+    protected static $_aCenters = [
+        'AD' => [42.5, 1.6], 'AE' => [23.4, 53.8], 'AF' => [33.9, 67.7], 'AG' => [17.1, -61.8], 'AI' => [18.2, -63.1],
+        'AL' => [41.2, 20.2], 'AM' => [40.1, 45.0], 'AO' => [-11.2, 17.9], 'AQ' => [-75.3, -0.1], 'AR' => [-38.4, -63.6],
+        'AS' => [-14.3, -170.1], 'AT' => [47.5, 14.6], 'AU' => [-25.3, 133.8], 'AW' => [12.5, -70.0], 'AX' => [60.2, 20.0],
+        'AZ' => [40.1, 47.6], 'BA' => [43.9, 17.7], 'BB' => [13.2, -59.5], 'BD' => [23.7, 90.4], 'BE' => [50.5, 4.5],
+        'BF' => [12.2, -1.6], 'BG' => [42.7, 25.5], 'BH' => [25.9, 50.6], 'BI' => [-3.4, 29.9], 'BJ' => [9.3, 2.3],
+        'BL' => [17.9, -62.8], 'BM' => [32.3, -64.8], 'BN' => [4.5, 114.7], 'BO' => [-16.3, -63.6], 'BQ' => [12.2, -68.3],
+        'BR' => [-14.2, -51.9], 'BS' => [25.0, -77.4], 'BT' => [27.5, 90.4], 'BW' => [-22.3, 24.7], 'BY' => [53.7, 28.0],
+        'BZ' => [17.2, -88.5], 'CA' => [56.1, -106.3], 'CC' => [-12.2, 96.9], 'CD' => [-4.0, 21.8], 'CF' => [6.6, 20.9],
+        'CG' => [-0.2, 15.8], 'CH' => [46.8, 8.2], 'CI' => [7.5, -5.5], 'CK' => [-21.2, -159.8], 'CL' => [-35.7, -71.5],
+        'CM' => [7.4, 12.4], 'CN' => [35.9, 104.2], 'CO' => [4.6, -74.3], 'CR' => [9.7, -83.8], 'CU' => [21.5, -77.8],
+        'CV' => [16.0, -24.0], 'CW' => [12.2, -69.0], 'CX' => [-10.4, 105.7], 'CY' => [35.1, 33.4], 'CZ' => [49.8, 15.5],
+        'DE' => [51.2, 10.5], 'DJ' => [11.8, 42.6], 'DK' => [56.3, 9.5], 'DM' => [15.4, -61.4], 'DO' => [18.7, -70.2],
+        'DZ' => [28.0, 1.7], 'EC' => [-1.8, -78.2], 'EE' => [58.6, 25.0], 'EG' => [26.8, 30.8], 'EH' => [24.2, -12.9],
+        'ER' => [15.2, 39.8], 'ES' => [40.5, -3.7], 'ET' => [9.1, 40.5], 'FI' => [61.9, 25.7], 'FJ' => [-16.6, 179.4],
+        'FK' => [-51.8, -59.5], 'FM' => [7.4, 150.6], 'FO' => [61.9, -6.9], 'FR' => [46.2, 2.2], 'GA' => [-0.8, 11.6],
+        'GB' => [55.4, -3.4], 'GD' => [12.3, -61.6], 'GE' => [42.3, 43.4], 'GF' => [3.9, -53.1], 'GG' => [49.5, -2.6],
+        'GH' => [7.9, -1.0], 'GI' => [36.1, -5.3], 'GL' => [71.7, -42.6], 'GM' => [13.4, -15.3], 'GN' => [9.9, -9.7],
+        'GP' => [16.3, -61.6], 'GQ' => [1.7, 10.3], 'GR' => [39.1, 21.8], 'GS' => [-54.4, -36.6], 'GT' => [15.8, -90.2],
+        'GU' => [13.4, 144.8], 'GW' => [11.8, -15.2], 'GY' => [4.9, -58.9], 'HK' => [22.4, 114.1], 'HN' => [15.2, -86.2],
+        'HR' => [45.1, 15.2], 'HT' => [19.0, -72.3], 'HU' => [47.2, 19.5], 'ID' => [-0.8, 113.9], 'IE' => [53.4, -8.2],
+        'IL' => [31.0, 34.9], 'IM' => [54.2, -4.5], 'IN' => [20.6, 79.0], 'IO' => [-6.3, 71.9], 'IQ' => [33.2, 43.7],
+        'IR' => [32.4, 53.7], 'IS' => [65.0, -19.0], 'IT' => [41.9, 12.6], 'JE' => [49.2, -2.1], 'JM' => [18.1, -77.3],
+        'JO' => [30.6, 36.2], 'JP' => [36.2, 138.3], 'KE' => [-0.0, 37.9], 'KG' => [41.2, 74.8], 'KH' => [12.6, 105.0],
+        'KI' => [-3.4, -168.7], 'KM' => [-11.9, 43.9], 'KN' => [17.4, -62.8], 'KP' => [40.3, 127.5], 'KR' => [35.9, 127.8],
+        'KW' => [29.3, 47.5], 'KY' => [19.5, -80.6], 'KZ' => [48.0, 66.9], 'LA' => [19.9, 102.5], 'LB' => [33.9, 35.9],
+        'LC' => [13.9, -61.0], 'LI' => [47.2, 9.6], 'LK' => [7.9, 80.8], 'LR' => [6.4, -9.4], 'LS' => [-29.6, 28.2],
+        'LT' => [55.2, 23.9], 'LU' => [49.8, 6.1], 'LV' => [56.9, 24.6], 'LY' => [26.3, 17.2], 'MA' => [31.8, -7.1],
+        'MC' => [43.7, 7.4], 'MD' => [47.4, 28.4], 'ME' => [42.7, 19.4], 'MF' => [18.1, -63.1], 'MG' => [-18.8, 46.9],
+        'MH' => [7.1, 171.2], 'MK' => [41.6, 21.7], 'ML' => [17.6, -4.0], 'MM' => [21.9, 96.0], 'MN' => [46.9, 103.8],
+        'MO' => [22.2, 113.5], 'MP' => [17.3, 145.4], 'MQ' => [14.6, -61.0], 'MR' => [21.0, -10.9], 'MS' => [16.7, -62.2],
+        'MT' => [35.9, 14.4], 'MU' => [-20.3, 57.6], 'MV' => [3.2, 73.2], 'MW' => [-13.3, 34.3], 'MX' => [23.6, -102.6],
+        'MY' => [4.2, 102.0], 'MZ' => [-18.7, 35.5], 'NA' => [-22.96, 18.5], 'NC' => [-20.9, 165.6], 'NE' => [17.6, 8.1],
+        'NF' => [-29.0, 167.9], 'NG' => [9.1, 8.7], 'NI' => [12.9, -85.2], 'NL' => [52.1, 5.3], 'NO' => [60.5, 8.5],
+        'NP' => [28.4, 84.1], 'NR' => [-0.5, 166.9], 'NU' => [-19.1, -169.9], 'NZ' => [-40.9, 174.9], 'OM' => [21.5, 55.9],
+        'PA' => [8.5, -80.8], 'PE' => [-9.2, -75.0], 'PF' => [-17.7, -149.4], 'PG' => [-6.3, 143.96], 'PH' => [12.9, 121.8],
+        'PK' => [30.4, 69.3], 'PL' => [51.9, 19.1], 'PM' => [46.9, -56.3], 'PN' => [-24.7, -127.4], 'PR' => [18.2, -66.6],
+        'PS' => [31.95, 35.2], 'PT' => [39.4, -8.2], 'PW' => [7.5, 134.6], 'PY' => [-23.4, -58.4], 'QA' => [25.4, 51.2],
+        'RE' => [-21.1, 55.5], 'RO' => [45.9, 25.0], 'RS' => [44.0, 21.0], 'RU' => [61.5, 105.3], 'RW' => [-1.9, 29.9],
+        'SA' => [23.9, 45.1], 'SB' => [-9.6, 160.2], 'SC' => [-4.7, 55.5], 'SD' => [12.9, 30.2], 'SE' => [60.1, 18.6],
+        'SG' => [1.35, 103.8], 'SH' => [-24.1, -10.0], 'SI' => [46.2, 15.0], 'SJ' => [77.6, 23.7], 'SK' => [48.7, 19.7],
+        'SL' => [8.5, -11.8], 'SM' => [43.9, 12.5], 'SN' => [14.5, -14.5], 'SO' => [5.2, 46.2], 'SR' => [3.9, -56.0],
+        'SS' => [6.9, 31.3], 'ST' => [0.2, 6.6], 'SV' => [13.8, -88.9], 'SX' => [18.0, -63.1], 'SY' => [34.8, 39.0],
+        'SZ' => [-26.5, 31.5], 'TC' => [21.7, -71.8], 'TD' => [15.5, 18.7], 'TF' => [-49.3, 69.3], 'TG' => [8.6, 0.8],
+        'TH' => [15.9, 101.0], 'TJ' => [38.9, 71.3], 'TK' => [-8.97, -171.9], 'TL' => [-8.9, 125.7], 'TM' => [38.97, 59.6],
+        'TN' => [33.9, 9.5], 'TO' => [-21.2, -175.2], 'TR' => [39.0, 35.2], 'TT' => [10.7, -61.2], 'TV' => [-7.1, 177.6],
+        'TW' => [23.7, 121.0], 'TZ' => [-6.4, 34.9], 'UA' => [48.4, 31.2], 'UG' => [1.4, 32.3], 'UM' => [19.3, 166.6],
+        'US' => [37.1, -95.7], 'UY' => [-32.5, -55.8], 'UZ' => [41.4, 64.6], 'VA' => [41.9, 12.5], 'VC' => [12.98, -61.3],
+        'VE' => [6.4, -66.6], 'VG' => [18.4, -64.6], 'VI' => [18.3, -64.9], 'VN' => [14.1, 108.3], 'VU' => [-15.4, 166.96],
+        'WF' => [-13.8, -177.2], 'WS' => [-13.8, -172.1], 'XK' => [42.6, 20.9], 'YE' => [15.6, 48.5], 'YT' => [-12.8, 45.2],
+        'ZA' => [-30.6, 22.9], 'ZM' => [-13.1, 27.8], 'ZW' => [-19.0, 29.2],
+    ];
+
+    /**
+     * [lat, lng] of a country, or null.
+     */
+    public static function getCenter($sCountry)
+    {
+        $sCountry = mb_strtoupper((string)$sCountry);
+
+        return isset(self::$_aCenters[$sCountry]) ? self::$_aCenters[$sCountry] : null;
+    }
+}
+
+/** @} */

+ 2434 - 0
modules/boonex/analytics/updates/15.0.1_15.0.2/source/classes/BxAnalyticsModule.php

@@ -0,0 +1,2434 @@
+<?php defined('BX_DOL') or die('hack attempt');
+/**
+* Copyright (c) UNA, Inc - https://una.io
+* MIT License - https://opensource.org/licenses/MIT
+*
+* @defgroup    Analytics Analytics
+* @ingroup     UnaModules
+*
+* @{
+*/
+
+/**
+ * Analytics module
+ */     
+define('BX_ANALYTICS_SYSTEM', 'system');
+define('BX_ANALYTICS_TOP_BY_LIKES', 'top_by_likes');
+define('BX_ANALYTICS_TOP_BY_VIEWS', 'top_by_views');
+define('BX_ANALYTICS_CONTENT_TOTAL', 'content_total');
+define('BX_ANALYTICS_CONTENT_SPEED', 'content_speed');
+define('BX_ANALYTICS_MOST_FOLLOWED_PROFILES', 'top_by_followers');
+define('BX_ANALYTICS_MOST_ACTIVE_PROFILES', 'top_by_activity');
+class BxAnalyticsModule extends BxDolModule
+{
+    protected $aColors = array('#3366CC', '#DC3912', '#FF9900', '#109618', '#990099');
+
+    protected $_oReports = null;
+
+    /**
+     * Reports that blocks, Studio and the API may ask for; each is a BxAnalyticsReports method.
+     */
+    protected $_aReports = ['filters', 'overview', 'top', 'table', 'realtime', 'map', 'bots', 'sessions', 'users', 'events', 'journeys', 'retention', 'goals', 'funnels', 'revenue', 'subscriptions', 'sales', 'credits', 'errors', 'performance', 'query'];
+
+    /**
+     * Reports that show members by name.
+     */
+    protected $_aPeopleReports = ['sessions', 'users'];
+
+    /**
+     * Reports that show money and buyers.
+     */
+    protected $_aRevenueReports = ['revenue', 'subscriptions', 'sales', 'credits'];
+
+    /**
+     * Reports with a CSV download. None of them names members: one that does must stay
+     * behind the same checks as getReportData (checkAllowedPeople).
+     */
+    protected $_aExportReports = ['query', 'table', 'top'];
+
+    /**
+     * Payment alerts (unit `system`) that keep the revenue ledger.
+     */
+    protected $_aPaymentAlerts = ['register_payment', 'refund_payment', 'create_subscription', 'prolong_subscription', 'overdue_subscription', 'cancel_subscription', 'cancel_order', 'finalize_checkout'];
+
+    /**
+     * Studio sections: page => list of [report, block params, spans all columns].
+     */
+    protected $_aStudioSections = [
+        'overview' => [['filters', [], true], ['overview', [], true], ['top', ['group' => 'pages']], ['top', ['group' => 'sources']], ['top', ['group' => 'locations']], ['top', ['group' => 'devices']], ['top', ['group' => 'platforms']]],
+        'realtime' => [['realtime', [], true]],
+        'pages' => [['filters', [], true], ['table', ['table' => 'pages'], true]],
+        'sources' => [['filters', [], true], ['table', ['table' => 'sources'], true]],
+        'audience' => [['filters', [], true], ['top', ['group' => 'locations']], ['top', ['group' => 'devices']], ['top', ['group' => 'platforms']]],
+        'map' => [['filters', [], true], ['map', [], true]],
+        'bots' => [['filters', [], true], ['bots', [], true]],
+        'sessions' => [['filters', [], true], ['sessions', [], true]],
+        'users' => [['filters', [], true], ['users', [], true]],
+        'events' => [['filters', [], true], ['events', [], true]],
+        'journeys' => [['filters', [], true], ['journeys', [], true]],
+        'retention' => [['filters', [], true], ['retention', [], true]],
+        'goals' => [['filters', [], true], ['goals', [], true]],
+        'funnels' => [['filters', [], true], ['funnels', [], true]],
+        'revenue' => [['filters', [], true], ['revenue', [], true]],
+        'subscriptions' => [['filters', [], true], ['subscriptions', [], true]],
+        'sales' => [['filters', [], true], ['sales', [], true]],
+        'credits' => [['filters', [], true], ['credits', [], true]],
+        'errors' => [['filters', [], true], ['errors', [], true]],
+        'performance' => [['filters', [], true], ['performance', [], true]],
+        'query' => [['filters', [], true], ['query', [], true]],
+    ];
+
+    /**
+     * Studio sections that are admin tools, not reports: the API playground, the pages built
+     * from analytics blocks, and the tracker setup. They show page objects, addresses and
+     * settings, so they stay out of $_aReports, which get_report (any API client) can ask for.
+     */
+    protected $_aStudioTools = ['api', 'dashboards', 'setup'];
+
+    /**
+     * The page whose blocks a new analytics page starts with, and the cells copied: the
+     * center and the right bar of layout 12, the cells NEO's dashboard tabs show.
+     */
+    protected $_sDashboardTemplate = 'bx_analytics_page';
+    protected $_aDashboardCells = [2, 3];
+    protected $_iDashboardLayout = 12;
+
+    /**
+     * The event the Setup section's test sends.
+     */
+    const SETUP_TEST_EVENT = 'analytics_setup_test';
+
+    /**
+     * The start of a cached report: the cache directory holds .php files, and one requested
+     * over HTTP shows nothing past it.
+     */
+    const REPORT_CACHE_GUARD = "<?php exit; ?>\n";
+
+    /**
+     * Cache name of the reports' generation (not under REPORT_CACHE_PREFIX, so pruning the
+     * expired reports keeps it) and how long it is kept: long past any report.
+     */
+    const REPORT_CACHE_GENERATION = 'bx_analytics_reports_generation';
+    const REPORT_CACHE_GENERATION_TTL = 2592000;
+
+    protected $_sReportCacheGeneration = null;
+          
+    function __construct(&$aModule)
+    {
+        parent::__construct($aModule);
+    }
+          
+    /**
+    * Service methods
+    */
+          
+    /**
+    * @page service Service Calls
+    * @section bx_analytics Analytics
+    * @subsection bx_analytics-other Other
+    * @subsubsection bx_analytics get_canvas
+    * 
+    * @code bx_srv('bx_analytics', 'get_canvas', [...]); @endcode
+    * 
+    * Get canvas  
+    * 
+    * @return an html. 
+    * 
+    * @see BxAnalyticsModule::serviceGetCanvas
+    */
+    /** 
+    * @ref bx_analytics-get_canvas "get_canvas"
+    */
+    public function serviceGetCanvas()
+    {
+        if(bx_is_api() || $this->checkAllowed() !== CHECK_ACTION_RESULT_ALLOWED)
+            return bx_is_api() ? [] : '';
+
+        return $this->_oTemplate->getCanvas();
+    }
+	
+	/**
+    * @page service Service Calls
+    * @section bx_analytics Analytics
+    * @subsection bx_analytics-other Other
+    * @subsubsection bx_analytics is_avaliable
+    * 
+    * @code bx_srv('bx_analytics', 'is_avaliable', [...]); @endcode
+    * 
+    * Check avalability for current profile  
+    * 
+    * @return boolean. 
+    * 
+    * @see BxAnalyticsModule::serviceIsAvaliable
+    */
+    /** 
+    * @ref bx_analytics-is_avaliable "is_avaliable"
+    */
+	public function serviceIsAvaliable()
+    {
+		return $this->checkAllowed() === CHECK_ACTION_RESULT_ALLOWED ? true : false;
+    }
+
+    /**
+     * @page service Service Calls
+     * @section bx_analytics Analytics
+     * @subsection bx_analytics-other Other
+     * @subsubsection bx_analytics-is_revenue_avaliable is_revenue_avaliable
+     *
+     * @code bx_srv('bx_analytics', 'is_revenue_avaliable'); @endcode
+     *
+     * Whether the current profile may see the revenue reports ("view analytics revenue",
+     * administrators by default). Shows the Revenue item of the account dashboard menu.
+     *
+     * @return boolean
+     *
+     * @see BxAnalyticsModule::serviceIsRevenueAvaliable
+     */
+    /**
+     * @ref bx_analytics-is_revenue_avaliable "is_revenue_avaliable"
+     */
+    public function serviceIsRevenueAvaliable()
+    {
+        return $this->checkAllowedRevenue() === CHECK_ACTION_RESULT_ALLOWED;
+    }
+
+          
+    /**
+    * @page service Service Calls
+    * @section bx_analytics Analytics
+    * @subsection bx_analytics-other Other
+    * @subsubsection bx_analytics get_modules
+    * 
+    * @code bx_srv('bx_analytics', 'get_modules', [...]); @endcode
+    * 
+    * Get modules list  
+    * 
+    * @return an html. 
+    * 
+    * @see BxAnalyticsModule::serviceGetModules
+    */
+    /** 
+    * @ref bx_analytics-get_modules "get_modules"
+    */
+    public function serviceGetModules()
+    {
+        $aResult = array();
+        $aResult[BX_ANALYTICS_SYSTEM] = _t('_bx_analytics_system_text');
+        $BxDolModuleQuery = BxDolModuleQuery::getInstance();
+        $aModules = $BxDolModuleQuery->getModulesBy(array('type' => 'modules', 'active' => 1));
+        foreach($aModules as $aModule){
+            $oModule = BxDolModule::getInstance($aModule['name']);
+            if($oModule instanceof iBxDolContentInfoService){
+                $aResult[$aModule['name']] = $aModule['title'];
+            }
+        }
+        
+        /**
+         * @hooks
+         * @hookdef hook-bx_analytics-get_modules 'bx_analytics', 'get_modules' - hook on get modules list with avalaible analytics
+         * - $unit_name - equals `get_modules`
+         * - $action - equals `get_modules` 
+         * - $object_id - not used
+         * - $sender_id -not used
+         * - $extra_params - array of additional params with the following array keys:
+         *      - `list` - [array] by ref, array of modules for analytics as [module_name] = module_title, can be overridden in hook processing
+         * @hook @ref hook-bx_analytics-get_modules
+         */
+        bx_alert($this->_aModule['name'], 'get_modules', 0, 0, array(
+            'list' => &$aResult,
+            'list_ref' => &$aResult,
+        ));
+        
+        return $aResult;
+    }
+          
+    public function serviceGetSafeServices()
+    {
+        return array_merge(parent::serviceGetSafeServices(), [
+            'GetReport' => '',
+            'GetTrackerConfig' => '',
+        ]);
+    }
+
+    public function serviceGetPublicServices()
+    {
+        return array_merge(parent::serviceGetPublicServices(), [
+            'Collect' => '',
+        ]);
+    }
+
+    /**
+     * @page service Service Calls
+     * @section bx_analytics Analytics
+     * @subsection bx_analytics-tracking Tracking
+     * @subsubsection bx_analytics-collect collect
+     *
+     * @code bx_srv('bx_analytics', 'collect', [$aPayload]); @endcode
+     * @code api.php?r=bx_analytics/collect with the JSON payload as the request body @endcode
+     *
+     * Store pageviews and events sent by NEO, the apps or another API client.
+     * The payload is described in BxAnalyticsCollector. Over the API the payload is read from
+     * the request body when no parameter is given.
+     *
+     * One address may send so many hits a minute and start so many visits an hour (Settings >
+     * Tracking). Past a limit the hits are dropped, and the answer is still a success with
+     * `stored: 0` and `limited: true`, not an error: a tracker goes on, and its hits are
+     * stored again from the next minute. Send fewer, batched requests when you see it.
+     *
+     * @param $mixedPayload payload array or JSON string
+     * @return array with `stored` - the number of hits stored; `limited` - true when they
+     *      were dropped for a rate limit
+     *
+     * @see BxAnalyticsModule::serviceCollect
+     */
+    /**
+     * @ref bx_analytics-collect "collect"
+     */
+    public function serviceCollect($mixedPayload = '')
+    {
+        if(empty($mixedPayload))
+            $mixedPayload = $this->_getCollectBody();
+
+        if(is_string($mixedPayload) && !$this->isCollectBodyAllowed($mixedPayload))
+            return ['stored' => 0];
+
+        $aPayload = is_array($mixedPayload) ? $mixedPayload : json_decode((string)$mixedPayload, true);
+        if(!is_array($aPayload))
+            return ['stored' => 0];
+
+        $oCollector = $this->getCollector();
+        $iStored = $oCollector->collect($aPayload, [
+            'platform' => BX_ANALYTICS_PLATFORM_API,
+            'trusted' => $this->_isApiKeyRequest(),
+            'profile_id' => bx_get_logged_profile_id(),
+        ]);
+
+        return $oCollector->isLimited() ? ['stored' => 0, 'limited' => true] : ['stored' => $iStored];
+    }
+
+    /**
+     * @page service Service Calls
+     * @section bx_analytics Analytics
+     * @subsection bx_analytics-tracking Tracking
+     * @subsubsection bx_analytics-track track
+     *
+     * @code bx_srv('bx_analytics', 'track', ['export_done', ['rows' => 120]]); @endcode
+     *
+     * Record a custom event from server code, such as a finished import or a webhook. It is
+     * stored like a site action: on the platform of the request (core for cron jobs), joined
+     * to the member's running visit on that platform when there is one. For PHP only: not
+     * available over the API. Don't call `collect` from server code instead, which records
+     * the server's own address and user agent as an API client.
+     *
+     * @param $sName event name: up to 64 letters, digits, spaces and _ . : / -
+     * @param $aProps properties: up to 30 keys of letters, digits, _ . -, with scalar values
+     * @param $iProfileId who did it; 0 for the logged in member, if any
+     * @param $fValue a number to sum in reports, such as an amount
+     * @return bool whether it was stored (false when tracking is off, the name is invalid, or
+     *      the platform or the member's level is excluded)
+     *
+     * @see BxAnalyticsModule::serviceTrack
+     */
+    /**
+     * @ref bx_analytics-track "track"
+     */
+    public function serviceTrack($sName, $aProps = [], $iProfileId = 0, $fValue = 0)
+    {
+        if(!is_string($sName) || !preg_match('/^[\p{L}\p{N} _.:\/-]{1,64}$/u', $sName))
+            return false;
+
+        $iProfileId = (int)$iProfileId;
+        if(!$iProfileId)
+            $iProfileId = (int)bx_get_logged_profile_id();
+
+        return $this->getCollector()->collectAction($sName, '', 0, $iProfileId, [
+            'type' => BX_ANALYTICS_HIT_EVENT,
+            'api_key' => $this->_isApiKeyRequest(),
+            'props' => is_array($aProps) ? $aProps : [],
+            'value' => is_numeric($fValue) ? (float)$fValue : 0,
+        ]);
+    }
+
+    /**
+     * @page service Service Calls
+     * @section bx_analytics Analytics
+     * @subsection bx_analytics-tracking Tracking
+     * @subsubsection bx_analytics-get_tracker_config get_tracker_config
+     *
+     * @code bx_srv('bx_analytics', 'get_tracker_config', ['rn-ios']); @endcode
+     * @code api.php?r=bx_analytics/get_tracker_config&params[]=next-web @endcode
+     *
+     * Tracker settings for NEO and the apps, read once when the client starts:
+     * whether to send hits at all, whether to keep a visitor id (persistent identity mode),
+     * whether to honour Do Not Track, the session timeout, and what to report about the
+     * app's health. The hits are described in BxAnalyticsCollector.
+     *
+     * @param $sPlatform client platform code (next-web, rn-ios, rn-android, api); when empty,
+     *      the platform of the request
+     * @return array with
+     *      - `enabled`, `persistent`, `respect_dnt` (booleans), `session_timeout` (seconds)
+     *      - `errors` (boolean): send `error` hits
+     *      - `errors_ignore` (array of strings): messages not to send, patterns with * for any
+     *        characters, matched against the whole message, case-insensitively; the server
+     *        drops them too, so filtering on the client only saves requests
+     *      - `vitals` (boolean): send `vitals` hits
+     *      - `vitals_sample` (0 to 100): percent of page loads (app launches on native) that
+     *        measure vitals, decided once per load with a random roll from 0 to 100 below vitals_sample
+     *
+     * @see BxAnalyticsModule::serviceGetTrackerConfig
+     */
+    /**
+     * @ref bx_analytics-get_tracker_config "get_tracker_config"
+     */
+    public function serviceGetTrackerConfig($sPlatform = '')
+    {
+        if(!is_string($sPlatform) || $sPlatform === '') {
+            bx_import('Request', $this->_aModule);
+            $oRequest = new BxAnalyticsRequest();
+            $sPlatform = $oRequest->getPlatform(defined('BX_API'), $this->_isApiKeyRequest());
+        }
+
+        // `core` is the server's own platform, never a client's
+        $bEnabled = $this->_oConfig->isTrackingEnabled() && $sPlatform != BX_ANALYTICS_PLATFORM_CORE && $this->_oConfig->isPlatformEnabled($sPlatform);
+
+        return [
+            'enabled' => $bEnabled,
+            'persistent' => $bEnabled && $this->_oConfig->getIdentityMode() == BX_ANALYTICS_IDENTITY_PERSISTENT,
+            'respect_dnt' => $this->_oConfig->isRespectDnt(),
+            'session_timeout' => $this->_oConfig->getSessionTimeout(),
+            'errors' => $bEnabled && $this->_oConfig->isTrackErrors(),
+            'errors_ignore' => $this->_oConfig->getErrorsIgnore(),
+            'vitals' => $bEnabled && $this->_oConfig->isTrackVitals() && $this->_oConfig->getVitalsSample() > 0,
+            'vitals_sample' => $this->_oConfig->getVitalsSample(),
+        ];
+    }
+
+    /**
+     * Hits from the classic tracker: same origin, a JSON body sent with sendBeacon. Always
+     * 204, hits dropped for a rate limit too (see BxAnalyticsCollector::_isWithinHitLimit).
+     */
+    public function actionCollect()
+    {
+        $sBody = $this->_getCollectBody();
+        $aPayload = $sBody !== false ? json_decode($sBody, true) : null;
+        if(is_array($aPayload))
+            $this->getCollector()->collect($aPayload, [
+                'platform' => BX_ANALYTICS_PLATFORM_CORE_WEB,
+                'same_origin' => true,
+                'profile_id' => bx_get_logged_profile_id(),
+            ]);
+
+        header('HTTP/1.1 204 No Content');
+        header('Cache-Control: no-store');
+        exit;
+    }
+
+    /**
+     * Whether a collect request's body is small enough to decode: no larger than a tracker
+     * ever sends (MAX_COLLECT_BYTES). The endpoint is open to anyone, and decoding takes
+     * memory and time before any rate limit applies.
+     */
+    public function isCollectBodyAllowed($sBody)
+    {
+        return is_string($sBody) && mb_strlen($sBody, '8bit') <= $this->_oConfig->CNF['MAX_COLLECT_BYTES'];
+    }
+
+    /**
+     * The body of a collect request, or false when it is too large (see isCollectBodyAllowed),
+     * told by its length header before it is read when there is one.
+     */
+    protected function _getCollectBody()
+    {
+        $iMax = $this->_oConfig->CNF['MAX_COLLECT_BYTES'];
+        if(isset($_SERVER['CONTENT_LENGTH']) && (int)$_SERVER['CONTENT_LENGTH'] > $iMax)
+            return false;
+
+        $sBody = file_get_contents('php://input', false, null, 0, $iMax + 1);
+
+        return $this->isCollectBodyAllowed($sBody) ? $sBody : false;
+    }
+
+    /**
+     * @page service Service Calls
+     * @section bx_analytics Analytics
+     * @subsection bx_analytics-tracking Tracking
+     * @subsubsection bx_analytics-get_tracker_code get_tracker_code
+     *
+     * @code bx_srv('bx_analytics', 'get_tracker_code', [...]); @endcode
+     *
+     * The classic UI tracker, injected at the end of every page.
+     *
+     * @return HTML string, empty when tracking is off for the classic UI or for the viewer
+     *
+     * @see BxAnalyticsModule::serviceGetTrackerCode
+     */
+    /**
+     * @ref bx_analytics-get_tracker_code "get_tracker_code"
+     */
+    public function serviceGetTrackerCode()
+    {
+        if(!$this->_oConfig->isTrackingEnabled() || !$this->_oConfig->isPlatformEnabled(BX_ANALYTICS_PLATFORM_CORE_WEB))
+            return '';
+
+        // bots don't run the tracker: the page counts them as it renders
+        bx_import('UserAgent', $this->_aModule);
+        $sUserAgent = isset($_SERVER['HTTP_USER_AGENT']) ? (string)$_SERVER['HTTP_USER_AGENT'] : '';
+        if(BxAnalyticsUserAgent::isBot($sUserAgent)) {
+            $this->getCollector()->collectBot($sUserAgent, isset($_SERVER['REQUEST_URI']) ? (string)$_SERVER['REQUEST_URI'] : '');
+            return '';
+        }
+
+        $aLevels = $this->_oConfig->getExcludedLevels();
+        if(!empty($aLevels)) {
+            $iProfileId = bx_get_logged_profile_id();
+            $aMembership = BxDolAcl::getInstance()->getMemberMembershipInfo($iProfileId ? $iProfileId : 0);
+            if(!empty($aMembership['id']) && in_array((int)$aMembership['id'], $aLevels, true))
+                return '';
+        }
+
+        return $this->_oTemplate->getTrackerCode();
+    }
+
+    /**
+     * @page service Service Calls
+     * @section bx_analytics Analytics
+     * @subsection bx_analytics-reports Reports
+     * @subsubsection bx_analytics-get_report get_report
+     *
+     * @code bx_srv('bx_analytics', 'get_report', ['overview', ['range' => '30d', 'platform' => 'rn-ios']]); @endcode
+     * @code api.php?r=bx_analytics/get_report&params[]=top&params[]={"group":"pages","tab":"entry","range":"7d"} @endcode
+     *
+     * Report data for NEO and other clients. Reports: filters, overview, top, table, realtime
+     * and the other sections listed in BxAnalyticsModule::$_aReports. Sessions and users need
+     * the "view analytics people" permission; revenue, subscriptions, sales and credits need
+     * "view analytics revenue". Errors and performance need only "use analytics", but an
+     * error's stack trace (visitor text) is left out without "view analytics people".
+     *
+     * @param $sReport report name
+     * @param $mixedParams filter parameters (see BxAnalyticsFilter) and the report's own
+     *      (group, table, tab, page, limit...); an array or a JSON string
+     * @return array report data, or an error array when the viewer may not use analytics
+     *
+     * @see BxAnalyticsModule::serviceGetReport
+     */
+    /**
+     * @ref bx_analytics-get_report "get_report"
+     */
+    public function serviceGetReport($sReport, $mixedParams = [])
+    {
+        if(!$this->serviceIsAvaliable())
+            return ['error' => _t('_sys_txt_access_denied'), 'code' => 403];
+
+        if((in_array($sReport, $this->_aRevenueReports, true) && $this->checkAllowedRevenue() !== CHECK_ACTION_RESULT_ALLOWED) || (in_array($sReport, $this->_aPeopleReports, true) && $this->checkAllowedPeople() !== CHECK_ACTION_RESULT_ALLOWED))
+            return ['error' => _t('_sys_txt_access_denied'), 'code' => 403];
+
+        $aParams = is_array($mixedParams) ? $mixedParams : json_decode((string)$mixedParams, true);
+        if(!is_array($aParams))
+            $aParams = [];
+
+        $aData = $this->getReportData($sReport, $aParams);
+        if($aData === false)
+            return ['error' => _t('_sys_request_page_not_found_cpt'), 'code' => 404];
+
+        return $aData;
+    }
+
+    /**
+     * A report block reloaded by the dashboard script: data plus rendered HTML. The block's
+     * own parameters (group, table...) come as JSON in `bp`, the filters as the page's URL
+     * parameters.
+     */
+    public function actionReport($sReport = '')
+    {
+        header('Content-Type: application/json; charset=utf-8');
+        header('Cache-Control: no-store');
+
+        if(!$this->serviceIsAvaliable() || (in_array($sReport, $this->_aRevenueReports, true) && $this->checkAllowedRevenue() !== CHECK_ACTION_RESULT_ALLOWED)) {
+            http_response_code(403);
+            echo json_encode(['error' => _t('_sys_txt_access_denied')]);
+            exit;
+        }
+
+        $aBlockParams = json_decode((string)bx_get('bp'), true);
+        if(!is_array($aBlockParams))
+            $aBlockParams = [];
+
+        $aParams = array_merge($_GET, $aBlockParams);
+        unset($aParams['bp']);
+
+        $aData = $this->getReportData($sReport, $aParams);
+        if($aData === false) {
+            http_response_code(404);
+            echo json_encode(['error' => _t('_sys_request_page_not_found_cpt')]);
+            exit;
+        }
+
+        echo json_encode([
+            'data' => $aData,
+            'html' => $this->_oTemplate->getReportHtml($sReport, $aData, $this->getFilter($aParams), $aBlockParams),
+        ]);
+        exit;
+    }
+
+    /**
+     * A report as a CSV download: GET export/<report>/ with the page's filters and the
+     * report's own parameters (q_dim, q_metric, q_dir for the query; table or group and tab
+     * otherwise). Every value of the dimension, up to MAX_EXPORT_ROWS rows. Reading, so the
+     * analytics permission is checked without counting a use of it.
+     */
+    public function actionExport($sReport = '')
+    {
+        $aExport = $this->getReportExport((string)$sReport, $_GET);
+        if($aExport === false)
+            $this->_outputJson(['error' => _t('_sys_txt_access_denied'), 'code' => 403]);
+
+        header('Content-Type: text/csv; charset=utf-8');
+        header('Content-Disposition: attachment; filename="' . $aExport['filename'] . '"');
+        header('Cache-Control: no-store');
+        header('X-Content-Type-Options: nosniff');
+
+        $rOutput = fopen('php://output', 'w');
+        BxAnalyticsCsv::write($rOutput, $aExport['header'], $aExport['rows']);
+        fclose($rOutput);
+        exit;
+    }
+
+    /**
+     * @page service Service Calls
+     * @section bx_analytics Analytics
+     * @subsection bx_analytics-blocks Blocks
+     * @subsubsection bx_analytics-get_block_filters get_block_filters
+     *
+     * @code bx_srv('bx_analytics', 'get_block_filters'); @endcode
+     *
+     * The filter bar: period, platform, active filters and visitors online. It keeps the
+     * filters in the page URL, and every analytics block on the page follows them.
+     *
+     * @return HTML string, or a block description for the API; empty for viewers without access
+     *
+     * @see BxAnalyticsModule::serviceGetBlockFilters
+     */
+    /**
+     * @ref bx_analytics-get_block_filters "get_block_filters"
+     */
+    public function serviceGetBlockFilters()
+    {
+        return $this->_getBlock('filters');
+    }
+
+    /**
+     * @page service Service Calls
+     * @section bx_analytics Analytics
+     * @subsection bx_analytics-blocks Blocks
+     * @subsubsection bx_analytics-get_block_overview get_block_overview
+     *
+     * @code bx_srv('bx_analytics', 'get_block_overview'); @endcode
+     *
+     * Key figures with the change against the previous period, and the main chart.
+     *
+     * @return HTML string, or a block description for the API; empty for viewers without access
+     *
+     * @see BxAnalyticsModule::serviceGetBlockOverview
+     */
+    /**
+     * @ref bx_analytics-get_block_overview "get_block_overview"
+     */
+    public function serviceGetBlockOverview()
+    {
+        return $this->_getBlock('overview');
+    }
+
+    /**
+     * @page service Service Calls
+     * @section bx_analytics Analytics
+     * @subsection bx_analytics-blocks Blocks
+     * @subsubsection bx_analytics-get_block_top get_block_top
+     *
+     * @code bx_srv('bx_analytics', 'get_block_top', ['sources']); @endcode
+     *
+     * A tabbed top list: pages, sources, locations, devices or platforms.
+     *
+     * @param $sGroup list group
+     * @return HTML string, or a block description for the API; empty for viewers without access
+     *
+     * @see BxAnalyticsModule::serviceGetBlockTop
+     */
+    /**
+     * @ref bx_analytics-get_block_top "get_block_top"
+     */
+    public function serviceGetBlockTop($sGroup = 'pages')
+    {
+        return $this->_getBlock('top', ['group' => $sGroup]);
+    }
+
+    /**
+     * @page service Service Calls
+     * @section bx_analytics Analytics
+     * @subsection bx_analytics-blocks Blocks
+     * @subsubsection bx_analytics-get_block_table get_block_table
+     *
+     * @code bx_srv('bx_analytics', 'get_block_table', ['pages']); @endcode
+     *
+     * A full table with every metric, a page at a time: pages or sources.
+     *
+     * @param $sTable table name
+     * @return HTML string, or a block description for the API; empty for viewers without access
+     *
+     * @see BxAnalyticsModule::serviceGetBlockTable
+     */
+    /**
+     * @ref bx_analytics-get_block_table "get_block_table"
+     */
+    public function serviceGetBlockTable($sTable = 'pages')
+    {
+        return $this->_getBlock('table', ['table' => $sTable]);
+    }
+
+    /**
+     * @page service Service Calls
+     * @section bx_analytics Analytics
+     * @subsection bx_analytics-blocks Blocks
+     * @subsubsection bx_analytics-get_block_report get_block_report
+     *
+     * @code bx_srv('bx_analytics', 'get_block_report', ['realtime']); @endcode
+     *
+     * Any other report as a block: realtime, map, bots, sessions, users, events, goals,
+     * funnels, journeys, retention, revenue, subscriptions, sales, credits, errors,
+     * performance, query.
+     *
+     * @param $sReport report name
+     * @param $aParams the report's own parameters
+     * @return HTML string, or a block description for the API; empty for viewers without access
+     *
+     * @see BxAnalyticsModule::serviceGetBlockReport
+     */
+    /**
+     * @ref bx_analytics-get_block_report "get_block_report"
+     */
+    public function serviceGetBlockReport($sReport, $aParams = [])
+    {
+        return $this->_getBlock($sReport, is_array($aParams) ? $aParams : []);
+    }
+
+    /**
+     * A Studio section: the filter bar (where the section uses it) and the section's blocks.
+     */
+    public function getStudioSection($sSection)
+    {
+        // admin tools: not reports, so get_report and the blocks never reach them
+        if(in_array($sSection, $this->_aStudioTools, true)) {
+            if(!isAdmin())
+                return '';
+
+            $sMethod = '_getStudio' . bx_gen_method_name($sSection);
+            return $this->_oTemplate->getStudioSection([['title' => '', 'content' => $this->$sMethod(), 'wide' => true]]);
+        }
+
+        if(!isset($this->_aStudioSections[$sSection]))
+            return '';
+
+        // one report under the filter bar needs no heading: the section title names it
+        $iReports = 0;
+        foreach($this->_aStudioSections[$sSection] as $aBlock)
+            if($aBlock[0] != 'filters')
+                $iReports++;
+
+        $aBlocks = [];
+        foreach($this->_aStudioSections[$sSection] as $aBlock) {
+            list($sReport, $aParams, $bWide) = array_pad($aBlock, 3, false);
+
+            $sTitle = '';
+            if($iReports > 1 && $sReport == 'top')
+                $sTitle = _t('_bx_analytics_group_' . $aParams['group']);
+            elseif($iReports > 1 && !in_array($sReport, ['filters', 'overview', 'table'], true))
+                $sTitle = _t('_bx_analytics_report_' . (isset($aParams['title']) ? $aParams['title'] : $sReport));
+
+            $aBlocks[] = [
+                'title' => $sTitle,
+                'content' => $this->_getBlock($sReport, $aParams ? $aParams : []),
+                'wide' => (bool)$bWide,
+            ];
+        }
+
+        return $this->_oTemplate->getStudioSection($aBlocks);
+    }
+
+    /**
+     * A report as a page block: a description NEO renders from for the API, HTML otherwise.
+     */
+    protected function _getBlock($sReport, $aBlockParams = [])
+    {
+        if(!$this->serviceIsAvaliable() || !in_array($sReport, $this->_aReports, true))
+            return bx_is_api() ? [] : '';
+
+        if(in_array($sReport, $this->_aPeopleReports, true) && $this->checkAllowedPeople() !== CHECK_ACTION_RESULT_ALLOWED)
+            return bx_is_api() ? [] : '';
+
+        if(in_array($sReport, $this->_aRevenueReports, true) && $this->checkAllowedRevenue() !== CHECK_ACTION_RESULT_ALLOWED)
+            return bx_is_api() ? [] : '';
+
+        if(bx_is_api())
+            return [bx_api_get_block('analytics_' . $sReport, $this->_getApiBlockData($sReport, $aBlockParams))];
+
+        $aParams = array_merge($_GET, $aBlockParams);
+        $aData = $this->getReportData($sReport, $aParams);
+        if($aData === false)
+            return '';
+
+        return $this->_oTemplate->getBlock($sReport, $aData, $this->getFilter($aParams), $aBlockParams);
+    }
+
+    /*
+     * Studio tools: API playground, dashboards, setup. Site administrators only.
+     */
+
+    /**
+     * Reports the viewer may ask get_report for, with their parameters: common (the filter)
+     * and reports => [report => parameters]. Reports with members or money are left out
+     * without the permission to see them.
+     */
+    public function getApiCatalog()
+    {
+        $aSchema = $this->getReports()->getParamsSchema();
+
+        $aReports = [];
+        foreach($this->_aReports as $sReport)
+            if($this->_isReportAllowed($sReport) && isset($aSchema['reports'][$sReport]))
+                $aReports[$sReport] = $aSchema['reports'][$sReport];
+
+        return ['common' => $aSchema['common'], 'reports' => $aReports];
+    }
+
+    /**
+     * Run get_report as api.php would for the current viewer and answer with api.php's
+     * envelope: status, module, method, params and data, or status and error. Only a report
+     * of the catalog is run: the playground never takes a service or a method name.
+     *
+     * @param string $sReport report name
+     * @param mixed $mixedParams the report's parameters, an array or a JSON object string
+     */
+    public function getApiResponse($sReport, $mixedParams)
+    {
+        if(is_array($mixedParams))
+            $aParams = $mixedParams;
+        else
+            $aParams = trim((string)$mixedParams) === '' ? [] : json_decode((string)$mixedParams, true);
+        if(!is_array($aParams) || (!empty($aParams) && array_values($aParams) === $aParams))
+            return ['status' => 400, 'error' => _t('_bx_analytics_txt_api_bad_params')];
+
+        $aCatalog = $this->getApiCatalog();
+        if(!is_string($sReport) || !isset($aCatalog['reports'][$sReport]))
+            return ['status' => 404, 'error' => _t('_sys_request_page_not_found_cpt')];
+
+        $mixedRet = $this->serviceGetReport($sReport, $aParams);
+        if(is_array($mixedRet) && isset($mixedRet['error']))
+            return [
+                'status' => isset($mixedRet['code']) ? (int)$mixedRet['code'] : 500,
+                'error' => $mixedRet['error'],
+                'data' => '',
+            ];
+
+        return [
+            'status' => 200,
+            'module' => $this->getName(),
+            'method' => 'get_report',
+            'params' => [$sReport, (object)$aParams],
+            'data' => $mixedRet,
+        ];
+    }
+
+    /**
+     * Run a report from the API playground: POST report, params (JSON), csrf_token.
+     */
+    public function actionApi()
+    {
+        $this->_checkAdminRequest();
+
+        $iStart = microtime(true);
+        $aResponse = $this->getApiResponse((string)bx_get('report', 'post'), (string)bx_get('params', 'post'));
+
+        $this->_outputJson([
+            'ok' => true,
+            'ms' => (int)round(1000 * (microtime(true) - $iStart)),
+            'response' => $aResponse,
+        ]);
+    }
+
+    /**
+     * Site pages with analytics blocks: title, blocks, blocks NEO renders, view and builder
+     * links, and whether the page goes away with the module (created by it).
+     */
+    public function getDashboards()
+    {
+        $oPermalinks = BxDolPermalinks::getInstance();
+
+        $aResult = [];
+        foreach($this->_oDb->getPagesWithBlocks($this->getName()) as $aPage)
+            $aResult[] = [
+                'object' => $aPage['object'],
+                'title' => _t($aPage['title']),
+                'blocks' => (int)$aPage['blocks'],
+                'api_blocks' => (int)$aPage['api_blocks'],
+                'view_url' => bx_absolute_url($oPermalinks->permalink($aPage['url'] !== '' ? $aPage['url'] : 'page.php?i=' . $aPage['uri'])),
+                'edit_url' => BX_DOL_URL_STUDIO . 'builder_page.php?type=' . rawurlencode($aPage['module']) . '&page=' . rawurlencode($aPage['object']),
+                'module_page' => $aPage['module'] == $this->getName(),
+            ];
+
+        return $aResult;
+    }
+
+    /**
+     * A new site page of the module with the analytics page's blocks in the center and the
+     * right bar, all shown in NEO. Its title is a language string in every language. Pages
+     * of the module are removed when the module is disabled.
+     *
+     * @param string $sTitle page title, 1 to 100 characters
+     * @return array|false object, uri, edit_url and view_url, or false
+     */
+    public function createDashboard($sTitle)
+    {
+        $sTitle = trim((string)$sTitle);
+        if($sTitle === '' || get_mb_len($sTitle) > 100)
+            return false;
+
+        $sUri = uriGenerate(get_mb_substr($sTitle, 0, 50), 'sys_objects_page', 'uri', ['empty' => 'analytics']);
+
+        // the object names language keys: Latin letters, digits and _ only, whatever the title
+        $sName = trim(preg_replace('/[^a-z0-9_]+/', '_', get_mb_substr($sUri, 0, 40)), '_');
+        $sObject = uriGenerate($this->getName() . '_' . ($sName !== '' ? $sName : 'dashboard'), 'sys_objects_page', 'object', ['divider' => '_']);
+        if($this->_oDb->isPageObject($sObject) || $this->_oDb->isPageUri($sUri))
+            return false;
+
+        $sKey = '_' . $sObject . '_page_title';
+        $sKeySystem = '_' . $sObject . '_page_title_sys';
+        $oLanguages = BxDolStudioLanguagesUtils::getInstance();
+
+        // disabling the module removes its pages but not their titles: keys of a page that is
+        // gone are stale, and adding a string to an existing key fails
+        $oLanguages->deleteLanguageString($sKey);
+        $oLanguages->deleteLanguageString($sKeySystem);
+
+        if(!$oLanguages->addLanguageString($sKey, $sTitle) || !$oLanguages->addLanguageString($sKeySystem, $sTitle)) {
+            $oLanguages->deleteLanguageString($sKey);
+            $oLanguages->deleteLanguageString($sKeySystem);
+            return false;
+        }
+
+        $mixedBlocks = $this->_oDb->insertPageWithBlocks([
+            'object' => $sObject,
+            'uri' => $sUri,
+            'title_system' => $sKeySystem,
+            'title' => $sKey,
+            'module' => $this->getName(),
+            'layout_id' => $this->_iDashboardLayout,
+            'author' => bx_get_logged_profile_id(),
+        ], $this->_sDashboardTemplate, $this->_aDashboardCells);
+
+        // page objects and their URIs are cached
+        foreach(['sys_pages_objects_data', 'sys_pages_uri_object_map', 'sys_pages_urirewrite_object_map'] as $sCache)
+            $this->_oDb->cleanCache($sCache);
+
+        if($mixedBlocks === false) {
+            $oLanguages->deleteLanguageString($sKey);
+            $oLanguages->deleteLanguageString($sKeySystem);
+            return false;
+        }
+
+        return [
+            'object' => $sObject,
+            'uri' => $sUri,
+            'blocks' => $mixedBlocks,
+            'edit_url' => BX_DOL_URL_STUDIO . 'builder_page.php?type=' . rawurlencode($this->getName()) . '&page=' . rawurlencode($sObject),
+            'view_url' => bx_absolute_url(BxDolPermalinks::getInstance()->permalink('page.php?i=' . $sUri)),
+        ];
+    }
+
+    /**
+     * Create an analytics page: POST title, csrf_token. Answers with its builder link.
+     */
+    public function actionDashboardCreate()
+    {
+        $this->_checkAdminRequest();
+
+        $aPage = $this->createDashboard((string)bx_get('title', 'post'));
+        if($aPage === false)
+            $this->_outputJson(['error' => _t('_bx_analytics_txt_dash_create_failed')]);
+
+        $this->_outputJson(['ok' => true, 'url' => $aPage['edit_url']]);
+    }
+
+    /**
+     * Activity per platform: last (latest hit), hits (last 24 hours), visitors (in visits
+     * active in the last 24 hours), sessions (started in the last 7 days), app_version (the
+     * highest seen in those visits, apps only). Kept for a minute.
+     *
+     * @param int $iNow the time to count back from
+     * @param bool $bCache use and fill the minute's copy
+     */
+    public function getPlatformActivity($iNow, $bCache = true)
+    {
+        $sCache = 'bx_analytics_platform_activity';
+        if($bCache) {
+            $aCached = $this->_oDb->getCache($sCache, '', 60);
+            if(is_array($aCached))
+                return $aCached;
+        }
+
+        $iDay = $iNow - 86400;
+        $iWeek = $iNow - 7 * 86400;
+        $aHits = $this->_oDb->getRecentHits($iDay, $iNow);
+        $aLast = $this->_oDb->getLastHits($iNow);
+        $aVisitors = $this->_oDb->getActiveVisitors($iDay, $iNow);
+        $aSessions = $this->_oDb->getStartedSessions($iWeek, $iNow);
+
+        $aApps = array_values(array_filter(array_keys($this->_oConfig->getPlatforms()), [$this->_oConfig, 'isAppPlatform']));
+        $aVersions = [];
+        foreach($this->_oDb->getAppVersions($iWeek, $iNow, $aApps) as $aRow)
+            if(!isset($aVersions[$aRow['platform']]) || version_compare($aRow['app_version'], $aVersions[$aRow['platform']], '>'))
+                $aVersions[$aRow['platform']] = $aRow['app_version'];
+
+        $aResult = [];
+        foreach(array_keys($this->_oConfig->getPlatforms()) as $sPlatform) {
+            $iLast = max(isset($aLast[$sPlatform]) ? (int)$aLast[$sPlatform] : 0, isset($aHits[$sPlatform]) ? (int)$aHits[$sPlatform]['last'] : 0);
+
+            // hits outside a visit (the server's own, actions without a running visit) are
+            // only in events: without a hit in the last 24 hours, look up the latest one
+            if(!isset($aHits[$sPlatform]))
+                $iLast = max($iLast, $this->_oDb->getLastEvent($sPlatform, $iNow));
+
+            $aResult[$sPlatform] = [
+                'last' => $iLast,
+                'hits' => isset($aHits[$sPlatform]) ? (int)$aHits[$sPlatform]['hits'] : 0,
+                'visitors' => isset($aVisitors[$sPlatform]) ? (int)$aVisitors[$sPlatform] : 0,
+                'sessions' => isset($aSessions[$sPlatform]) ? (int)$aSessions[$sPlatform] : 0,
+                'app_version' => isset($aVersions[$sPlatform]) ? $aVersions[$sPlatform] : '',
+            ];
+        }
+
+        if($bCache)
+            $this->_oDb->setCache($sCache, $aResult, 60);
+
+        return $aResult;
+    }
+
+    /**
+     * Tracker status per platform: enabled, status (receiving: a hit in the last hour,
+     * quiet: in the last 7 days, nodata, off) and the activity.
+     *
+     * @param int $iNow the time to count back from
+     * @param bool $bCache use the minute's copy of the activity
+     */
+    public function getSetupStatus($iNow, $bCache = true)
+    {
+        $bTracking = $this->_oConfig->isTrackingEnabled();
+        $aActivity = $this->getPlatformActivity($iNow, $bCache);
+
+        $aResult = [];
+        foreach($aActivity as $sPlatform => $aRow) {
+            $bEnabled = $bTracking && $this->_oConfig->isPlatformEnabled($sPlatform);
+
+            $sStatus = 'nodata';
+            if(!$bEnabled)
+                $sStatus = 'off';
+            elseif($aRow['last'] >= $iNow - 3600)
+                $sStatus = 'receiving';
+            elseif($aRow['last'] >= $iNow - 7 * 86400)
+                $sStatus = 'quiet';
+
+            $aResult[$sPlatform] = ['platform' => $sPlatform, 'enabled' => $bEnabled, 'status' => $sStatus] + $aRow;
+        }
+
+        return $aResult;
+    }
+
+    /**
+     * What the classic tracker's collector would do with a pageview of the site's home page
+     * from the current request: a dry run that stores nothing.
+     */
+    public function checkRequest()
+    {
+        return $this->getCollector()->check($this->_getSetupPayload(BX_ANALYTICS_HIT_PAGEVIEW), $this->_getSetupContext());
+    }
+
+    /**
+     * Dry run of the tracker for the admin's browser: POST csrf_token. Answers with the
+     * result rendered.
+     */
+    public function actionCheck()
+    {
+        $this->_checkAdminRequest();
+
+        $this->_outputJson(['ok' => true, 'html' => $this->_oTemplate->getSetupCheckHtml($this->checkRequest())]);
+    }
+
+    /**
+     * Send a test event from the admin's browser through the real collector: POST
+     * csrf_token. It is stored like any hit, so it adds a visit. Answers with whether it
+     * was stored and the status table again.
+     */
+    public function actionTestEvent()
+    {
+        $this->_checkAdminRequest();
+
+        $iStored = $this->getCollector()->collect($this->_getSetupPayload(BX_ANALYTICS_HIT_EVENT), $this->_getSetupContext());
+        $this->_oDb->cleanCache('bx_analytics_platform_activity');
+
+        $this->_outputJson([
+            'ok' => true,
+            'stored' => $iStored,
+            'message' => _t($iStored ? '_bx_analytics_txt_setup_test_stored' : '_bx_analytics_txt_setup_test_dropped'),
+            'html' => $this->_oTemplate->getSetupStatusHtml($this->getSetupStatus(time(), false)),
+        ]);
+    }
+
+    /**
+     * A classic tracker hit on the site's home page, for the Setup checks.
+     */
+    protected function _getSetupPayload($sType)
+    {
+        $aHit = ['type' => $sType, 'url' => BX_DOL_URL_ROOT, 'title' => _t('_bx_analytics_txt_setup_test_title')];
+        if($sType == BX_ANALYTICS_HIT_EVENT)
+            $aHit['name'] = self::SETUP_TEST_EVENT;
+
+        return ['platform' => BX_ANALYTICS_PLATFORM_CORE_WEB, 'hits' => [$aHit]];
+    }
+
+    protected function _getSetupContext()
+    {
+        return [
+            'platform' => BX_ANALYTICS_PLATFORM_CORE_WEB,
+            'same_origin' => true,
+            'profile_id' => bx_get_logged_profile_id(),
+        ];
+    }
+
+    /**
+     * Studio > API: the playground.
+     */
+    protected function _getStudioApi()
+    {
+        return $this->_oTemplate->getStudioApi([
+            'catalog' => $this->getApiCatalog(),
+            'api_enabled' => (bool)getParam('sys_api_enable'),
+            'api_access' => getParam('sys_api_access_by_origin') || getParam('sys_api_access_by_key'),
+        ]);
+    }
+
+    /**
+     * Studio > Dashboards: pages with analytics blocks, and a new one.
+     */
+    protected function _getStudioDashboards()
+    {
+        return $this->_oTemplate->getStudioDashboards([
+            'pages' => $this->getDashboards(),
+        ]);
+    }
+
+    /**
+     * Studio > Setup: tracker status per platform, what each client needs, custom events,
+     * and the checks of the admin's own browser.
+     */
+    protected function _getStudioSetup()
+    {
+        $aAlerts = [];
+        foreach($this->_oDb->getHandlerAlerts($this->getName()) as $aAlert) {
+            // the alerts that record site actions, not the ones that keep data in order; new
+            // content of each module (sync_actions) is said in words, not listed
+            if(in_array($aAlert['unit'] . ':' . $aAlert['action'], ['profile:delete', 'system:enable', 'system:disable'], true) || ($aAlert['unit'] == 'system' && in_array($aAlert['action'], $this->_aPaymentAlerts, true)) || ($aAlert['action'] == 'added' && $aAlert['unit'] != 'comment'))
+                continue;
+            $aAlerts[] = $aAlert['unit'] . ':' . $aAlert['action'];
+        }
+
+        return $this->_oTemplate->getStudioSetup([
+            'status' => $this->getSetupStatus(time()),
+            'tracking' => $this->_oConfig->isTrackingEnabled(),
+            'injection' => $this->_oDb->isInjectionActive('bx_analytics_tracker'),
+            'track_actions' => $this->_oConfig->isTrackActions(),
+            'alerts' => $aAlerts,
+            'api_enabled' => (bool)getParam('sys_api_enable'),
+            'api_key' => (bool)getParam('sys_api_access_by_key'),
+            'api_origin' => (bool)getParam('sys_api_access_by_origin'),
+        ]);
+    }
+
+    /**
+     * Whether the request is a POST from a site administrator with a valid form token: what
+     * every Studio tool action needs.
+     */
+    public function isAdminRequest()
+    {
+        return isAdmin() && isset($_SERVER['REQUEST_METHOD']) && $_SERVER['REQUEST_METHOD'] == 'POST' && BxDolForm::isCsrfTokenValid((string)bx_get('csrf_token', 'post'), false);
+    }
+
+    /**
+     * Answer 403 and stop, unless the request is an administrator's POST with a valid token.
+     */
+    protected function _checkAdminRequest()
+    {
+        if(!$this->isAdminRequest())
+            $this->_outputJson(['error' => _t('_sys_txt_access_denied'), 'code' => 403]);
+    }
+
+    /**
+     * @page service Service Calls
+     * @section bx_analytics Analytics
+     * @subsection bx_analytics-blocks Blocks
+     * @subsubsection bx_analytics-get_studio_dashboard_traffic get_studio_dashboard_traffic
+     *
+     * @code bx_srv('bx_analytics', 'get_studio_dashboard_traffic'); @endcode
+     *
+     * Traffic block of the Studio dashboard: visitors online on every platform and the last 7 days.
+     *
+     * @return array with `content` for the Studio dashboard
+     *
+     * @see BxAnalyticsModule::serviceGetStudioDashboardTraffic
+     */
+    /**
+     * @ref bx_analytics-get_studio_dashboard_traffic "get_studio_dashboard_traffic"
+     */
+    public function serviceGetStudioDashboardTraffic()
+    {
+        if(!isAdmin())
+            return '';
+
+        // the week's figures come from the report cache; only the visitors online are live
+        $aOverview = $this->getReportData('overview', ['range' => '7d']);
+        $aPlatforms = $this->getReportData('top', ['range' => '7d', 'group' => 'platforms', 'limit' => 5]);
+        if($aOverview === false || $aPlatforms === false)
+            return '';
+
+        return [
+            'content' => $this->_oTemplate->getStudioDashboardTraffic($aOverview, $this->getReports()->getRealtime($this->getFilter(['range' => '7d']), ['limit' => 0]), $aPlatforms),
+        ];
+    }
+
+    /**
+     * @page service Service Calls
+     * @section bx_analytics Analytics
+     * @subsection bx_analytics-other Other
+     * @subsubsection bx_analytics-pruning pruning
+     *
+     * @code bx_srv('bx_analytics', 'pruning'); @endcode
+     *
+     * Hourly cron job: deletes old daily salts, then data past the retention period in
+     * small batches, within a time limit so other cron jobs aren't held up. The revenue
+     * ledger is never pruned. While past payments are being imported, the cron imports
+     * another step of them.
+     *
+     * @return int rows deleted
+     *
+     * @see BxAnalyticsModule::servicePruning
+     */
+    /**
+     * @ref bx_analytics-pruning "pruning"
+     */
+    public function servicePruning()
+    {
+        $CNF = &$this->_oConfig->CNF;
+
+        // yesterday's salt is still needed to continue visits that crossed midnight
+        $this->_oDb->deleteSaltsBefore(gmdate('Y-m-d', time() - 86400));
+
+        $iBefore = time() - 86400 * $this->_oConfig->getRetentionDays();
+        $this->_oDb->deleteBotsBefore(gmdate('Y-m-d', $iBefore));
+        $iDeadline = time() + $CNF['PRUNING_SECONDS'];
+
+        $iDeleted = 0;
+        foreach($this->getPruningCutoffs(time()) as $sMethod => $iTime)
+            do {
+                $iBatch = $this->_oDb->$sMethod($iTime, $CNF['PRUNING_BATCH']);
+                $iDeleted += $iBatch;
+            } while($iBatch >= $CNF['PRUNING_BATCH'] && time() < $iDeadline);
+
+        $this->pruneReportCache(time());
+
+        try {
+            $oRevenue = $this->getRevenue();
+            if($oRevenue->getBackfillStatus()['state'] == 'running') {
+                $oRevenue->backfill(time() + $CNF['BACKFILL_SECONDS']);
+                $this->cleanReportCache();
+            }
+        }
+        catch(Throwable $e) {
+            bx_log('sys_payments', 'bx_analytics: import of past payments failed: ' . $e->getMessage(), BX_LOG_ERR);
+        }
+
+        return $iDeleted;
+    }
+
+    /**
+     * What pruning deletes and from when: a BxAnalyticsDb delete method => the time before
+     * which its rows go. Vitals have a shorter retention of their own (a row per sampled
+     * page view), never longer than the rest. Rate limit counters are kept for 2 hours.
+     * Error groups that go on happening keep their sample stack as long as the rest.
+     *
+     * The run's time limit is shared, in this order: the small tables that grow fastest
+     * come first, so a backlog of events (after the retention was lowered) can't leave the
+     * rate limit counters growing for hours.
+     *
+     * @param int $iNow the time pruning runs at
+     */
+    public function getPruningCutoffs($iNow)
+    {
+        $iBefore = (int)$iNow - 86400 * $this->_oConfig->getRetentionDays();
+
+        return [
+            // the collector's rate limit counters: the last full hour and the running one
+            'deleteLimitsBefore' => (int)$iNow - 7200,
+            'deleteVitalsBefore' => (int)$iNow - 86400 * $this->_oConfig->getVitalsRetentionDays(),
+            'deleteErrorHitsBefore' => $iBefore,
+            'deleteErrorGroupsBefore' => $iBefore,
+            'clearErrorStacksBefore' => $iBefore,
+            'deleteEventsBefore' => $iBefore,
+            'deleteSessionsBefore' => $iBefore,
+        ];
+    }
+
+    /**
+     * @page service Service Calls
+     * @section bx_analytics Analytics
+     * @subsection bx_analytics-tracking Tracking
+     * @subsubsection bx_analytics-sync_actions sync_actions
+     *
+     * @code bx_srv('bx_analytics', 'sync_actions'); @endcode
+     *
+     * Subscribe to "added" of every content module on the site, so new content becomes an
+     * event (comments and votes have site-wide alerts). Runs when the module is enabled and
+     * whenever another module is enabled or disabled.
+     *
+     * @return int subscriptions written
+     *
+     * @see BxAnalyticsModule::serviceSyncActions
+     */
+    /**
+     * @ref bx_analytics-sync_actions "sync_actions"
+     */
+    public function serviceSyncActions()
+    {
+        $aUnits = [];
+
+        foreach(BxDolModuleQuery::getInstance()->getModulesBy(['type' => 'modules', 'active' => 1]) as $aModule) {
+            $oModule = BxDolModule::getInstance($aModule['name']);
+            if($oModule instanceof iBxDolContentInfoService)
+                $aUnits[$aModule['name']][] = 'added';
+        }
+
+        $iWritten = $this->_oDb->setActionAlerts($this->getName(), ['added'], $aUnits, ['comment']);
+        BxDolAlerts::cacheInvalidate();
+
+        return $iWritten;
+    }
+
+    /**
+     * Alerts the module listens to: deleted profiles, modules switched on and off, and the
+     * site actions it records as events.
+     */
+    public function onAlert($oAlert)
+    {
+        $sUnit = $oAlert->sUnit;
+        $sAction = $oAlert->sAction;
+
+        // money: whatever goes wrong here must never break a checkout
+        if($sUnit == 'system' && !empty($oAlert->aExtras['module']) && in_array($sAction, $this->_aPaymentAlerts, true)) {
+            try {
+                $this->getRevenue()->onPaymentAlert($oAlert);
+            }
+            catch(Throwable $e) {
+                bx_log('sys_payments', 'bx_analytics: payment alert ' . $sAction . ' failed: ' . $e->getMessage(), BX_LOG_ERR);
+            }
+            return;
+        }
+
+        if($sUnit == 'profile' && $sAction == 'delete') {
+            $this->onProfileDelete($oAlert->iObject);
+            return;
+        }
+
+        if($sUnit == 'system' && in_array($sAction, ['enable', 'disable'], true)) {
+            if(empty($oAlert->aExtras['config']['name']) || $oAlert->aExtras['config']['name'] != $this->getName())
+                $this->serviceSyncActions();
+            return;
+        }
+
+        $aContext = ['api_key' => $this->_isApiKeyRequest()];
+        $iSender = (int)$oAlert->iSender;
+
+        switch($sAction) {
+            case 'add':
+                if($sUnit == 'account') {
+                    $this->getCollector()->collectAction('signup', 'account', $oAlert->iObject, 0, $aContext);
+                    return;
+                }
+                if($sUnit == 'profile') {
+                    $sType = isset($oAlert->aExtras['module']) ? $oAlert->aExtras['module'] : '';
+                    $this->getCollector()->collectAction('profile_add', $sType, $oAlert->iObject, $oAlert->iObject, $aContext);
+                }
+                return;
+
+            case 'login':
+                if($sUnit == 'account') {
+                    $oProfile = BxDolProfile::getInstanceAccountProfile($oAlert->iObject);
+                    $this->getCollector()->collectAction('login', 'account', $oAlert->iObject, $oProfile ? $oProfile->id() : 0, $aContext);
+                }
+                return;
+
+            case 'connection_added':
+                if($sUnit == 'system')
+                    $this->getCollector()->collectAction('connection', isset($oAlert->aExtras['object_name']) ? $oAlert->aExtras['object_name'] : '', isset($oAlert->aExtras['content']) ? (int)$oAlert->aExtras['content'] : 0, $iSender, $aContext);
+                return;
+
+            case 'added':
+                // comments of any system, or new content of a module
+                if($sUnit == 'comment')
+                    $this->getCollector()->collectAction('comment', isset($oAlert->aExtras['object_system']) ? $oAlert->aExtras['object_system'] : '', isset($oAlert->aExtras['object_id']) ? (int)$oAlert->aExtras['object_id'] : 0, $iSender, $aContext);
+                else
+                    $this->getCollector()->collectAction('content_add', $sUnit, $oAlert->iObject, $iSender, $aContext);
+                return;
+
+            case 'do':
+                if($sUnit == 'vote') {
+                    $aProps = [];
+                    if(!empty($oAlert->aExtras['reaction']) && is_string($oAlert->aExtras['reaction']))
+                        $aProps['reaction'] = $oAlert->aExtras['reaction'];
+                    $this->getCollector()->collectAction('vote', isset($oAlert->aExtras['object_system']) ? $oAlert->aExtras['object_system'] : '', isset($oAlert->aExtras['object_id']) ? (int)$oAlert->aExtras['object_id'] : 0, $iSender, $aContext + ['props' => $aProps]);
+                }
+                return;
+        }
+    }
+
+    /**
+     * Forget a deleted profile: its visits stay in reports as an anonymous visitor's. Its
+     * payments stay in the revenue ledger, as they stay in the payment module, but lose the
+     * visit and the traffic sources they were tied to.
+     */
+    public function onProfileDelete($iProfileId)
+    {
+        $iProfileId = (int)$iProfileId;
+        if(!$iProfileId)
+            return 0;
+
+        $iChanged = $this->_oDb->forgetProfile($iProfileId, random_bytes(16)) + $this->_oDb->forgetRevenueProfile($iProfileId);
+
+        // cached reports may still show the member's name and link
+        if($iChanged)
+            $this->cleanReportCache();
+
+        return $iChanged;
+    }
+
+    public function serviceGetOptionsPlatforms()
+    {
+        $aResult = [];
+        foreach($this->_oConfig->getPlatforms() as $sCode => $sTitle)
+            $aResult[$sCode] = _t($sTitle);
+
+        return $aResult;
+    }
+
+    public function serviceGetOptionsIdentity()
+    {
+        return [
+            BX_ANALYTICS_IDENTITY_COOKIELESS => _t('_bx_analytics_identity_cookieless'),
+            BX_ANALYTICS_IDENTITY_PERSISTENT => _t('_bx_analytics_identity_persistent'),
+        ];
+    }
+
+    public function serviceGetOptionsGeoSource()
+    {
+        return [
+            BX_ANALYTICS_GEO_NONE => _t('_bx_analytics_geo_none'),
+            BX_ANALYTICS_GEO_HEADERS => _t('_bx_analytics_geo_headers'),
+            BX_ANALYTICS_GEO_CLOUDFLARE => _t('_bx_analytics_geo_cloudflare'),
+            BX_ANALYTICS_GEO_VERCEL => _t('_bx_analytics_geo_vercel'),
+            BX_ANALYTICS_GEO_CLOUDFRONT => _t('_bx_analytics_geo_cloudfront'),
+        ];
+    }
+
+    public function getCollector()
+    {
+        bx_import('UserAgent', $this->_aModule);
+        bx_import('Source', $this->_aModule);
+        bx_import('Request', $this->_aModule);
+        bx_import('Errors', $this->_aModule);
+        bx_import('Vitals', $this->_aModule);
+        bx_import('Collector', $this->_aModule);
+
+        return new BxAnalyticsCollector($this);
+    }
+
+    public function getFilter($aParams)
+    {
+        bx_import('Filter', $this->_aModule);
+
+        return new BxAnalyticsFilter($this, $aParams);
+    }
+
+    /**
+     * The revenue ledger, for the current request.
+     */
+    public function getRevenue()
+    {
+        $this->getCollector(); // loads the request, user agent and collector classes
+        bx_import('Revenue', $this->_aModule);
+
+        return new BxAnalyticsRevenue($this);
+    }
+
+    public function getReports()
+    {
+        if($this->_oReports === null) {
+            bx_import('Errors', $this->_aModule);
+            bx_import('Vitals', $this->_aModule);
+            bx_import('Reports', $this->_aModule);
+            $this->_oReports = new BxAnalyticsReports($this);
+        }
+
+        return $this->_oReports;
+    }
+
+    /**
+     * Report data by name, or false for an unknown report or parameters it doesn't know.
+     */
+    public function getReportData($sReport, $aParams)
+    {
+        if(!$this->_isReportAllowed($sReport))
+            return false;
+
+        $oReports = $this->getReports();
+        $sMethod = 'get' . bx_gen_method_name($sReport);
+        if(!method_exists($oReports, $sMethod))
+            return false;
+
+        $oFilter = $this->getFilter($aParams);
+
+        // the same report with the same filters and parameters, for the same kind of viewer,
+        // comes from the cache for a few minutes; live figures never do
+        $sCache = $oReports->isLive($sReport, $aParams) ? '' : $this->_getReportCacheName($sReport, $oFilter, $aParams);
+        if($sCache !== '') {
+            $aCached = $this->_getReportCache($sCache);
+            if(is_array($aCached))
+                return $aCached;
+        }
+
+        $aData = $oReports->$sMethod($oFilter, $aParams);
+        if($aData === false)
+            return false;
+
+        $aData['filter'] = $oFilter->getParams();
+
+        if($sCache !== '')
+            $this->_setReportCache($sCache, $aData);
+
+        return $aData;
+    }
+
+    /**
+     * Drop every cached report, after a change that reports show (goals, funnels, the status
+     * of an error, a deleted member). Report names carry a generation, and a new one leaves
+     * the old copies to expire: Memcache and Memcached delete by prefix by emptying the whole
+     * server, and Redis by a KEYS scan of every key, so nothing is deleted by prefix there.
+     * Files are deleted too (keys of the database cache are `db_<name>_<site hash>`, see
+     * BxDolDb::genDbCacheKey, so the prefix is the name's start after `db_`).
+     */
+    public function cleanReportCache()
+    {
+        $oCache = $this->_getReportCacheEngine();
+        if(!$oCache)
+            return;
+
+        if($oCache instanceof BxDolCacheFileHtml)
+            $oCache->removeAllByPrefix('db_' . $this->_oConfig->CNF['REPORT_CACHE_PREFIX']);
+
+        $this->_sReportCacheGeneration = sprintf('%.6F', microtime(true));
+        $oCache->setData($this->_oDb->genDbCacheKey(self::REPORT_CACHE_GENERATION), $this->_sReportCacheGeneration, self::REPORT_CACHE_GENERATION_TTL);
+    }
+
+    /**
+     * The generation of cached reports: changed by cleanReportCache(), '' before the first.
+     */
+    protected function _getReportCacheGeneration()
+    {
+        if($this->_sReportCacheGeneration !== null)
+            return $this->_sReportCacheGeneration;
+
+        $oCache = $this->_getReportCacheEngine();
+        $sGeneration = $oCache ? $oCache->getData($this->_oDb->genDbCacheKey(self::REPORT_CACHE_GENERATION)) : '';
+
+        return $this->_sReportCacheGeneration = is_string($sGeneration) ? $sGeneration : '';
+    }
+
+    /**
+     * Delete cached reports past their time to live, in files: a file is deleted when its own
+     * report is read again, and most never are (yesterday's filters, a tab opened once).
+     * Other cache engines expire entries themselves.
+     *
+     * @param int $iNow the current time
+     * @return int files deleted
+     */
+    public function pruneReportCache($iNow)
+    {
+        $oCache = $this->_getReportCacheEngine();
+        if(!$oCache)
+            $oCache = $this->_oDb->getDbCacheObject();
+        if(!($oCache instanceof BxDolCacheFile) || !($rDir = opendir(BX_DIRECTORY_PATH_CACHE)))
+            return 0;
+
+        $sPrefix = 'db_' . $this->_oConfig->CNF['REPORT_CACHE_PREFIX'];
+        $iBefore = (int)$iNow - $this->_oConfig->CNF['REPORT_CACHE_TTL'];
+
+        $iDeleted = 0;
+        while(($sFile = readdir($rDir)) !== false)
+            if(bx_mb_strpos($sFile, $sPrefix) === 0 && @filemtime(BX_DIRECTORY_PATH_CACHE . $sFile) < $iBefore && $oCache->delData($sFile))
+                $iDeleted++;
+        closedir($rDir);
+
+        return $iDeleted;
+    }
+
+    /**
+     * The cache of reports: the database cache's engine, but with files as plain text. The
+     * file engine writes PHP scripts read with include(), and opcache would keep a compiled
+     * copy of every report (a new one for every filter, page and day) until it ran out of
+     * memory. Null when the database cache is off.
+     */
+    protected function _getReportCacheEngine()
+    {
+        if(!getParam('sys_db_cache_enable'))
+            return null;
+
+        $oCache = $this->_oDb->getDbCacheObject();
+
+        return $oCache instanceof BxDolCacheFile ? bx_instance('BxDolCacheFileHtml') : $oCache;
+    }
+
+    /**
+     * A cached report, as JSON after REPORT_CACHE_GUARD, or null.
+     */
+    protected function _getReportCache($sName)
+    {
+        $oCache = $this->_getReportCacheEngine();
+        if(!$oCache)
+            return null;
+
+        $sData = $oCache->getData($this->_oDb->genDbCacheKey($sName), $this->_oConfig->CNF['REPORT_CACHE_TTL']);
+        if(!is_string($sData) || bx_mb_strpos($sData, self::REPORT_CACHE_GUARD) !== 0)
+            return null;
+
+        $aData = json_decode(get_mb_substr($sData, get_mb_len(self::REPORT_CACHE_GUARD), get_mb_len($sData)), true);
+
+        return is_array($aData) ? $aData : null;
+    }
+
+    protected function _setReportCache($sName, $aData)
+    {
+        $oCache = $this->_getReportCacheEngine();
+        $sData = json_encode($aData, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRESERVE_ZERO_FRACTION);
+        if(!$oCache || $sData === false)
+            return false;
+
+        return $oCache->setData($this->_oDb->genDbCacheKey($sName), self::REPORT_CACHE_GUARD . $sData, $this->_oConfig->CNF['REPORT_CACHE_TTL']);
+    }
+
+    /**
+     * The cache name of a report: the report, the resolved filter (not the current second,
+     * so a cached copy serves the whole period), the parameters the report reads, the
+     * language, what the viewer may see (money, people, managing, administration), since
+     * reports differ by those, and the generation (see cleanReportCache).
+     */
+    protected function _getReportCacheName($sReport, $oFilter, $aParams)
+    {
+        $aFilter = $oFilter->get();
+        unset($aFilter['now'], $aFilter['buckets']);
+
+        $aOwn = [];
+        foreach($this->getReports()->getCacheParams($sReport, $aParams) as $sKey => $mixedValue)
+            if(is_string($sKey) && $sKey !== '' && $sKey[0] != '_')
+                $aOwn[$sKey] = $mixedValue;
+        ksort($aOwn);
+
+        $aViewer = [
+            isAdmin(),
+            $this->checkAllowedRevenue() === CHECK_ACTION_RESULT_ALLOWED,
+            $this->checkAllowedPeople() === CHECK_ACTION_RESULT_ALLOWED,
+            $this->checkAllowedManage() === CHECK_ACTION_RESULT_ALLOWED,
+        ];
+
+        return $this->_oConfig->CNF['REPORT_CACHE_PREFIX'] . hash('sha256', json_encode([$sReport, $aFilter, $aOwn, bx_lang_name(), $aViewer, $this->_getReportCacheGeneration()]));
+    }
+
+    /**
+     * A report's rows for a CSV download, behind the same checks as getReportData: header,
+     * rows, total, truncated, and filename (made of the report, the dimension and the
+     * period's dates only). False when the viewer may not use analytics or see the report,
+     * and for a report without a download. The use isn't counted: the action is countable,
+     * and reading a report again is not another use.
+     */
+    public function getReportExport($sReport, $aParams)
+    {
+        if(!$this->serviceIsAvaliable() || !in_array($sReport, $this->_aExportReports, true) || !$this->_isReportAllowed($sReport))
+            return false;
+
+        $oFilter = $this->getFilter($aParams);
+        $aExport = $this->getReports()->getExport($sReport, $oFilter, $aParams, $this->_oConfig->CNF['MAX_EXPORT_ROWS']);
+        if($aExport === false)
+            return false;
+
+        $aFilter = $oFilter->get();
+        bx_import('Csv', $this->_aModule);
+        $aExport['filename'] = BxAnalyticsCsv::getFilename([$sReport, $aExport['dimension'], $aFilter['from_date'], $aFilter['to_date']]);
+
+        return $aExport;
+    }
+
+    /**
+     * Whether a report exists and the viewer may see it: reports with members need "view
+     * analytics people", reports with money "view analytics revenue".
+     */
+    protected function _isReportAllowed($sReport)
+    {
+        if(!in_array($sReport, $this->_aReports, true))
+            return false;
+
+        if(in_array($sReport, $this->_aPeopleReports, true) && $this->checkAllowedPeople() !== CHECK_ACTION_RESULT_ALLOWED)
+            return false;
+
+        if(in_array($sReport, $this->_aRevenueReports, true) && $this->checkAllowedRevenue() !== CHECK_ACTION_RESULT_ALLOWED)
+            return false;
+
+        return true;
+    }
+
+    /**
+     * What an API block carries: the report and the endpoint NEO calls for its data.
+     */
+    protected function _getApiBlockData($sReport, $aParams = [])
+    {
+        $aRanges = [];
+        foreach(['today', 'yesterday', '24h', '7d', '30d', '90d', '12m'] as $sRange)
+            $aRanges[] = ['value' => $sRange, 'title' => _t('_bx_analytics_range_' . $sRange)];
+
+        $aPlatforms = [];
+        foreach($this->_oConfig->getPlatforms() as $sCode => $sTitle)
+            $aPlatforms[] = ['value' => $sCode, 'title' => _t($sTitle)];
+
+        $aMetrics = [];
+        foreach($this->getReports()->getMetrics() as $sMetric)
+            $aMetrics[$sMetric] = _t('_bx_analytics_metric_' . $sMetric);
+
+        return [
+            'report' => $sReport,
+            'params' => (object)$aParams,
+            // the element asks for its data itself, with the page's filters, from get_report
+            'request_url' => 'bx_analytics/get_report',
+            'ranges' => $aRanges,
+            'platforms' => $aPlatforms,
+            'metrics' => $aMetrics,
+            'strings' => [
+                'range' => _t('_bx_analytics_txt_range'),
+                'platform' => _t('_bx_analytics_txt_platform'),
+                'all_platforms' => _t('_bx_analytics_txt_all_platforms'),
+                'online' => _t('_bx_analytics_txt_online_now', '{0}'),
+                'remove_filter' => _t('_bx_analytics_txt_remove_filter', '{0}'),
+                'filter_by' => _t('_bx_analytics_txt_filter_by', '{0}'),
+                'previous_period' => _t('_bx_analytics_txt_previous_period', '{0}'),
+                'key_figures' => _t('_bx_analytics_txt_key_figures'),
+                'visitors' => _t('_bx_analytics_metric_visitors'),
+                'no_data' => _t('_bx_analytics_txt_no_data'),
+                // NEO shows a Retry button next to it, so no "reload the page"
+                'error' => _t('_bx_analytics_txt_error_report'),
+                'duration_h' => _t('_bx_analytics_txt_duration_h'),
+                'duration_m' => _t('_bx_analytics_txt_duration_m'),
+                'duration_s' => _t('_bx_analytics_txt_duration_s'),
+            ],
+        ];
+    }
+
+    public function isApiKeyRequest()
+    {
+        return $this->_isApiKeyRequest();
+    }
+
+    /**
+     * The request came through api.php with an API key that api.php has already checked:
+     * the NEO server, whose proxy headers carry the visitor's address.
+     */
+    protected function _isApiKeyRequest()
+    {
+        if(!defined('BX_API') || !getParam('sys_api_access_by_key'))
+            return false;
+
+        $sAuth = isset($_SERVER['HTTP_AUTHORIZATION']) ? $_SERVER['HTTP_AUTHORIZATION'] : '';
+        if($sAuth === '' && function_exists('getallheaders')) {
+            $aHeaders = array_change_key_case((array)getallheaders(), CASE_LOWER);
+            $sAuth = isset($aHeaders['authorization']) ? $aHeaders['authorization'] : '';
+        }
+
+        return strpos($sAuth, 'Bearer ') === 0;
+    }
+
+    /**
+    * Action methods
+    */
+          
+    public function actionGetReports($sModuleName)
+    {
+		if(!$this->serviceIsAvaliable())
+            return '';
+            
+        header('Content-Type: application/json');
+             
+        $aSystemsVote = BxDolVote::getSystems();
+        $aRv = array();
+        if ($sModuleName == BX_ANALYTICS_SYSTEM){
+            $aRv[BX_ANALYTICS_TOP_BY_LIKES] = _t('_bx_analytics_type_top_contents_by_likes');
+            $aRv[BX_ANALYTICS_TOP_BY_VIEWS] = _t('_bx_analytics_type_top_by_views');
+            $aRv[BX_ANALYTICS_CONTENT_TOTAL] = _t('_bx_analytics_type_accounts_growth');
+            $aRv[BX_ANALYTICS_CONTENT_SPEED] = _t('_bx_analytics_type_accounts_speed');
+        }
+        else{
+            $oModule = BxDolModule::getInstance($sModuleName);
+                  
+            if(BxDolRequest::serviceExists($sModuleName, 'act_as_profile') && BxDolService::call($sModuleName, 'act_as_profile') == true){
+                $aRv[BX_ANALYTICS_MOST_FOLLOWED_PROFILES] = _t('_bx_analytics_type_most_followed_profiles');
+                $aRv[BX_ANALYTICS_MOST_ACTIVE_PROFILES] = _t('_bx_analytics_type_most_active_profiles');
+            }
+                  
+            if(isset($oModule->_oConfig->CNF['OBJECT_VOTES']) && isset($aSystemsVote[$oModule->_oConfig->CNF['OBJECT_VOTES']])){
+                $aRv[BX_ANALYTICS_TOP_BY_LIKES] = _t('_bx_analytics_type_top_contents_by_likes');
+            }
+                  
+            if(isset($oModule->_oConfig->CNF['OBJECT_VIEWS'])){
+                $aRv[BX_ANALYTICS_TOP_BY_VIEWS] = _t('_bx_analytics_type_top_by_views');
+            }
+                  
+            $aRv[BX_ANALYTICS_CONTENT_TOTAL] = _t('_bx_analytics_type_content_growth');
+            $aRv[BX_ANALYTICS_CONTENT_SPEED] = _t('_bx_analytics_type_content_speed');
+        }
+        /**
+         * @hooks
+         * @hookdef hook-bx_analytics-get_reports 'bx_analytics', 'get_reports' - hook on get avalaible reports for specified module
+         * - $unit_name - equals `bx_analytics`
+         * - $action - equals `get_reports` 
+         * - $object_id - not used
+         * - $sender_id -not used
+         * - $extra_params - array of additional params with the following array keys:
+         *      - `module` - [string] module name
+         *      - `list` - [array] by ref, array of avalaible reports for analytics as [report_name] = report_title, can be overridden in hook processing
+         * @hook @ref hook-bx_analytics-get_reports
+         */
+        bx_alert($this->_aModule['name'], 'get_reports', 0, 0, array(
+            'module' => $sModuleName,
+            'list' => &$aRv,
+            'list_ref' => &$aRv,
+        ));
+        
+        echo json_encode($aRv);
+    }
+          
+    public function actionGetReportsData($sModuleName, $sReportName, $sDateFrom, $sDateTo, $sType = '')
+    {
+        if(!$this->serviceIsAvaliable())
+            return '';
+        
+        if ($sType == 'csv'){
+            header('Content-type: text/csv');
+            header('Content-disposition: attachment;filename=' . $sModuleName . '_' . $sReportName . '_' . $sDateFrom . '_' . $sDateTo . '.csv');
+            echo $this->actionGetReportsDataCsv($sModuleName, $sReportName, $sDateFrom, $sDateTo);
+            
+        }
+        
+        if ($sType == ''){
+            header('Content-Type: application/json');
+            echo $this->actionGetReportsDataJson($sModuleName, $sReportName, $sDateFrom, $sDateTo);
+        } 
+    }
+    
+    private function actionGetReportsDataCsv($sModuleName, $sReportName, $sDateFrom, $sDateTo)
+    {
+        $sRv = '';
+        $sColumn = ";";
+        $sRow = "\n";
+        $sData = $this->actionGetReportsDataJson($sModuleName, $sReportName, $sDateFrom, $sDateTo);
+        $oRv = json_decode($sData, true); 
+        $oData = $oRv['data'];
+        $sCol1 = $oRv['strings'][0];
+        $iColumnCount = count($oData['datasets']);
+        $sRv .= $sCol1 . $sColumn;
+        foreach($oData['datasets'] as $aItem){
+            $sRv .= ($aItem['label'] != '' ? $aItem['label'] : $oRv['strings'][1]) . $sColumn;
+        }
+   
+        $sRv .= $sRow;
+        $iK = 0;
+        foreach($oData['datasets'][0]['data'] as $sDx => $aItemData){
+            $sTxt = "";
+            if (is_array($oData['datasets'][0]['data'][$sDx]))
+                $sTxt = $oData['datasets'][0]['data'][$sDx]['x'];
+            else
+                $sTxt = $oData['labels'][$sDx];
+            if (isset($oRv['links']) && count($oRv['links'])>0) {
+                $sTxt .= ' (' . $oRv['links'][$sDx] . ')';
+            }
+            
+            $sRv .= $sTxt . $sColumn;
+            for ($i = 0; $i < $iColumnCount; $i++){
+                $sText = '';
+                if (is_array($aItemData)) {
+                    $sText = $aItemData['y'];
+                }
+                else {
+                    $sText = $aItemData;
+                }
+                $sRv .= $sText . $sColumn;
+            }
+            $sRv .= $sRow;
+            $iK++;
+        }
+        echo $sRv;
+    }
+    
+    private function actionGetReportsDataJson($sModuleName, $sReportName, $sDateFrom, $sDateTo)
+    { 
+        $iDateFrom = strtotime($sDateFrom);
+        $iDateTo = strtotime($sDateTo) + 86400;
+        $sType = "bar";
+        $bIsTimeX = false;
+        $iMinValueY = 0;
+        $iMaxValueY = 0;
+        $bDefaultFillData = true;
+        $sReportType = $sReportName;
+        
+        $aValues = array('labels' => array(), 'values' => array(array('legend' => '', 'data' => array())), 'links' => array(), 'strings' => array(0 => _t('_bx_analytics_txt_item'), 1 => _t('_bx_analytics_txt_value')));
+   
+        if ($sModuleName != BX_ANALYTICS_SYSTEM){
+            $oModule = BxDolModule::getInstance($sModuleName);
+            $aValues['strings'][0] = $oModule->_aModule['title'];
+            $aValues['strings'][1] = _t('_bx_analytics_type_' . $sReportType . '_label');
+        }
+        else{
+            $aValues['strings'][0] = _t('_bx_analytics_txt_total_content');
+            $aValues['strings'][1] = _t('_bx_analytics_type_' . $sReportType . '_label');
+        }
+              
+        switch ($sReportType) {
+            case BX_ANALYTICS_TOP_BY_LIKES:
+                $bDefaultFillData = false;
+                $this->getViewsOrVotes(BxDolVote::getSystems(), 'OBJECT_VOTES', $sModuleName, $iDateFrom, $iDateTo, $aValues, $iMaxValueY, $iMinValueY);
+                break;
+                  
+            case BX_ANALYTICS_TOP_BY_VIEWS:
+                $bDefaultFillData = false;
+                $this->getViewsOrVotes(BxDolView::getSystems(), 'OBJECT_VIEWS', $sModuleName, $iDateFrom, $iDateTo, $aValues, $iMaxValueY, $iMinValueY);
+                break;
+                  
+            case BX_ANALYTICS_MOST_FOLLOWED_PROFILES:
+                $oModule = BxDolModule::getInstance($sModuleName);
+                $aData = $this->_oDb->getMostFollowedProfiles($sModuleName, $iDateFrom, $iDateTo);
+                break;
+                  
+            case BX_ANALYTICS_MOST_ACTIVE_PROFILES:
+                $aData = array();
+                $oModule = BxDolModule::getInstance($sModuleName);
+                $aTmp = $this->getSelectedModules();
+                foreach($aTmp[0] as $sContentModule){
+                    $oContentModule = BxDolModule::getInstance($sContentModule);
+                    if ($oContentModule && isset($oContentModule->_oConfig->CNF['TABLE_ENTRIES']) && isset($oContentModule->_oConfig->CNF['FIELD_AUTHOR'])){
+                        $sContentTable = $oContentModule->_oConfig->CNF['TABLE_ENTRIES'];
+                        $sColumnAuthor = $oContentModule->_oConfig->CNF['FIELD_AUTHOR'];
+                        $sColumnAdded = $oContentModule->_oConfig->CNF['FIELD_ADDED'];
+                        if (!empty($sContentTable) && !empty($sColumnAuthor) && !empty($sColumnAdded) && (!BxDolRequest::serviceExists($sContentModule, 'act_as_profile') || BxDolService::call($sContentModule, 'act_as_profile') == false)){
+                            $aData1 = $this->_oDb->getMostActiveProfiles($sModuleName, $sContentModule, $sContentTable, $sColumnAuthor, $sColumnAdded, $iDateFrom, $iDateTo);
+                            foreach($aData1 as $aTmp3){
+                                if (array_key_exists($aTmp3['object_id'], $aData))
+                                    $aData[$aTmp3['object_id']]['value'] += $aTmp3['value'];
+                                else
+                                    $aData[$aTmp3['object_id']] = $aTmp3;
+                            }
+                        }
+                    }
+                }
+                $aData = $this->sortArrayOfArraysAndSlice($aData, 'value', intval(getParam('bx_analytics_items_count')));
+                break;
+                
+            default:
+                $sType = "line";
+                $sTableName = '';
+                $aValues['strings'][0] = _t('_bx_analytics_txt_date');
+                $bDefaultFillData = false;
+                if ($sModuleName != BX_ANALYTICS_SYSTEM){
+                    $oModule = BxDolModule::getInstance($sModuleName);
+                    if (isset($oModule->_oConfig->CNF['TABLE_ENTRIES']))
+                        $sTableName = $oModule->_oConfig->CNF['TABLE_ENTRIES'];
+                    $aValues['strings'][1] = ($sReportType == BX_ANALYTICS_CONTENT_TOTAL ? _t('_bx_analytics_txt_total_count', $oModule->_aModule['title']) : _t('_bx_analytics_txt_count', $oModule->_aModule['title']));
+                }
+                else{
+                    $sTableName = 'sys_accounts';
+                    $aValues['strings'][1] = ($sReportType == BX_ANALYTICS_CONTENT_TOTAL ? _t('_bx_analytics_txt_total_count', _t('_bx_analytics_txt_accounts')) : _t('_bx_analytics_txt_count', _t('_bx_analytics_txt_accounts')));
+                }
+                if (isset($oModule) && isset($oModule->_oConfig->CNF['FIELD_ADDED']))
+                    $sColumnAdded = $oModule->_oConfig->CNF['FIELD_ADDED'];
+                else
+                    $sColumnAdded = 'added';
+                
+                $aData = array();
+                $iValuePrev = 0;
+                
+                /**
+                 * @hooks
+                 * @hookdef hook-bx_analytics-get_chart_data_line 'bx_analytics', 'get_chart_data_line' - hook on get avalaible reports for specified module
+                 * - $unit_name - equals `bx_analytics`
+                 * - $action - equals `get_chart_data_line` 
+                 * - $object_id - not used
+                 * - $sender_id -not used
+                 * - $extra_params - array of additional params with the following array keys:
+                 *      - `module` - [string] module name
+                 *      - `report_name` - [string] report name
+                 *      - `report_type` - [string]  by ref, report name, can be overridden in hook processing
+                 *      - `date_from` - [int] report's start of interval in php time format
+                 *      - `date_to` - [int] report's end of interval in php time format
+                 *      - `data` - [array] by ref, array of data for chart, can be overridden in hook processing
+                 *      - `prev_value` - [int] by ref, previous value for chart (started value), can be overridden in hook processing
+                 * @hook @ref hook-bx_analytics-get_chart_data_line
+                 */
+                bx_alert($this->_aModule['name'], 'get_chart_data_line', 0, 0, array(
+                    'module' => $sModuleName,
+                    'report_name' => $sReportName,
+                    'report_type' => &$sReportType,
+                    'report_type_ref' => &$sReportType,
+                    'date_from' => $iDateFrom,
+                    'date_to' => $iDateTo,
+                    'data' => &$aData,
+                    'data_ref' => &$aData,
+                    'prev_value' => &$iValuePrev,
+                    'prev_value_ref' => &$iValuePrev,
+                ));
+                
+                
+                
+                if ($sColumnAdded != '' && count($aData) == 0){
+                    $aData = $this->_oDb->getGrowth($sTableName, $sColumnAdded, $iDateFrom, $iDateTo);
+                    $iValuePrev = $this->_oDb->getGrowthInitValue($sTableName, $sColumnAdded, $iDateFrom);
+                }
+                
+                if (count($aData) > 0){
+                    $aTmpDates = array();
+                    foreach ($aData as $aValue) {
+                        $sX = $aValue['period'];
+                        $aTmpDates[$sX] = $aValue['count'];
+                    }
+                    for ($i = $iDateFrom; $i < $iDateTo ; $i = $i + 86400 ){
+                        $sX = date('Y-m-d', $i);
+                        if (!array_key_exists($sX, $aTmpDates)){
+                            array_push($aValues['values'][0]['data'], array('x' => $sX, 'y' => $sReportType == BX_ANALYTICS_CONTENT_TOTAL ? $iValuePrev : 0));
+                        }
+                        else{
+                            array_push($aValues['values'][0]['data'], array('x' => $sX, 'y' => $sReportType == BX_ANALYTICS_CONTENT_TOTAL ? ($iValuePrev + $aTmpDates[$sX]) : $aTmpDates[$sX]));
+                            $iValuePrev += $aTmpDates[$sX];
+                        }
+                        //array_push($aValues['labels'], $sX);
+                        if ($sReportType == BX_ANALYTICS_CONTENT_TOTAL){
+                            if ($iValuePrev > $iMaxValueY)
+                                $iMaxValueY = $iValuePrev;
+                        }
+                        else{
+                            if (array_key_exists($sX, $aTmpDates) && $aTmpDates[$sX] > $iMaxValueY)
+                                $iMaxValueY = $aTmpDates[$sX];
+                        } 
+                    }
+                }
+                break;
+        }
+          
+        if ($bDefaultFillData){
+            foreach ($aData as $aValue) {
+                array_push($aValues['labels'], $this->getItemName($oModule->serviceGetTitle($aValue['object_id'])));
+                array_push($aValues['values'][0]['data'], $aValue['value']);
+                array_push($aValues['links'], $oModule->serviceGetLink($aValue['object_id']));
+                if ($aValue['value'] > $iMaxValueY)
+                    $iMaxValueY = $aValue['value'];
+            }
+        }
+        $iMaxValueY = ceil($iMaxValueY * 1.1);
+        
+        $aDataForChartXAxes = array();
+        if ($bIsTimeX){
+            $sUnit = 'day';
+            $iInterval = ($iDateTo - $iDateFrom) / 86400;
+            if ($iInterval > 50)
+                $sUnit = 'week';
+            if ($iInterval > 100)
+                $sUnit = 'month';
+            
+            $aDataForChartXAxes = array(
+                'type' => 'time',
+                'time' => array(
+                    'tooltipFormat' => 'DD.MM.YYYY',
+                    'unit' => $sUnit,
+                 ),
+                'display' => true,
+                'distribution' => 'linear',
+                'ticks' => array(
+                    'display' => true, 
+                    'autoSkip' => true, 
+                )
+            );
+        }
+        else{
+            $aDataForChartXAxes = array(
+                'ticks' => array(
+                    'autoSkip' => true
+                ),
+                'display' => true
+            );
+        }
+       
+        $aDataForChartDatasets = array();
+        for($i = 0; $i < count($aValues['values']); $i++){
+            $aDataForChartDatasets[] = array(
+                'label' => $aValues['values'][$i]['legend'],
+                'fill' => false,
+                'backgroundColor' => $this->aColors[$i],
+                'borderColor' => $this->aColors[$i],
+                'borderWidth' => 2,
+                'data' => $aValues['values'][$i]['data']
+            );
+        }
+            
+        $aDataForChart = array(
+            'type' => $sType,
+            'data' => array(
+                'labels' => $aValues['labels'], 
+                'datasets' => $aDataForChartDatasets
+            ),
+            'options' => array(
+                'legend' => array(
+                    'position' => 'bottom', 
+                    'display' => count($aValues['values']) == 1 ? false :true
+                ),
+                'elements' => array(
+                    'point'=> array(
+                        'radius' => 0
+                    )
+                ),
+                'scales' => array(
+                    'y' => array(
+                        'ticks' => array(
+                            'max' => $iMaxValueY, 
+                            'min' => $iMinValueY, 
+                            'stepSize' => $this->getStep($iMinValueY, $iMaxValueY), 
+                            'autoSkip' => true
+                        )
+                    ),
+                    'x' => $aDataForChartXAxes
+                )
+            ),
+            'links' => $aValues['links'],
+            'strings' => $aValues['strings'],
+        );
+        
+        return json_encode($aDataForChart);
+    }
+	
+    /**
+     * Who may define goals and funnels: site administrators, and levels with "manage analytics"
+     * besides "use analytics".
+     */
+    public function checkAllowedManage()
+    {
+        if(isAdmin())
+            return CHECK_ACTION_RESULT_ALLOWED;
+
+        if($this->checkAllowed() !== CHECK_ACTION_RESULT_ALLOWED)
+            return _t('_sys_txt_access_denied');
+
+        $aCheck = checkActionModule(bx_get_logged_profile_id(), 'manage analytics', $this->getName(), false);
+
+        return $aCheck[CHECK_ACTION_RESULT] === CHECK_ACTION_RESULT_ALLOWED ? CHECK_ACTION_RESULT_ALLOWED : _t('_sys_txt_access_denied');
+    }
+
+    /**
+     * Save or delete a goal: POST op (save, delete), id, name, type (page, event), pattern, csrf_token.
+     */
+    public function actionGoal()
+    {
+        $aResult = $this->_checkManageRequest();
+        if($aResult === true) {
+            $iId = (int)bx_get('id', 'post');
+            if(bx_get('op', 'post') == 'delete')
+                $aResult = ['ok' => $this->_oDb->deleteGoal($iId)];
+            else {
+                $aGoal = $this->_getMatchInput(bx_get('type', 'post'), bx_get('pattern', 'post'));
+                $sName = trim((string)bx_get('name', 'post'));
+                if($aGoal === false || $sName === '' || get_mb_len($sName) > 128)
+                    $aResult = ['error' => _t('_bx_analytics_txt_goal_invalid')];
+                else if(!$iId && $this->isGoalLimitReached())
+                    $aResult = ['error' => _t('_bx_analytics_txt_goal_limit', $this->_oConfig->CNF['MAX_GOALS'])];
+                else {
+                    $iId = $this->_oDb->saveGoal($iId, ['name' => $sName] + $aGoal);
+                    $aResult = ['ok' => (bool)$iId, 'id' => $iId];
+                }
+            }
+            $this->cleanReportCache();
+        }
+
+        $this->_outputJson($aResult);
+    }
+
+    /**
+     * Whether there are as many goals as there can be: the Goals report counts each one in
+     * both periods, a query per goal and period.
+     */
+    public function isGoalLimitReached()
+    {
+        return $this->_oDb->getGoalsCount() >= $this->_oConfig->CNF['MAX_GOALS'];
+    }
+
+    /**
+     * Save or delete a funnel: POST op, id, name, step_type[], step_pattern[], csrf_token.
+     */
+    public function actionFunnel()
+    {
+        $aResult = $this->_checkManageRequest();
+        if($aResult === true) {
+            $iId = (int)bx_get('id', 'post');
+            if(bx_get('op', 'post') == 'delete')
+                $aResult = ['ok' => $this->_oDb->deleteFunnel($iId), 'id' => 0];
+            else {
+                $aTypes = bx_get('step_type', 'post');
+                $aPatterns = bx_get('step_pattern', 'post');
+                $aSteps = [];
+                if(is_array($aTypes) && is_array($aPatterns))
+                    foreach(array_values($aTypes) as $i => $sType) {
+                        if(!isset($aPatterns[$i]) || trim((string)$aPatterns[$i]) === '')
+                            continue;
+                        $aStep = $this->_getMatchInput($sType, $aPatterns[$i]);
+                        if($aStep === false) {
+                            $aSteps = false;
+                            break;
+                        }
+                        $aSteps[] = $aStep;
+                    }
+
+                $sName = trim((string)bx_get('name', 'post'));
+                if($aSteps === false || count($aSteps) < 2 || count($aSteps) > $this->_oConfig->CNF['MAX_FUNNEL_STEPS'] || $sName === '' || get_mb_len($sName) > 128)
+                    $aResult = ['error' => _t('_bx_analytics_txt_funnel_invalid')];
+                else {
+                    $iId = $this->_oDb->saveFunnel($iId, $sName, $aSteps);
+                    $aResult = ['ok' => (bool)$iId, 'id' => $iId];
+                }
+            }
+            $this->cleanReportCache();
+        }
+
+        $this->_outputJson($aResult);
+    }
+
+    /**
+     * Mark an error group open, resolved or ignored: POST op (the status), id, csrf_token.
+     * An ignored error is left out of the lists but keeps counting; a resolved one opens
+     * again when it happens after that.
+     */
+    public function actionErrorStatus()
+    {
+        $aResult = $this->_checkManageRequest();
+        if($aResult === true) {
+            $sStatus = (string)bx_get('op', 'post');
+            $iId = (int)bx_get('id', 'post');
+            if(!in_array($sStatus, ['open', 'resolved', 'ignored'], true) || !$iId || !$this->_oDb->getErrorGroup($iId))
+                $aResult = ['error' => _t('_sys_request_page_not_found_cpt'), 'code' => 404];
+            else {
+                $aResult = ['ok' => $this->_oDb->setErrorStatus($iId, $sStatus, time()), 'id' => $iId];
+                $this->cleanReportCache();
+            }
+        }
+
+        $this->_outputJson($aResult);
+    }
+
+    /**
+     * A POST from a manager with a valid form token, or an error array.
+     */
+    protected function _checkManageRequest()
+    {
+        if($_SERVER['REQUEST_METHOD'] != 'POST' || $this->checkAllowedManage() !== CHECK_ACTION_RESULT_ALLOWED)
+            return ['error' => _t('_sys_txt_access_denied'), 'code' => 403];
+
+        if(!BxDolForm::isCsrfTokenValid((string)bx_get('csrf_token', 'post'), false))
+            return ['error' => _t('_sys_txt_access_denied'), 'code' => 403];
+
+        return true;
+    }
+
+    /**
+     * A goal or funnel step from form input: a page path (starting with / or *, * for any
+     * characters) or an event name (name, or name@module), or false.
+     */
+    protected function _getMatchInput($sType, $sPattern)
+    {
+        $sPattern = trim((string)$sPattern);
+        if($sPattern === '' || get_mb_len($sPattern) > 255)
+            return false;
+
+        if($sType == 'page')
+            return in_array(get_mb_substr($sPattern, 0, 1), ['/', '*'], true) ? ['type' => 'page', 'pattern' => $sPattern] : false;
+
+        if($sType == 'event')
+            return preg_match('/^[\p{L}\p{N} _.:\/-]{1,64}(@[a-z0-9_]{1,32})?$/u', $sPattern) ? ['type' => 'event', 'pattern' => $sPattern] : false;
+
+        return false;
+    }
+
+    protected function _outputJson($aResult)
+    {
+        header('Content-Type: application/json; charset=utf-8');
+        header('Cache-Control: no-store');
+        if(isset($aResult['code']))
+            http_response_code((int)$aResult['code']);
+
+        echo json_encode($aResult);
+        exit;
+    }
+
+    /**
+     * Reports that show members by name: sessions and users. Site administrators always
+     * see them; everyone else needs "view analytics people" besides "use analytics".
+     */
+    public function checkAllowedPeople()
+    {
+        if(isAdmin())
+            return CHECK_ACTION_RESULT_ALLOWED;
+
+        if($this->checkAllowed() !== CHECK_ACTION_RESULT_ALLOWED)
+            return _t('_sys_txt_access_denied');
+
+        $aCheck = checkActionModule(bx_get_logged_profile_id(), 'view analytics people', $this->getName(), false);
+
+        return $aCheck[CHECK_ACTION_RESULT] === CHECK_ACTION_RESULT_ALLOWED ? CHECK_ACTION_RESULT_ALLOWED : _t('_sys_txt_access_denied');
+    }
+
+    /**
+     * Reports that show money and buyers: revenue, subscriptions, sales, credits, and members'
+     * lifetime value. Site administrators always see them; everyone else needs "view analytics
+     * revenue" besides "use analytics".
+     */
+    public function checkAllowedRevenue()
+    {
+        if(isAdmin())
+            return CHECK_ACTION_RESULT_ALLOWED;
+
+        if($this->checkAllowed() !== CHECK_ACTION_RESULT_ALLOWED)
+            return _t('_sys_txt_access_denied');
+
+        $aCheck = checkActionModule(bx_get_logged_profile_id(), 'view analytics revenue', $this->getName(), false);
+
+        return $aCheck[CHECK_ACTION_RESULT] === CHECK_ACTION_RESULT_ALLOWED ? CHECK_ACTION_RESULT_ALLOWED : _t('_sys_txt_access_denied');
+    }
+
+    /**
+     * Import past payments from the payment module now: POST op (import, restart), csrf_token.
+     * Site administrators only. One step runs within a time limit; the hourly cron continues.
+     */
+    public function actionBackfill()
+    {
+        if(!isAdmin() || $_SERVER['REQUEST_METHOD'] != 'POST' || !BxDolForm::isCsrfTokenValid((string)bx_get('csrf_token', 'post'), false))
+            $this->_outputJson(['error' => _t('_sys_txt_access_denied'), 'code' => 403]);
+
+        $oRevenue = $this->getRevenue();
+        $aStatus = $oRevenue->getBackfillStatus();
+        if($aStatus['state'] == 'unavailable')
+            $this->_outputJson(['error' => _t('_bx_analytics_txt_revenue_no_payment')]);
+
+        if(bx_get('op', 'post') == 'restart' || $aStatus['state'] == 'done')
+            $oRevenue->restartBackfill();
+
+        $aResult = $oRevenue->backfill(time() + $this->_oConfig->CNF['BACKFILL_SECONDS']);
+
+        // cached revenue reports hold the old totals and the import's state
+        $this->cleanReportCache();
+
+        $this->_outputJson(['ok' => true] + $aResult);
+    }
+
+	public function checkAllowed($isPerformAction = false)
+    {
+        // site administrators always see analytics, whatever their profile's level
+        if(isAdmin())
+            return CHECK_ACTION_RESULT_ALLOWED;
+
+        $aCheck = checkActionModule(bx_get_logged_profile_id(), 'use analytics', $this->getName(), $isPerformAction);
+        if ($aCheck[CHECK_ACTION_RESULT] !== CHECK_ACTION_RESULT_ALLOWED)
+             return _t('_sys_txt_access_denied');
+        return CHECK_ACTION_RESULT_ALLOWED;
+    }
+          
+    public function getSelectedModules()
+    {
+        $aModulesList = $this->serviceGetModules();
+        $aModules = array_keys($aModulesList);
+        $sModulesDisabled = explode(',', getParam('bx_analytics_modules_disabled'));
+        return array(array_diff($aModules, $sModulesDisabled), $aModulesList);
+    }
+
+    private function getViewsOrVotes($aSystems, $sSystem, $sModuleName, $iDateFrom, $iDateTo, &$aValues, &$iMaxValueY, &$iMinValueY)
+    {
+        $aData = array();
+        if ($sModuleName != BX_ANALYTICS_SYSTEM){
+            $oModule = BxDolModule::getInstance($sModuleName);
+            $sSystem = $oModule->_oConfig->CNF['OBJECT_VIEWS'];
+            if(isset($aSystems[$sSystem]))
+                $aData = $this->_oDb->getTopContentByLikes($sModuleName, $aSystems[$sSystem]['table_track'], $iDateFrom, $iDateTo);
+        }
+        else{
+            $aTmp = $this->getSelectedModules();
+            foreach ($aTmp[0] as $sModule) {
+                if ($sModule != BX_ANALYTICS_SYSTEM){
+                    $oModule = BxDolModule::getInstance($sModule);
+                    if (isset($oModule->_oConfig->CNF['OBJECT_VIEWS'])){
+                        $sSystem = $oModule->_oConfig->CNF['OBJECT_VIEWS'];
+                        if(isset($aSystems[$sSystem])){
+                            $aData1 = $this->_oDb->getTopContentByLikes($sModule, $aSystems[$sSystem]['table_track'], $iDateFrom, $iDateTo);
+                            $aData = array_merge($aData, $aData1);
+                        }
+                    }
+                }
+            }
+            $aData = $this->sortArrayOfArraysAndSlice($aData, 'value', intval(getParam('bx_analytics_items_count')));
+        }
+        foreach ($aData as $aValue) {
+            $oModule = BxDolModule::getInstance($aValue['module']);
+            array_push($aValues['labels'], $this->getItemName($oModule->serviceGetTitle($aValue['object_id'])));
+            array_push($aValues['values'][0]['data'], $aValue['value']);
+            array_push($aValues['links'], $oModule->serviceGetLink($aValue['object_id']));
+            if ($aValue['value'] > $iMaxValueY)
+                $iMaxValueY = $aValue['value'];
+        }
+    }
+          
+    private function getStep($iMin, $iMax)
+    {
+        $iCount = 8;
+        return ceil(($iMax - $iMin) / $iCount);
+    }
+          
+    private function sortArrayOfArraysAndSlice($aArr, $sFld, $Count)
+    {
+        $aDataSort = array();
+        foreach ($aArr as $sKey => $oRow)
+        {
+            $aDataSort[$sKey] = $oRow[$sFld];
+        }
+        array_multisort($aDataSort, SORT_DESC, $aArr);
+        return array_slice($aArr, 0, $Count);
+    }
+          
+    private function getItemName($sString)
+    {
+        $sString = strip_tags($sString);
+        if ($sString == ''){
+            $sString = _t('_bx_analytics_txt_empty_title_item');
+        }
+        return $sString;
+    }
+}
+
+/** @} */

+ 31 - 0
modules/boonex/analytics/updates/15.0.1_15.0.2/source/classes/BxAnalyticsPage.php

@@ -0,0 +1,31 @@
+<?php defined('BX_DOL') or die('hack attempt');
+/**
+ * Copyright (c) UNA, Inc - https://una.io
+ * MIT License - https://opensource.org/licenses/MIT
+ *
+ * @defgroup    Analytics Analytics
+ * @ingroup     UnaModules
+ *
+ * @{
+ */
+
+class BxAnalyticsPage extends BxTemplPage
+{
+    protected $MODULE;
+
+    public function __construct($aObject, $oTemplate = false)
+    {
+        $this->MODULE = 'bx_analytics';
+        parent::__construct($aObject, $oTemplate);
+        $oMenuSubmenu = BxDolMenu::getObjectInstance('sys_site_submenu');
+        if($oMenuSubmenu) {
+            $sMenuSubmenu = 'sys_account_dashboard';
+            $oMenuSubmenu->setObjectSubmenu($sMenuSubmenu, array('title' => _t('_sys_menu_item_title_account_dashboard'), 'link' => '', 'icon' => ''));
+            // the item's name in sys_account_dashboard: the Revenue page has its own, named like its URI
+            $sItem = isset($aObject['uri']) && $aObject['uri'] == 'analytics-revenue' ? 'analytics-revenue' : 'dashboard-analytics';
+            BxDolMenu::getObjectInstance($sMenuSubmenu)->setSelected($this->MODULE, $sItem);
+        }
+    }
+}
+
+/** @} */

+ 3009 - 0
modules/boonex/analytics/updates/15.0.1_15.0.2/source/classes/BxAnalyticsReports.php

@@ -0,0 +1,3009 @@
+<?php defined('BX_DOL') or die('hack attempt');
+/**
+ * Copyright (c) UNA, Inc - https://una.io
+ * MIT License - https://opensource.org/licenses/MIT
+ *
+ * @defgroup    Analytics Analytics
+ * @ingroup     UnaModules
+ *
+ * @{
+ */
+
+/**
+ * Report data as plain arrays. The classic templates, Studio and the NEO elements all render
+ * from these, so a number means the same thing everywhere.
+ *
+ * Every report is a method get<Report>(BxAnalyticsFilter $oFilter, array $aParams) that returns
+ * an array, or false when the parameters name nothing it knows.
+ */
+class BxAnalyticsReports
+{
+    /**
+     * Tabbed top lists: group => [tab => dimension]. A block shows one group.
+     */
+    protected $_aGroups = [
+        'pages' => ['pages' => 'path', 'titles' => 'title', 'entry' => 'entry_path', 'exit' => 'exit_path', 'hostnames' => 'hostname'],
+        'sources' => ['referrers' => 'referrer_host', 'channels' => 'channel', 'utm_source' => 'utm_source', 'utm_medium' => 'utm_medium', 'utm_campaign' => 'utm_campaign'],
+        'locations' => ['countries' => 'country', 'regions' => 'region', 'cities' => 'city', 'languages' => 'language'],
+        'devices' => ['browsers' => 'browser', 'devices' => 'device', 'os' => 'os', 'screens' => 'screen'],
+        'platforms' => ['platforms' => 'platform', 'apps' => 'app', 'app_versions' => 'app_version'],
+    ];
+
+    /**
+     * Full tables with every metric: table => [tab => dimension].
+     */
+    protected $_aTables = [
+        'pages' => ['pages' => 'path', 'titles' => 'title', 'entry' => 'entry_path', 'exit' => 'exit_path', 'hostnames' => 'hostname'],
+        'sources' => ['referrers' => 'referrer_host', 'referrer_urls' => 'referrer', 'channels' => 'channel', 'utm_source' => 'utm_source', 'utm_medium' => 'utm_medium', 'utm_campaign' => 'utm_campaign', 'utm_term' => 'utm_term', 'utm_content' => 'utm_content'],
+    ];
+
+    protected $_aMetrics = ['visitors', 'sessions', 'pageviews', 'pages_per_session', 'bounce_rate', 'duration'];
+
+    /**
+     * Tabs of the other reports: report => [tab => what the tab shows]. The first is the default.
+     * - map: the period's visitors or those online now
+     * - events: event types
+     * - retention: whether only members count (cohorts of member visitors)
+     * - errors: statuses (`new` is open or resolved, first seen in the period)
+     * - performance: pages grouped by route or by path
+     * - revenue: splits as columns of the ledger
+     */
+    protected $_aReportTabs = [
+        'map' => ['period' => 'period', 'live' => 'live'],
+        // set in the constructor: the hit types are constants of the config, which the module loads on first use
+        'events' => [],
+        'retention' => ['members' => true, 'visitors' => false],
+        'errors' => [
+            'open' => ['open'],
+            'new' => ['open', 'resolved'],
+            'resolved' => ['resolved'],
+            'ignored' => ['ignored'],
+            'all' => ['open', 'resolved', 'ignored'],
+        ],
+        'performance' => ['route' => 'route', 'path' => 'path'],
+        'revenue' => ['sources' => 'module', 'providers' => 'provider', 'platforms' => 'platform', 'first_touch' => 'first_channel', 'last_touch' => 'last_channel', 'campaigns' => 'last_utm_campaign'],
+    ];
+
+    protected $_iPerPage = 25;
+
+    protected $_oModule;
+    protected $_oDb;
+    protected $_oConfig;
+
+    protected $_aCountries;
+    protected $_aModuleTitles;
+
+    public function __construct($oModule)
+    {
+        $this->_oModule = $oModule;
+        $this->_oDb = $oModule->_oDb;
+        $this->_oConfig = $oModule->_oConfig;
+
+        $this->_aReportTabs['events'] = ['all' => [BX_ANALYTICS_HIT_EVENT, BX_ANALYTICS_HIT_UNA], 'custom' => [BX_ANALYTICS_HIT_EVENT], 'site' => [BX_ANALYTICS_HIT_UNA]];
+
+        $this->_oDb->setReportSession();
+    }
+
+    /**
+     * Whether a report shows live figures (visitors online, the last minutes), which are
+     * never cached.
+     */
+    public function isLive($sReport, $aParams = [])
+    {
+        if(in_array($sReport, ['realtime', 'filters'], true))
+            return true;
+
+        return $sReport == 'map' && $this->_getTab($aParams, 'map', $this->_aReportTabs['map']) == 'live';
+    }
+
+    /**
+     * The parameters a report reads, past the filter: its cache name is made of them. Blocks
+     * send the whole page URL, which also holds other blocks' tabs and pages, the chart's
+     * metric and opened rows; those would make a copy per combination of things the report
+     * never shows. A report missing here keeps every parameter.
+     */
+    public function getCacheParams($sReport, $aParams)
+    {
+        $aKeys = [
+            'overview' => [],
+            'top' => ['group', 'tab'],
+            'table' => ['table', 'tab', 'page'],
+            'query' => ['q_dim', 'q_metric', 'q_dir', 'page', 'page_query'],
+            'map' => ['tab', 'tab_map'],
+            'bots' => ['tab', 'tab_bots', 'page_bot_list'],
+            'events' => ['event', 'tab', 'tab_events', 'page_events'],
+            'sessions' => ['session', 'page_sessions'],
+            'users' => ['user', 'session', 'page_users', 'page_sessions'],
+            'journeys' => ['jfrom', 'via'],
+            'retention' => ['tab', 'tab_retention'],
+            'goals' => ['edit_goal'],
+            'funnels' => ['funnel', 'edit_funnel'],
+            'revenue' => ['tab', 'tab_revenue'],
+            'subscriptions' => [],
+            'sales' => [],
+            'credits' => [],
+            'errors' => ['error', 'tab', 'tab_errors', 'page_errors'],
+            'performance' => ['vital', 'vital_device', 'tab', 'tab_performance', 'page_performance'],
+        ];
+        if(!isset($aKeys[$sReport]))
+            return $aParams;
+
+        // a top list's tab, and a table's tab and page, are named after the group or the table
+        $aOwn = array_merge($aKeys[$sReport], ['limit', 'meta']);
+        if($sReport == 'top' && ($sGroup = (string)$this->_param($aParams, 'group')) !== '')
+            $aOwn[] = 'tab_' . $sGroup;
+        if($sReport == 'table' && ($sTable = (string)$this->_param($aParams, 'table')) !== '')
+            array_push($aOwn, 'tab_' . $sTable, 'page_' . $sTable);
+
+        return array_intersect_key($aParams, array_flip($aOwn));
+    }
+
+    public function getGroups()
+    {
+        return $this->_aGroups;
+    }
+
+    public function isGroup($sGroup)
+    {
+        return isset($this->_aGroups[$sGroup]);
+    }
+
+    public function getMetrics()
+    {
+        return $this->_aMetrics;
+    }
+
+    /**
+     * What get_report accepts, for the API playground: the filter parameters every report
+     * reads, and each report's own. A parameter is [type, values, min, max, default] with
+     * type enum (one of values), enum_by (one of values[the value of the parameter `by`]),
+     * int (min to max), date (YYYY-MM-DD), string, or dims (an object of dimension => value,
+     * the dimensions in values).
+     *
+     * @return array common => parameters, reports => [report => parameters]
+     */
+    public function getParamsSchema()
+    {
+        $fEnum = function($aValues, $sDefault = null) {
+            return ['type' => 'enum', 'values' => array_values($aValues), 'default' => $sDefault !== null ? $sDefault : reset($aValues)];
+        };
+        $fInt = function($iMin, $iMax = null, $iDefault = null) {
+            return array_filter(['type' => 'int', 'min' => $iMin, 'max' => $iMax, 'default' => $iDefault], function($m) { return $m !== null; });
+        };
+        $fString = function() {
+            return ['type' => 'string'];
+        };
+
+        // a tab is one of its group's or table's: group or table => tabs
+        $fTabs = function($sBy, $aLists) {
+            return ['type' => 'enum_by', 'by' => $sBy, 'values' => array_map('array_keys', $aLists)];
+        };
+
+        $aDimensions = array_keys($this->_oDb->getDimensions());
+        $aQueryMetrics = [];
+        foreach($aDimensions as $sDimension)
+            $aQueryMetrics = array_merge($aQueryMetrics, $this->_oDb->getBreakdownMetrics($sDimension));
+
+        bx_import('Filter', $this->_oModule->_aModule);
+
+        return [
+            'common' => [
+                'range' => $fEnum(BxAnalyticsFilter::$aRanges, BxAnalyticsFilter::RANGE_DEFAULT),
+                'from' => ['type' => 'date'],
+                'to' => ['type' => 'date'],
+                'tz' => ['type' => 'string', 'default' => 'UTC'],
+                'platform' => ['type' => 'enum', 'values' => array_keys($this->_oConfig->getPlatforms())],
+                'bucket' => ['type' => 'enum', 'values' => ['hour', 'day', 'week', 'month']],
+                'f' => ['type' => 'dims', 'values' => array_values(array_diff($aDimensions, ['platform']))],
+            ],
+            'reports' => [
+                'filters' => [],
+                'overview' => [],
+                'top' => ['group' => $fEnum(array_keys($this->_aGroups)), 'tab' => $fTabs('group', $this->_aGroups), 'limit' => $fInt(1, 100, 10)],
+                'table' => ['table' => $fEnum(array_keys($this->_aTables)), 'tab' => $fTabs('table', $this->_aTables), 'page' => $fInt(1)],
+                'realtime' => ['limit' => $fInt(0, 50, 20)],
+                'map' => ['tab' => $fEnum(array_keys($this->_aReportTabs['map']))],
+                'bots' => ['tab' => $fEnum(array_keys($this->_getBotCategories())), 'page_bot_list' => $fInt(1)],
+                'events' => ['event' => $fString(), 'tab' => $fEnum(array_keys($this->_aReportTabs['events'])), 'page_events' => $fInt(1)],
+                'sessions' => ['session' => $fInt(1), 'page_sessions' => $fInt(1)],
+                'users' => ['user' => $fInt(1), 'session' => $fInt(1), 'page_users' => $fInt(1)],
+                'journeys' => ['jfrom' => $fString(), 'via' => $fString()],
+                'retention' => ['tab' => $fEnum(array_keys($this->_aReportTabs['retention']))],
+                'goals' => [],
+                'funnels' => ['funnel' => $fInt(1)],
+                'revenue' => ['tab' => $fEnum(array_keys($this->_aReportTabs['revenue']))],
+                'subscriptions' => [],
+                'sales' => [],
+                'credits' => [],
+                'errors' => ['error' => $fInt(1), 'tab' => $fEnum(array_keys($this->_aReportTabs['errors'])), 'page_errors' => $fInt(1)],
+                'performance' => ['vital' => $fEnum(BxAnalyticsVitals::METRICS), 'vital_device' => $fEnum(['all', 'mobile', 'desktop']), 'tab' => $fEnum(array_keys($this->_aReportTabs['performance'])), 'page_performance' => $fInt(1)],
+                'query' => ['q_dim' => $fEnum($aDimensions), 'q_metric' => $fEnum(array_unique($aQueryMetrics)), 'q_dir' => $fEnum(['desc', 'asc']), 'page_query' => $fInt(1)],
+            ],
+        ];
+    }
+
+    /**
+     * Bot tabs: tab => bot categories (none for all).
+     */
+    protected function _getBotCategories()
+    {
+        bx_import('UserAgent', $this->_oModule->_aModule);
+
+        return [
+            'all' => [],
+            'ai' => [BxAnalyticsUserAgent::BOT_AI_CRAWLER, BxAnalyticsUserAgent::BOT_AI_AGENT],
+            'search' => [BxAnalyticsUserAgent::BOT_SEARCH],
+            'social' => [BxAnalyticsUserAgent::BOT_SOCIAL],
+            'seo' => [BxAnalyticsUserAgent::BOT_SEO],
+            'monitor' => [BxAnalyticsUserAgent::BOT_MONITOR],
+            'script' => [BxAnalyticsUserAgent::BOT_SCRIPT, BxAnalyticsUserAgent::BOT_FEED, BxAnalyticsUserAgent::BOT_OTHER],
+        ];
+    }
+
+    /**
+     * The filter bar: active dimension filters with readable values, and visitors online.
+     */
+    public function getFilters(BxAnalyticsFilter $oFilter, $aParams = [])
+    {
+        $aFilter = $oFilter->get();
+
+        $aChips = [];
+        foreach($aFilter['dims'] as $sName => $sValue)
+            $aChips[] = [
+                'name' => $sName,
+                'value' => $sValue,
+                'title' => _t('_bx_analytics_dim_' . $sName),
+                'label' => $this->getLabel($sName, $sValue),
+            ];
+
+        $aOnline = $this->_oDb->getOnline(time() - $this->_oConfig->getOnlineWindow());
+
+        return [
+            'chips' => $aChips,
+            'online' => $aFilter['platform'] !== '' ? (isset($aOnline[$aFilter['platform']]) ? (int)$aOnline[$aFilter['platform']] : 0) : (int)array_sum($aOnline),
+            'window' => (int)($this->_oConfig->getOnlineWindow() / 60),
+        ];
+    }
+
+    /**
+     * KPIs with the change against the previous period, and the series for the chart.
+     */
+    public function getOverview(BxAnalyticsFilter $oFilter, $aParams = [])
+    {
+        $aFilter = $oFilter->get();
+
+        $aCurrent = $this->_getKpis($this->_oDb->getTotals($aFilter));
+        $aPrevious = $this->_getKpis($this->_oDb->getTotals($oFilter->getPrevious()));
+
+        $aKpis = [];
+        foreach($this->_aMetrics as $sMetric)
+            $aKpis[$sMetric] = [
+                'value' => $aCurrent[$sMetric],
+                'previous' => $aPrevious[$sMetric],
+                'change' => $this->_change($aCurrent[$sMetric], $aPrevious[$sMetric]),
+            ];
+
+        $aRows = $this->_oDb->getSeries($aFilter, $aFilter['bucket'], $aFilter['offset'], $aFilter['starts']);
+
+        // the bucket that holds "now" is still filling up, and the chart draws it dashed
+        $aSeries = [];
+        $aBuckets = $aFilter['buckets'];
+        foreach($aBuckets as $i => $iBucket) {
+            $iNext = isset($aBuckets[$i + 1]) ? $aBuckets[$i + 1] : $aFilter['to'];
+            $aKpi = $this->_getKpis(isset($aRows[$iBucket]) ? $aRows[$iBucket] : []);
+            $aSeries[] = array_merge([
+                't' => $iBucket,
+                'future' => $iBucket > $aFilter['now'],
+                'partial' => $iBucket <= $aFilter['now'] && $aFilter['now'] < $iNext,
+            ], $aKpi);
+        }
+
+        return [
+            'kpis' => $aKpis,
+            'series' => $aSeries,
+            'bucket' => $aFilter['bucket'],
+            'from' => $aFilter['from'],
+            'to' => $aFilter['to'],
+            'tz' => $aFilter['tz'],
+        ];
+    }
+
+    /**
+     * One tab of a top list, with labels, shares and the filter each row applies when clicked.
+     * Params: group, tab (or tab_<group>), limit.
+     */
+    public function getTop(BxAnalyticsFilter $oFilter, $aParams = [])
+    {
+        $sGroup = $this->_param($aParams, 'group');
+        if(!isset($this->_aGroups[$sGroup]))
+            return false;
+
+        $sTab = $this->_getTab($aParams, $sGroup, $this->_aGroups[$sGroup]);
+        $sDimension = $this->_aGroups[$sGroup][$sTab];
+        $iLimit = isset($aParams['limit']) ? max(1, min(100, (int)$aParams['limit'])) : 10;
+
+        $aRows = $this->_oDb->getTop($oFilter->get(), $sDimension, $iLimit);
+
+        $iMax = 0;
+        $iTotal = 0;
+        foreach($aRows as $aRow) {
+            $iMax = max($iMax, (int)$aRow['visitors']);
+            $iTotal += (int)$aRow['visitors'];
+        }
+
+        $aItems = [];
+        foreach($aRows as $aRow)
+            $aItems[] = [
+                'value' => (string)$aRow['value'],
+                'label' => $this->getLabel($sDimension, (string)$aRow['value']),
+                'visitors' => (int)$aRow['visitors'],
+                'pageviews' => (int)$aRow['pageviews'],
+                'share' => $iTotal ? round(100 * $aRow['visitors'] / $iTotal, 1) : 0,
+                'bar' => $iMax ? round(100 * $aRow['visitors'] / $iMax, 1) : 0,
+            ];
+
+        return [
+            'group' => $sGroup,
+            'tab' => $sTab,
+            'tabs' => $this->_getTabs($this->_aGroups[$sGroup], $sTab),
+            'dimension' => $sDimension,
+            'column' => _t('_bx_analytics_tab_' . $sTab),
+            'items' => $aItems,
+        ];
+    }
+
+    /**
+     * A full table of a dimension with every metric, a page at a time.
+     * Params: table (pages, sources), tab (or tab_<table>), page (or page_<table>).
+     */
+    public function getTable(BxAnalyticsFilter $oFilter, $aParams = [])
+    {
+        $sTable = $this->_param($aParams, 'table');
+        if(!isset($this->_aTables[$sTable]))
+            return false;
+
+        $sTab = $this->_getTab($aParams, $sTable, $this->_aTables[$sTable]);
+        $sDimension = $this->_aTables[$sTable][$sTab];
+        $aFilter = $oFilter->get();
+
+        $iTotal = $this->_oDb->getTopCount($aFilter, $sDimension);
+        $iPages = max(1, (int)ceil($iTotal / $this->_iPerPage));
+        // page_<table> first: in Studio `page` names the section
+        $iPage = min($iPages, max(1, (int)$this->_param($aParams, 'page_' . $sTable, $this->_param($aParams, 'page', 1))));
+        $iStart = ($iPage - 1) * $this->_iPerPage;
+
+        if($this->_oDb->isEventDimension($sDimension)) {
+            $aRows = $this->_oDb->getPageStats($aFilter, $sDimension, $this->_iPerPage, $iStart);
+            $aColumns = [
+                ['key' => 'label', 'type' => 'label', 'title' => _t('_bx_analytics_tab_' . $sTab)],
+                ['key' => 'visitors', 'type' => 'number', 'title' => _t('_bx_analytics_metric_visitors')],
+                ['key' => 'pageviews', 'type' => 'number', 'title' => _t('_bx_analytics_metric_pageviews')],
+                ['key' => 'engaged', 'type' => 'engaged', 'title' => _t('_bx_analytics_metric_engaged')],
+            ];
+            if($sDimension == 'path')
+                $aColumns = array_merge($aColumns, [
+                    ['key' => 'entries', 'type' => 'number', 'title' => _t('_bx_analytics_metric_entries')],
+                    ['key' => 'bounce_rate', 'type' => 'percent', 'title' => _t('_bx_analytics_metric_bounce_rate')],
+                    ['key' => 'exits', 'type' => 'number', 'title' => _t('_bx_analytics_metric_exits')],
+                ]);
+        }
+        else {
+            $aRows = [];
+            foreach($this->_oDb->getTop($aFilter, $sDimension, $this->_iPerPage, $iStart) as $aRow)
+                $aRows[] = array_merge($aRow, $this->_getKpis($aRow));
+
+            $aColumns = [
+                ['key' => 'label', 'type' => 'label', 'title' => _t('_bx_analytics_tab_' . $sTab)],
+                ['key' => 'visitors', 'type' => 'number', 'title' => _t('_bx_analytics_metric_visitors')],
+                ['key' => 'sessions', 'type' => 'number', 'title' => _t('_bx_analytics_metric_sessions')],
+                ['key' => 'bounce_rate', 'type' => 'percent', 'title' => _t('_bx_analytics_metric_bounce_rate')],
+                ['key' => 'pages_per_session', 'type' => 'decimal', 'title' => _t('_bx_analytics_metric_pages_per_session')],
+                ['key' => 'duration', 'type' => 'duration', 'title' => _t('_bx_analytics_metric_duration')],
+            ];
+        }
+
+        $iMax = 0;
+        foreach($aRows as $aRow)
+            $iMax = max($iMax, (int)$aRow['visitors']);
+
+        $aResult = [];
+        foreach($aRows as $aRow)
+            $aResult[] = [
+                'label' => $this->getLabel($sDimension, (string)$aRow['value']),
+                'filter' => ['dim' => $sDimension, 'value' => (string)$aRow['value']],
+                'bar' => $iMax ? round(100 * $aRow['visitors'] / $iMax, 1) : 0,
+                'values' => $aRow,
+            ];
+
+        return [
+            'name' => $sTable,
+            'caption' => _t('_bx_analytics_group_' . $sTable),
+            'tab' => $sTab,
+            'tabs' => $this->_getTabs($this->_aTables[$sTable], $sTab),
+            'dimension' => $sDimension,
+            'columns' => $aColumns,
+            'rows' => $aResult,
+            'page' => $iPage,
+            'pages' => $iPages,
+            'total' => $iTotal,
+        ];
+    }
+
+    /**
+     * Explore any dimension: its values sorted by a metric, a page at a time, and the first
+     * few of them over time. Same shape as a table, so it renders and pages like one.
+     * Params: q_dim, q_metric, q_dir (see getQueryParams), page (or page_query).
+     */
+    public function getQuery(BxAnalyticsFilter $oFilter, $aParams = [])
+    {
+        $aQuery = $this->getQueryParams($aParams);
+        $sDimension = $aQuery['dimension'];
+        $sMetric = $aQuery['metric'];
+        $aFilter = $oFilter->get();
+
+        $iTotal = $this->_oDb->getTopCount($aFilter, $sDimension);
+        $iPages = max(1, (int)ceil($iTotal / $this->_iPerPage));
+        // page_query first: in Studio `page` names the section
+        $iPage = min($iPages, max(1, (int)$this->_param($aParams, 'page_query', $this->_param($aParams, 'page', 1))));
+        $iStart = ($iPage - 1) * $this->_iPerPage;
+
+        $aRows = $this->_getQueryRows($aFilter, $aQuery, $this->_iPerPage, $iStart);
+
+        // the chart follows the first values in this order, whatever page the table shows
+        $iSeries = (int)$this->_oConfig->CNF['QUERY_SERIES_VALUES'];
+        $aFirst = $iStart == 0 ? array_slice($aRows, 0, $iSeries) : $this->_getQueryRows($aFilter, $aQuery, $iSeries, 0, $iSeries, false);
+
+        $fMax = 0;
+        foreach($aRows as $aRow)
+            $fMax = max($fMax, (float)$aRow[$sMetric]);
+
+        $aResult = [];
+        foreach($aRows as $aRow)
+            $aResult[] = [
+                'label' => $this->getLabel($sDimension, $aRow['value']),
+                'filter' => ['dim' => $sDimension, 'value' => $aRow['value']],
+                'bar' => $fMax > 0 ? round(100 * $aRow[$sMetric] / $fMax, 1) : 0,
+                'values' => $aRow,
+            ];
+
+        $aDimensions = [];
+        foreach($this->_oDb->getDimensions() as $sName => $sKind)
+            $aDimensions[] = ['name' => $sName, 'title' => _t('_bx_analytics_dim_' . $sName), 'kind' => $sKind];
+
+        $aMetrics = [];
+        foreach($aQuery['metrics'] as $sName)
+            $aMetrics[] = ['name' => $sName, 'title' => _t('_bx_analytics_metric_' . $sName)];
+
+        return [
+            'name' => 'query',
+            'caption' => _t('_bx_analytics_dim_' . $sDimension),
+            'dimension' => $sDimension,
+            'kind' => $aQuery['kind'],
+            'metric' => $sMetric,
+            'dir' => $aQuery['dir'],
+            'dimensions' => $aDimensions,
+            'metrics' => $aMetrics,
+            'tabs' => [],
+            'columns' => $this->_getQueryColumns($aQuery),
+            'rows' => $aResult,
+            'page' => $iPage,
+            'pages' => $iPages,
+            'total' => $iTotal,
+            'export_max' => (int)$this->_oConfig->CNF['MAX_EXPORT_ROWS'],
+            'chart' => $this->_getQuerySeries($aFilter, $aQuery, $aFirst),
+        ];
+    }
+
+    /**
+     * What a query asks for, from q_dim (a dimension, see BxAnalyticsDb::getDimensions),
+     * q_metric (one the dimension can be sorted by) and q_dir (asc or desc). Anything unknown
+     * falls back to the first valid choice: pages, by visitors, highest first.
+     *
+     * @return array dimension, kind (event or session), metric, metrics (the dimension's
+     *      sortable metrics), dir
+     */
+    public function getQueryParams($aParams)
+    {
+        $aDimensions = $this->_oDb->getDimensions();
+        $sDimension = (string)$this->_param($aParams, 'q_dim');
+        if(!isset($aDimensions[$sDimension]))
+            $sDimension = key($aDimensions);
+
+        $aMetrics = $this->_oDb->getBreakdownMetrics($sDimension);
+        $sMetric = (string)$this->_param($aParams, 'q_metric');
+        if(!in_array($sMetric, $aMetrics, true))
+            $sMetric = $aMetrics[0];
+
+        return [
+            'dimension' => $sDimension,
+            'kind' => $aDimensions[$sDimension],
+            'metric' => $sMetric,
+            'metrics' => $aMetrics,
+            'dir' => $this->_param($aParams, 'q_dir') === 'asc' ? 'asc' : 'desc',
+        ];
+    }
+
+    /**
+     * Every value of a report's dimension with its metrics, for a CSV download: the query,
+     * or a table or top list tab (by visitors). Rows are the label, the stored value and the
+     * metrics as plain numbers.
+     *
+     * @param string $sReport query, table or top
+     * @param int $iMax rows at most
+     * @return array dimension, header, rows, total (values in the period), truncated; or false
+     */
+    public function getExport($sReport, BxAnalyticsFilter $oFilter, $aParams, $iMax)
+    {
+        switch($sReport) {
+            case 'query':
+                $aQuery = $this->getQueryParams($aParams);
+                break;
+
+            case 'table':
+            case 'top':
+                $aLists = $sReport == 'table' ? $this->_aTables : $this->_aGroups;
+                $sList = (string)$this->_param($aParams, $sReport == 'table' ? 'table' : 'group');
+                if(!isset($aLists[$sList]))
+                    return false;
+                $aQuery = $this->getQueryParams(['q_dim' => $aLists[$sList][$this->_getTab($aParams, $sList, $aLists[$sList])]]);
+                break;
+
+            default:
+                return false;
+        }
+
+        $aFilter = $oFilter->get();
+        $aColumns = $this->_getQueryColumns($aQuery);
+
+        $aHeader = [$aColumns[0]['title'], _t('_bx_analytics_txt_raw_value')];
+        foreach(array_slice($aColumns, 1) as $aColumn)
+            $aHeader[] = $aColumn['title'];
+
+        // one query: pages of a re-run aggregate would cost a full scan each and could
+        // repeat or skip values that change places while hits come in
+        $iMax = max(1, (int)$iMax);
+        $aRows = [];
+        foreach($this->_getQueryRows($aFilter, $aQuery, $iMax, 0, $iMax) as $aRow) {
+            $aLine = [$this->getLabel($aQuery['dimension'], $aRow['value']), $aRow['value']];
+            foreach(array_slice($aColumns, 1) as $aColumn)
+                $aLine[] = isset($aRow[$aColumn['key']]) ? $aRow[$aColumn['key']] : 0;
+            $aRows[] = $aLine;
+        }
+
+        $iTotal = $this->_oDb->getTopCount($aFilter, $aQuery['dimension']);
+
+        return [
+            'dimension' => $aQuery['dimension'],
+            'header' => $aHeader,
+            'rows' => $aRows,
+            'total' => $iTotal,
+            'truncated' => $iTotal > count($aRows),
+        ];
+    }
+
+    /**
+     * Query rows: the stored value and the metrics as numbers (KPIs for a session dimension,
+     * pageview figures for a page or title).
+     *
+     * @param bool $bPathSessions pages get entries, bounce rate and exits
+     */
+    protected function _getQueryRows($aFilter, $aQuery, $iLimit, $iStart, $iMax = 1000, $bPathSessions = true)
+    {
+        $aRows = $this->_oDb->getBreakdown($aFilter, $aQuery['dimension'], $aQuery['metric'], $aQuery['dir'] == 'asc' ? 'ASC' : 'DESC', $iLimit, $iStart, $iMax, $bPathSessions);
+
+        $aResult = [];
+        foreach($aRows as $aRow)
+            $aResult[] = ['value' => (string)$aRow['value']] + ($aQuery['kind'] == 'session' ? $this->_getKpis($aRow) : $this->_getPageKpis($aRow));
+
+        return $aResult;
+    }
+
+    /**
+     * Metrics of a page or title row from BxAnalyticsDb::getPageStats.
+     */
+    protected function _getPageKpis($aRow)
+    {
+        $aResult = [
+            'visitors' => isset($aRow['visitors']) ? (int)$aRow['visitors'] : 0,
+            'pageviews' => isset($aRow['pageviews']) ? (int)$aRow['pageviews'] : 0,
+            'engaged' => isset($aRow['engaged']) ? round((float)$aRow['engaged'], 1) : 0,
+        ];
+
+        if(isset($aRow['entries']))
+            $aResult += [
+                'entries' => (int)$aRow['entries'],
+                'bounce_rate' => (float)$aRow['bounce_rate'],
+                'exits' => (int)$aRow['exits'],
+            ];
+
+        return $aResult;
+    }
+
+    /**
+     * Table columns of a query, the sorted one marked.
+     */
+    protected function _getQueryColumns($aQuery)
+    {
+        if($aQuery['kind'] == 'session')
+            $aTypes = ['visitors' => 'number', 'sessions' => 'number', 'pageviews' => 'number', 'bounce_rate' => 'percent', 'pages_per_session' => 'decimal', 'duration' => 'duration'];
+        else {
+            $aTypes = ['visitors' => 'number', 'pageviews' => 'number', 'engaged' => 'engaged'];
+            if($aQuery['dimension'] == 'path')
+                $aTypes += ['entries' => 'number', 'bounce_rate' => 'percent', 'exits' => 'number'];
+        }
+
+        $aColumns = [['key' => 'label', 'type' => 'label', 'title' => _t('_bx_analytics_dim_' . $aQuery['dimension'])]];
+        foreach($aTypes as $sKey => $sType) {
+            $aColumn = ['key' => $sKey, 'type' => $sType, 'title' => _t('_bx_analytics_metric_' . $sKey)];
+            if($sKey == $aQuery['metric'])
+                $aColumn['sort'] = $aQuery['dir'] == 'asc' ? 'ascending' : 'descending';
+            $aColumns[] = $aColumn;
+        }
+
+        return $aColumns;
+    }
+
+    /**
+     * The chosen metric of some values per bucket. A bucket without data is 0 for a count
+     * and a gap for an average or a rate; buckets after now are gaps.
+     */
+    protected function _getQuerySeries($aFilter, $aQuery, $aValues)
+    {
+        $sMetric = $aQuery['metric'];
+        $bRatio = in_array($sMetric, ['pages_per_session', 'bounce_rate', 'duration', 'engaged'], true);
+
+        $aData = [];
+        foreach($this->_oDb->getBreakdownSeries($aFilter, $aQuery['dimension'], array_column($aValues, 'value'), $aFilter['bucket'], $aFilter['offset'], $aFilter['starts']) as $aRow) {
+            $aKpis = $aQuery['kind'] == 'session' ? $this->_getKpis($aRow) : $this->_getPageKpis($aRow);
+            $aData[(string)$aRow['value']][(int)$aRow['bucket']] = $aKpis[$sMetric];
+        }
+
+        $aBuckets = $aFilter['buckets'];
+        $aSeries = [];
+        foreach($aValues as $aValue) {
+            $aPoints = [];
+            foreach($aBuckets as $i => $iBucket) {
+                $iNext = isset($aBuckets[$i + 1]) ? $aBuckets[$i + 1] : $aFilter['to'];
+                if($iBucket > $aFilter['now'])
+                    $mixedValue = null;
+                elseif(isset($aData[$aValue['value']][$iBucket]))
+                    $mixedValue = $aData[$aValue['value']][$iBucket];
+                else
+                    $mixedValue = $bRatio ? null : 0;
+
+                $aPoints[] = [
+                    't' => $iBucket,
+                    'v' => $mixedValue,
+                    'partial' => $iBucket <= $aFilter['now'] && $aFilter['now'] < $iNext,
+                ];
+            }
+
+            $aSeries[] = [
+                'value' => $aValue['value'],
+                'label' => $this->getLabel($aQuery['dimension'], $aValue['value']),
+                'points' => $aPoints,
+            ];
+        }
+
+        return [
+            'kind' => 'series',
+            'metric' => $sMetric,
+            'title' => _t('_bx_analytics_metric_' . $sMetric),
+            'bucket' => $aFilter['bucket'],
+            'tz' => $aFilter['tz'],
+            'series' => $aSeries,
+        ];
+    }
+
+    /**
+     * Visitors online now, per platform, pageviews per minute over the last 30 minutes, and
+     * the latest pageviews.
+     */
+    public function getRealtime(BxAnalyticsFilter $oFilter, $aParams = [])
+    {
+        $iNow = time();
+        $sPlatform = $oFilter->get()['platform'];
+
+        $aOnline = $this->_oDb->getOnline($iNow - $this->_oConfig->getOnlineWindow());
+        $aPlatforms = [];
+        $iTotal = 0;
+        foreach($this->_oConfig->getPlatforms() as $sCode => $sTitle) {
+            $iCount = isset($aOnline[$sCode]) ? (int)$aOnline[$sCode] : 0;
+            $iTotal += $iCount;
+            if($iCount)
+                $aPlatforms[] = ['value' => $sCode, 'label' => _t($sTitle), 'visitors' => $iCount];
+        }
+        foreach($aPlatforms as $i => $a)
+            $aPlatforms[$i]['share'] = $iTotal ? round(100 * $a['visitors'] / $iTotal, 1) : 0;
+
+        $iMinutes = 30;
+        $iStart = (int)(floor($iNow / 60) * 60) - ($iMinutes - 1) * 60;
+        $aPerMinute = $this->_oDb->getPageviewsPerMinute($iStart, $sPlatform);
+        $aMinutes = [];
+        for($i = 0; $i < $iMinutes; $i++) {
+            $iMinute = $iStart + $i * 60;
+            $aMinutes[] = ['t' => $iMinute, 'pageviews' => isset($aPerMinute[$iMinute]) ? (int)$aPerMinute[$iMinute] : 0];
+        }
+
+        $iLimit = isset($aParams['limit']) ? max(0, min(50, (int)$aParams['limit'])) : 20;
+        $aRecent = [];
+        $aRows = $iLimit > 0 ? $this->_oDb->getRecentPageviews($iNow - $iMinutes * 60, $iLimit, $sPlatform) : [];
+        foreach($aRows as $aRow)
+            $aRecent[] = [
+                'ts' => (int)$aRow['ts'],
+                'platform' => $aRow['platform'],
+                'platform_label' => $this->getLabel('platform', $aRow['platform']),
+                'path' => $aRow['path'],
+                'title' => $aRow['title'],
+                'country' => $aRow['country'],
+                'country_label' => $this->getLabel('country', $aRow['country']),
+                'city' => $aRow['city'],
+                'device' => $this->getLabel('device', $aRow['device']),
+                'browser' => $aRow['browser'],
+                'os' => $aRow['os'],
+                'member' => (int)$aRow['profile_id'] > 0,
+            ];
+
+        return [
+            'online' => $sPlatform !== '' ? (isset($aOnline[$sPlatform]) ? (int)$aOnline[$sPlatform] : 0) : $iTotal,
+            'platforms' => $aPlatforms,
+            'minutes' => $aMinutes,
+            'recent' => $aRecent,
+            'window' => (int)($this->_oConfig->getOnlineWindow() / 60),
+        ];
+    }
+
+    /**
+     * Visitors on a map: per country at the country's centre, and per place where the edge
+     * gave coordinates. Tabs: the period, or live (the last 30 minutes).
+     */
+    public function getMap(BxAnalyticsFilter $oFilter, $aParams = [])
+    {
+        bx_import('Geo', $this->_oModule->_aModule);
+
+        $aTabs = $this->_aReportTabs['map'];
+        $sTab = $this->_getTab($aParams, 'map', $aTabs);
+
+        $aFilter = $oFilter->get();
+        if($sTab == 'live') {
+            $aFilter['from'] = time() - 30 * 60;
+            $aFilter['to'] = time() + 60;
+        }
+
+        $aCountries = [];
+        $iMax = 0;
+        $iUnknown = 0;
+        foreach($this->_oDb->getTop($aFilter, 'country', 250) as $aRow) {
+            $aCenter = BxAnalyticsGeo::getCenter($aRow['value']);
+            if(!$aCenter) {
+                $iUnknown += (int)$aRow['visitors'];
+                continue;
+            }
+
+            $iMax = max($iMax, (int)$aRow['visitors']);
+            $aCountries[] = [
+                'country' => $aRow['value'],
+                'label' => $this->getLabel('country', $aRow['value']),
+                'visitors' => (int)$aRow['visitors'],
+                'lat' => $aCenter[0],
+                'lng' => $aCenter[1],
+            ];
+        }
+
+        $aPoints = [];
+        foreach($this->_oDb->getMapPoints($aFilter) as $aRow)
+            $aPoints[] = [
+                'label' => trim($aRow['city'] . ($aRow['country'] !== '' ? ', ' . $this->getLabel('country', $aRow['country']) : ''), ', '),
+                'visitors' => (int)$aRow['visitors'],
+                'lat' => (float)$aRow['lat'],
+                'lng' => (float)$aRow['lng'],
+            ];
+
+        $iTotal = array_sum(array_column($aCountries, 'visitors'));
+        foreach($aCountries as $i => $a) {
+            $aCountries[$i]['share'] = $iTotal ? round(100 * $a['visitors'] / $iTotal, 1) : 0;
+            $aCountries[$i]['bar'] = $iMax ? round(100 * $a['visitors'] / $iMax, 1) : 0;
+        }
+
+        return [
+            'tab' => $sTab,
+            'tabs' => array_map(function($sName) use ($sTab) {
+                return ['name' => $sName, 'title' => _t('_bx_analytics_tab_map_' . $sName), 'active' => $sName == $sTab];
+            }, array_keys($aTabs)),
+            'countries' => $aCountries,
+            'points' => $aPoints,
+            'unknown' => $iUnknown,
+            'provider' => getParam('sys_location_leaflet_provider'),
+        ];
+    }
+
+    /**
+     * Bots and agents: totals, hits per day by category, the bots and the pages they ask for.
+     * Tabs narrow it to a category; AI covers crawlers and assistants fetching for a person.
+     */
+    public function getBots(BxAnalyticsFilter $oFilter, $aParams = [])
+    {
+        bx_import('UserAgent', $this->_oModule->_aModule);
+
+        $aCategories = $this->_getBotCategories();
+        $sTab = $this->_getTab($aParams, 'bots', $aCategories);
+        $aIn = $aCategories[$sTab];
+        $aFilter = $oFilter->get();
+
+        $aTotals = $this->_oDb->getBotTotals($aFilter, $aIn);
+        $aAi = $this->_oDb->getBotTotals($aFilter, $aCategories['ai']);
+        $aHumans = $this->_oDb->getTotals(array_merge($aFilter, ['dims' => []]));
+        $iHits = (int)$aTotals['hits'];
+        $iAll = $sTab == 'all' ? $iHits : (int)$this->_oDb->getBotTotals($aFilter)['hits'];
+
+        $aTiles = [
+            ['title' => _t('_bx_analytics_metric_bot_hits'), 'value' => $iHits, 'type' => 'number'],
+            ['title' => _t('_bx_analytics_metric_bots'), 'value' => (int)$aTotals['bots'], 'type' => 'number'],
+            ['title' => _t('_bx_analytics_metric_ai_hits'), 'value' => (int)$aAi['hits'], 'type' => 'number'],
+            ['title' => _t('_bx_analytics_metric_bot_share'), 'value' => $iAll + (int)$aHumans['pageviews'] ? round(100 * $iAll / ($iAll + (int)$aHumans['pageviews']), 1) : 0, 'type' => 'percent'],
+        ];
+
+        // hits per day and category: rows are per UTC day, so the chart has every UTC day the
+        // query reads (the same days as the table) and labels them in UTC. Stepping from the
+        // local start instead dropped the last UTC day east of UTC, and with it today's hits.
+        $aBotDays = $this->_oDb->getBotDays($aFilter);
+        $iDayTo = strtotime($aBotDays['to'] . ' 00:00:00 UTC');
+        $aDays = [];
+        for($i = strtotime($aBotDays['from'] . ' 00:00:00 UTC'); $i <= $iDayTo && count($aDays) < 800; $i += 86400)
+            $aDays[] = gmdate('Y-m-d', $i);
+
+        $aSeries = [];
+        foreach($this->_oDb->getBotSeries($aFilter) as $aRow)
+            if(empty($aIn) || in_array($aRow['category'], $aIn, true))
+                $aSeries[$aRow['category']][$aRow['day']] = (int)$aRow['hits'];
+
+        $aDatasets = [];
+        foreach($aSeries as $sCategory => $aValues)
+            $aDatasets[] = [
+                'key' => $sCategory,
+                'label' => _t('_bx_analytics_bot_' . $sCategory),
+                'values' => array_map(function($sDay) use ($aValues) { return isset($aValues[$sDay]) ? $aValues[$sDay] : 0; }, $aDays),
+            ];
+
+        $iTotal = $this->_oDb->getBotListCount($aFilter, $aIn);
+        $iPages = max(1, (int)ceil($iTotal / $this->_iPerPage));
+        $iPage = min($iPages, max(1, (int)$this->_param($aParams, 'page_bot_list', 1)));
+
+        $aBots = $this->_oDb->getBotList($aFilter, $aIn, $this->_iPerPage, ($iPage - 1) * $this->_iPerPage);
+        $iMax = 1;
+        foreach($aBots as $aBot)
+            $iMax = max($iMax, (int)$aBot['hits']);
+
+        $aRows = [];
+        foreach($aBots as $aBot)
+            $aRows[] = [
+                'label' => $aBot['bot'],
+                'sub' => _t('_bx_analytics_bot_' . $aBot['category']),
+                'bar' => round(100 * $aBot['hits'] / $iMax, 1),
+                'values' => [
+                    'hits' => (int)$aBot['hits'],
+                    'last_seen' => (int)$aBot['last_seen'],
+                    'platforms' => implode(', ', array_map(function($s) { return $this->getLabel('platform', $s); }, explode(',', (string)$aBot['platforms']))),
+                ],
+            ];
+
+        $aPagesRows = [];
+        $aPages = $this->_oDb->getBotPages($aFilter, $aIn, 10);
+        $iMaxPage = 1;
+        foreach($aPages as $aPage)
+            $iMaxPage = max($iMaxPage, (int)$aPage['hits']);
+        foreach($aPages as $aPage)
+            $aPagesRows[] = [
+                'label' => $aPage['path'],
+                'bar' => round(100 * $aPage['hits'] / $iMaxPage, 1),
+                'values' => ['hits' => (int)$aPage['hits']],
+            ];
+
+        return [
+            'tab' => $sTab,
+            'tabs' => $this->_getTabs(array_flip(array_keys($aCategories)), $sTab, '_bx_analytics_tab_bots_'),
+            'tiles' => $aTiles,
+            'chart' => [
+                'kind' => 'stacked',
+                'labels' => array_map(function($sDay) { return strtotime($sDay . ' 00:00:00 UTC'); }, $aDays),
+                'bucket' => 'day',
+                'tz' => 'UTC',
+                'datasets' => $aDatasets,
+            ],
+            'bots' => [
+                'name' => 'bot_list',
+                'caption' => _t('_bx_analytics_lmi_cpt_bots'),
+                'columns' => [
+                    ['key' => 'label', 'type' => 'label', 'title' => _t('_bx_analytics_txt_bot')],
+                    ['key' => 'hits', 'type' => 'number', 'title' => _t('_bx_analytics_metric_bot_hits')],
+                    ['key' => 'platforms', 'type' => 'text', 'title' => _t('_bx_analytics_dim_platform')],
+                    ['key' => 'last_seen', 'type' => 'datetime', 'title' => _t('_bx_analytics_txt_last_seen')],
+                ],
+                'rows' => $aRows,
+                'page' => $iPage,
+                'pages' => $iPages,
+                'empty' => _t('_bx_analytics_txt_no_bots'),
+            ],
+            'pages' => [
+                'name' => 'bot_pages',
+                'caption' => _t('_bx_analytics_txt_bot_pages'),
+                'columns' => [
+                    ['key' => 'label', 'type' => 'label', 'title' => _t('_bx_analytics_tab_pages')],
+                    ['key' => 'hits', 'type' => 'number', 'title' => _t('_bx_analytics_metric_bot_hits')],
+                ],
+                'rows' => $aPagesRows,
+                'empty' => _t('_bx_analytics_txt_no_bots'),
+            ],
+        ];
+    }
+
+    /**
+     * Custom events and site actions with their counts and visitors. With `event`
+     * (type|name|module) one event over time, by platform and by its properties.
+     * Tabs: all, custom (from trackers), site (UNA actions).
+     */
+    public function getEvents(BxAnalyticsFilter $oFilter, $aParams = [])
+    {
+        $aEvent = $this->_parseEvent($this->_param($aParams, 'event'));
+        if($aEvent)
+            return $this->_getEventDetail($oFilter, $aEvent);
+
+        $aTabs = $this->_aReportTabs['events'];
+        $sTab = $this->_getTab($aParams, 'events', $aTabs);
+        $aFilter = $oFilter->get();
+
+        $iTotal = $this->_oDb->getEventListCount($aFilter, $aTabs[$sTab]);
+        $iPages = max(1, (int)ceil($iTotal / $this->_iPerPage));
+        $iPage = min($iPages, max(1, (int)$this->_param($aParams, 'page_events', 1)));
+
+        $aEvents = $this->_oDb->getEventList($aFilter, $aTabs[$sTab], $this->_iPerPage, ($iPage - 1) * $this->_iPerPage);
+        $iMax = 1;
+        foreach($aEvents as $aRow)
+            $iMax = max($iMax, (int)$aRow['count']);
+
+        $aRows = [];
+        foreach($aEvents as $aRow)
+            $aRows[] = [
+                'label' => $this->getEventLabel($aRow['type'], $aRow['name'], $aRow['module']),
+                'sub' => _t('_bx_analytics_event_type_' . $aRow['type']),
+                'open' => ['param' => 'event', 'value' => $aRow['type'] . '|' . $aRow['name'] . '|' . $aRow['module']],
+                'bar' => round(100 * $aRow['count'] / $iMax, 1),
+                'values' => [
+                    'count' => (int)$aRow['count'],
+                    'visitors' => (int)$aRow['visitors'],
+                    'per_visitor' => $aRow['visitors'] ? round($aRow['count'] / $aRow['visitors'], 1) : 0,
+                    'last' => (int)$aRow['last'],
+                ],
+            ];
+
+        return [
+            'view' => 'list',
+            'name' => 'events',
+            'caption' => _t('_bx_analytics_lmi_cpt_events'),
+            'tab' => $sTab,
+            'tabs' => $this->_getTabs($aTabs, $sTab, '_bx_analytics_tab_events_'),
+            'columns' => [
+                ['key' => 'label', 'type' => 'label', 'title' => _t('_bx_analytics_txt_event')],
+                ['key' => 'count', 'type' => 'number', 'title' => _t('_bx_analytics_metric_count')],
+                ['key' => 'visitors', 'type' => 'number', 'title' => _t('_bx_analytics_metric_visitors')],
+                ['key' => 'per_visitor', 'type' => 'decimal', 'title' => _t('_bx_analytics_metric_per_visitor')],
+                ['key' => 'last', 'type' => 'datetime', 'title' => _t('_bx_analytics_txt_last_seen')],
+            ],
+            'rows' => $aRows,
+            'page' => $iPage,
+            'pages' => $iPages,
+            'empty' => _t('_bx_analytics_txt_no_events'),
+        ];
+    }
+
+    protected function _getEventDetail(BxAnalyticsFilter $oFilter, $aEvent)
+    {
+        $aFilter = $oFilter->get();
+
+        $aRows = $this->_oDb->getEventSeries($aFilter, $aEvent, $aFilter['bucket'], $aFilter['offset'], $aFilter['starts']);
+        $aCounts = [];
+        $aVisitors = [];
+        foreach($aFilter['buckets'] as $iBucket) {
+            $aCounts[] = isset($aRows[$iBucket]) ? (int)$aRows[$iBucket]['count'] : 0;
+            $aVisitors[] = isset($aRows[$iBucket]) ? (int)$aRows[$iBucket]['visitors'] : 0;
+        }
+
+        $aPlatforms = [];
+        $iTotal = 0;
+        $iTotalVisitors = 0;
+        foreach($this->_oDb->getEventPlatforms($aFilter, $aEvent) as $aRow) {
+            $iTotal += (int)$aRow['count'];
+            $iTotalVisitors += (int)$aRow['visitors'];
+            $aPlatforms[] = ['label' => $this->getLabel('platform', $aRow['platform']), 'count' => (int)$aRow['count']];
+        }
+        foreach($aPlatforms as $i => $a)
+            $aPlatforms[$i]['share'] = $iTotal ? round(100 * $a['count'] / $iTotal, 1) : 0;
+
+        // the most common values of each property among the latest occurrences; a sale's
+        // properties only for those who may see revenue (events recorded earlier carry amounts)
+        $aProps = [];
+        $bMoney = $aEvent['type'] == BX_ANALYTICS_HIT_UNA && in_array($aEvent['name'], ['purchase', 'renewal', 'trial_start'], true);
+        foreach($bMoney && $this->_oModule->checkAllowedRevenue() !== CHECK_ACTION_RESULT_ALLOWED ? [] : $this->_oDb->getEventProps($aFilter, $aEvent) as $sJson) {
+            $a = json_decode($sJson, true);
+            if(!is_array($a))
+                continue;
+            foreach($a as $sKey => $mixedValue)
+                if(is_scalar($mixedValue)) {
+                    $sValue = is_bool($mixedValue) ? ($mixedValue ? 'true' : 'false') : (string)$mixedValue;
+                    $aProps[$sKey][$sValue] = (isset($aProps[$sKey][$sValue]) ? $aProps[$sKey][$sValue] : 0) + 1;
+                }
+        }
+
+        $aPropTables = [];
+        foreach(array_slice($aProps, 0, 6, true) as $sKey => $aValues) {
+            arsort($aValues);
+            $iMax = max($aValues);
+            $aPropRows = [];
+            foreach(array_slice($aValues, 0, 10, true) as $sValue => $iCount)
+                $aPropRows[] = ['label' => (string)$sValue, 'bar' => round(100 * $iCount / $iMax, 1), 'values' => ['count' => $iCount]];
+
+            $aPropTables[] = [
+                'name' => 'prop_' . preg_replace('/[^a-z0-9_]/i', '_', $sKey),
+                'caption' => (string)$sKey,
+                'columns' => [
+                    ['key' => 'label', 'type' => 'label', 'title' => (string)$sKey],
+                    ['key' => 'count', 'type' => 'number', 'title' => _t('_bx_analytics_metric_count')],
+                ],
+                'rows' => $aPropRows,
+            ];
+        }
+
+        return [
+            'view' => 'detail',
+            'event' => $aEvent,
+            'title' => $this->getEventLabel($aEvent['type'], $aEvent['name'], $aEvent['module']),
+            'tiles' => [
+                ['title' => _t('_bx_analytics_metric_count'), 'value' => $iTotal, 'type' => 'number'],
+                ['title' => _t('_bx_analytics_metric_visitors'), 'value' => $iTotalVisitors, 'type' => 'number'],
+                ['title' => _t('_bx_analytics_metric_per_visitor'), 'value' => $iTotalVisitors ? round($iTotal / $iTotalVisitors, 1) : 0, 'type' => 'decimal'],
+            ],
+            'chart' => [
+                'kind' => 'lines',
+                'labels' => $aFilter['buckets'],
+                'bucket' => $aFilter['bucket'],
+                'tz' => $aFilter['tz'],
+                'datasets' => [
+                    ['key' => 'count', 'label' => _t('_bx_analytics_metric_count'), 'values' => $aCounts],
+                    ['key' => 'visitors', 'label' => _t('_bx_analytics_metric_visitors'), 'values' => $aVisitors],
+                ],
+            ],
+            'platforms' => $aPlatforms,
+            'props' => $aPropTables,
+        ];
+    }
+
+    /**
+     * Visits, latest first, with who, where from and on what. With `session` one visit's
+     * timeline. Shows members by name, so it needs the people permission.
+     */
+    public function getSessions(BxAnalyticsFilter $oFilter, $aParams = [], $iProfileId = 0)
+    {
+        $iSession = (int)$this->_param($aParams, 'session', 0);
+        if($iSession)
+            return $this->_getSessionDetail($iSession);
+
+        $aFilter = $oFilter->get();
+        $iTotal = $this->_oDb->getSessionListCount($aFilter, $iProfileId);
+        $iPages = max(1, (int)ceil($iTotal / $this->_iPerPage));
+        $iPage = min($iPages, max(1, (int)$this->_param($aParams, 'page_sessions', 1)));
+
+        $aRows = [];
+        foreach($this->_oDb->getSessionList($aFilter, $this->_iPerPage, ($iPage - 1) * $this->_iPerPage, $iProfileId) as $aSession) {
+            $aMember = $this->getMember((int)$aSession['profile_id']);
+            $aRows[] = [
+                'label' => $aMember ? $aMember['name'] : _t('_bx_analytics_txt_guest'),
+                'open' => ['param' => 'session', 'value' => (int)$aSession['id']],
+                'values' => [
+                    'started' => (int)$aSession['started'],
+                    'platform' => $this->getLabel('platform', $aSession['platform']),
+                    'location' => $this->_getLocation($aSession),
+                    'device' => $this->_getDevice($aSession),
+                    'source' => $aSession['referrer_host'] !== '' ? $aSession['referrer_host'] : $this->getLabel('channel', $aSession['channel'] !== '' ? $aSession['channel'] : 'direct'),
+                    'pageviews' => (int)$aSession['pageviews'],
+                    'duration' => (int)$aSession['duration'],
+                ],
+            ];
+        }
+
+        return [
+            'view' => 'list',
+            'name' => 'sessions',
+            'caption' => _t('_bx_analytics_lmi_cpt_sessions'),
+            'columns' => [
+                ['key' => 'label', 'type' => 'label', 'title' => _t('_bx_analytics_txt_visitor')],
+                ['key' => 'started', 'type' => 'datetime', 'title' => _t('_bx_analytics_txt_started')],
+                ['key' => 'platform', 'type' => 'text', 'title' => _t('_bx_analytics_dim_platform')],
+                ['key' => 'location', 'type' => 'text', 'title' => _t('_bx_analytics_txt_location')],
+                ['key' => 'device', 'type' => 'text', 'title' => _t('_bx_analytics_dim_device')],
+                ['key' => 'source', 'type' => 'text', 'title' => _t('_bx_analytics_txt_source')],
+                ['key' => 'pageviews', 'type' => 'number', 'title' => _t('_bx_analytics_metric_pageviews')],
+                ['key' => 'duration', 'type' => 'duration', 'title' => _t('_bx_analytics_txt_duration')],
+            ],
+            'rows' => $aRows,
+            'page' => $iPage,
+            'pages' => $iPages,
+            'empty' => _t('_bx_analytics_txt_no_data'),
+        ];
+    }
+
+    protected function _getSessionDetail($iSession)
+    {
+        $aSession = $this->_oDb->getSession($iSession);
+        if(empty($aSession))
+            return false;
+
+        $iStart = (int)$aSession['started'];
+        $aTimeline = [];
+        foreach($this->_oDb->getSessionEvents($iSession) as $aEvent)
+            $aTimeline[] = [
+                'offset' => max(0, (int)$aEvent['ts'] - $iStart),
+                'type' => $aEvent['type'],
+                'label' => $aEvent['type'] == BX_ANALYTICS_HIT_PAGEVIEW ? ($aEvent['title'] !== '' ? $aEvent['title'] : $aEvent['path']) : $this->getEventLabel($aEvent['type'], $aEvent['name'], $aEvent['module']),
+                'path' => $aEvent['type'] == BX_ANALYTICS_HIT_PAGEVIEW ? $aEvent['path'] : '',
+                'engaged' => (int)round($aEvent['engaged'] / 1000),
+            ];
+
+        $aMember = $this->getMember((int)$aSession['profile_id']);
+
+        return [
+            'view' => 'detail',
+            'id' => (int)$aSession['id'],
+            'member' => $aMember,
+            'facts' => [
+                ['title' => _t('_bx_analytics_txt_started'), 'value' => (int)$aSession['started'], 'type' => 'datetime'],
+                ['title' => _t('_bx_analytics_txt_duration'), 'value' => (int)$aSession['duration'], 'type' => 'duration'],
+                ['title' => _t('_bx_analytics_metric_pageviews'), 'value' => (int)$aSession['pageviews'], 'type' => 'number'],
+                ['title' => _t('_bx_analytics_dim_platform'), 'value' => $this->getLabel('platform', $aSession['platform']) . ($aSession['app_version'] !== '' ? ' ' . $aSession['app_version'] : ''), 'type' => 'text'],
+                ['title' => _t('_bx_analytics_txt_location'), 'value' => $this->_getLocation($aSession), 'type' => 'text'],
+                ['title' => _t('_bx_analytics_dim_device'), 'value' => $this->_getDevice($aSession), 'type' => 'text'],
+                ['title' => _t('_bx_analytics_txt_source'), 'value' => $aSession['referrer'] !== '' ? $aSession['referrer'] : $this->getLabel('channel', $aSession['channel'] !== '' ? $aSession['channel'] : 'direct'), 'type' => 'text'],
+                ['title' => _t('_bx_analytics_dim_language'), 'value' => $aSession['language'], 'type' => 'text'],
+            ],
+            'timeline' => $aTimeline,
+        ];
+    }
+
+    /**
+     * Members who visited in the period. With `user` one member's visits and actions; with
+     * `session` too, one of their visits. Needs the people permission.
+     */
+    public function getUsers(BxAnalyticsFilter $oFilter, $aParams = [])
+    {
+        $iUser = (int)$this->_param($aParams, 'user', 0);
+        if($iUser) {
+            if((int)$this->_param($aParams, 'session', 0))
+                return $this->_getSessionDetail((int)$aParams['session']);
+
+            return $this->_getUserDetail($oFilter, $iUser, $aParams);
+        }
+
+        $aFilter = $oFilter->get();
+        $iTotal = $this->_oDb->getMemberListCount($aFilter);
+        $iPages = max(1, (int)ceil($iTotal / $this->_iPerPage));
+        $iPage = min($iPages, max(1, (int)$this->_param($aParams, 'page_users', 1)));
+
+        $aMembers = $this->_oDb->getMemberList($aFilter, $this->_iPerPage, ($iPage - 1) * $this->_iPerPage);
+
+        // lifetime value: what each member paid, for viewers who may see revenue
+        $bRevenue = $this->_oModule->checkAllowedRevenue() === CHECK_ACTION_RESULT_ALLOWED;
+        $aLtv = $bRevenue ? $this->getLifetimeValues(array_column($aMembers, 'profile_id')) : [];
+
+        $aRows = [];
+        foreach($aMembers as $aRow) {
+            $aMember = $this->getMember((int)$aRow['profile_id']);
+            $aValues = [
+                'sessions' => (int)$aRow['sessions'],
+                'pageviews' => (int)$aRow['pageviews'],
+                'duration' => (int)$aRow['duration'],
+                'platforms' => $this->_getPlatformsText($aRow['platforms']),
+                'last' => (int)$aRow['last'],
+            ];
+            if($bRevenue)
+                $aValues['ltv'] = isset($aLtv[$aRow['profile_id']]) ? round((float)$aLtv[$aRow['profile_id']], 2) : 0;
+
+            $aRows[] = [
+                'label' => $aMember ? $aMember['name'] : _t('_bx_analytics_txt_deleted_member'),
+                'sub' => $aRow['country'] !== '' ? $this->getLabel('country', $aRow['country']) : '',
+                'open' => ['param' => 'user', 'value' => (int)$aRow['profile_id']],
+                'values' => $aValues,
+            ];
+        }
+
+        $aColumns = [
+            ['key' => 'label', 'type' => 'label', 'title' => _t('_bx_analytics_txt_member_name')],
+            ['key' => 'sessions', 'type' => 'number', 'title' => _t('_bx_analytics_metric_sessions')],
+            ['key' => 'pageviews', 'type' => 'number', 'title' => _t('_bx_analytics_metric_pageviews')],
+            ['key' => 'duration', 'type' => 'duration', 'title' => _t('_bx_analytics_txt_time_spent')],
+            ['key' => 'platforms', 'type' => 'text', 'title' => _t('_bx_analytics_tab_platforms')],
+        ];
+        if($bRevenue)
+            $aColumns[] = ['key' => 'ltv', 'type' => 'money', 'title' => _t('_bx_analytics_txt_lifetime_value')];
+        $aColumns[] = ['key' => 'last', 'type' => 'datetime', 'title' => _t('_bx_analytics_txt_last_seen')];
+
+        return [
+            'view' => 'list',
+            'name' => 'users',
+            'caption' => _t('_bx_analytics_lmi_cpt_users'),
+            'columns' => $aColumns,
+            'rows' => $aRows,
+            'page' => $iPage,
+            'pages' => $iPages,
+            'empty' => _t('_bx_analytics_txt_no_members'),
+        ];
+    }
+
+    protected function _getUserDetail(BxAnalyticsFilter $oFilter, $iProfileId, $aParams)
+    {
+        $aTotals = $this->_oDb->getMemberTotals($iProfileId);
+        if(empty($aTotals['totals']['sessions']))
+            return false;
+
+        $aPlatforms = [];
+        $iSessions = (int)$aTotals['totals']['sessions'];
+        foreach($aTotals['platforms'] as $sPlatform => $iCount)
+            $aPlatforms[] = [
+                'label' => $this->getLabel('platform', $sPlatform),
+                'count' => (int)$iCount,
+                'share' => $iSessions ? round(100 * $iCount / $iSessions, 1) : 0,
+            ];
+
+        $aActions = [];
+        $aRows = $this->_oDb->getMemberActions($iProfileId, $oFilter->get());
+        $iMax = 1;
+        foreach($aRows as $aRow)
+            $iMax = max($iMax, (int)$aRow['count']);
+        foreach($aRows as $aRow)
+            $aActions[] = [
+                'label' => $this->getEventLabel($aRow['type'], $aRow['name'], $aRow['module']),
+                'bar' => round(100 * $aRow['count'] / $iMax, 1),
+                'values' => ['count' => (int)$aRow['count'], 'last' => (int)$aRow['last']],
+            ];
+
+        $aSessions = $this->getSessions($oFilter, $aParams, $iProfileId);
+
+        $aTiles = [
+            ['title' => _t('_bx_analytics_metric_sessions'), 'value' => $iSessions, 'type' => 'number'],
+            ['title' => _t('_bx_analytics_metric_pageviews'), 'value' => (int)$aTotals['totals']['pageviews'], 'type' => 'number'],
+            ['title' => _t('_bx_analytics_txt_time_spent'), 'value' => (int)$aTotals['totals']['duration'], 'type' => 'duration'],
+            ['title' => _t('_bx_analytics_txt_first_seen'), 'value' => (int)$aTotals['totals']['first'], 'type' => 'date'],
+            ['title' => _t('_bx_analytics_txt_last_seen'), 'value' => (int)$aTotals['totals']['last'], 'type' => 'datetime'],
+        ];
+        if($this->_oModule->checkAllowedRevenue() === CHECK_ACTION_RESULT_ALLOWED) {
+            $aLtv = $this->getLifetimeValues([$iProfileId]);
+            $aTiles[] = ['title' => _t('_bx_analytics_txt_lifetime_value'), 'value' => isset($aLtv[$iProfileId]) ? round((float)$aLtv[$iProfileId], 2) : 0, 'type' => 'money'];
+        }
+
+        return [
+            'view' => 'user',
+            'member' => $this->getMember($iProfileId),
+            'tiles' => $aTiles,
+            'platforms' => $aPlatforms,
+            'actions' => [
+                'name' => 'user_actions',
+                'caption' => _t('_bx_analytics_txt_actions'),
+                'columns' => [
+                    ['key' => 'label', 'type' => 'label', 'title' => _t('_bx_analytics_txt_event')],
+                    ['key' => 'count', 'type' => 'number', 'title' => _t('_bx_analytics_metric_count')],
+                    ['key' => 'last', 'type' => 'datetime', 'title' => _t('_bx_analytics_txt_last_seen')],
+                ],
+                'rows' => $aActions,
+                'empty' => _t('_bx_analytics_txt_no_events'),
+            ],
+            'sessions' => $aSessions,
+        ];
+    }
+
+    /**
+     * Where visitors go from a page: the pages they open next and, for one of those, the
+     * pages after it; and the most common three-page paths from the entry page. Params:
+     * `from` (default: the top entry page), `via` (a next page to follow further).
+     */
+    public function getJourneys(BxAnalyticsFilter $oFilter, $aParams = [])
+    {
+        $iLimit = 200000;
+
+        // each visit's pages in order, a page reloaded in place counted once
+        $aRead = $this->_oDb->getPageSequences($oFilter->get(), $iLimit);
+        $aSequences = $aRead['sequences'];
+
+        $aEntries = [];
+        $aPaths = [];
+        foreach($aSequences as $aSequence) {
+            $aEntries[$aSequence[0]] = (isset($aEntries[$aSequence[0]]) ? $aEntries[$aSequence[0]] : 0) + 1;
+            $sPath = implode("\n", array_slice($aSequence, 0, 3));
+            $aPaths[$sPath] = (isset($aPaths[$sPath]) ? $aPaths[$sPath] : 0) + 1;
+        }
+        arsort($aEntries);
+        arsort($aPaths);
+
+        $sFrom = (string)$this->_param($aParams, 'jfrom', '');
+        if($sFrom === '' || !isset($aEntries[$sFrom]) && !$this->_inSequences($aSequences, $sFrom))
+            $sFrom = !empty($aEntries) ? (string)key($aEntries) : '';
+        $sVia = (string)$this->_param($aParams, 'via', '');
+
+        // the page after the first visit of `jfrom`, and after `via` when it came right after
+        $iFromSessions = 0;
+        $aNext = [];
+        $iNextExits = 0;
+        $iViaSessions = 0;
+        $aAfter = [];
+        $iAfterExits = 0;
+        foreach($aSequences as $aSequence) {
+            $i = array_search($sFrom, $aSequence, true);
+            if($i === false)
+                continue;
+
+            $iFromSessions++;
+            if(!isset($aSequence[$i + 1])) {
+                $iNextExits++;
+                continue;
+            }
+
+            $sNext = $aSequence[$i + 1];
+            $aNext[$sNext] = (isset($aNext[$sNext]) ? $aNext[$sNext] : 0) + 1;
+
+            if($sVia !== '' && $sNext === $sVia) {
+                $iViaSessions++;
+                if(isset($aSequence[$i + 2]))
+                    $aAfter[$aSequence[$i + 2]] = (isset($aAfter[$aSequence[$i + 2]]) ? $aAfter[$aSequence[$i + 2]] : 0) + 1;
+                else
+                    $iAfterExits++;
+            }
+        }
+        arsort($aNext);
+        arsort($aAfter);
+
+        $fStep = function($aCounts, $iExits, $iTotal, $iLimit) {
+            $aResult = [];
+            foreach(array_slice($aCounts, 0, $iLimit, true) as $sPage => $iCount)
+                $aResult[] = ['page' => (string)$sPage, 'sessions' => $iCount, 'share' => $iTotal ? round(100 * $iCount / $iTotal, 1) : 0];
+            if($iExits)
+                $aResult[] = ['page' => '', 'exit' => true, 'sessions' => $iExits, 'share' => $iTotal ? round(100 * $iExits / $iTotal, 1) : 0];
+            return $aResult;
+        };
+
+        $iTotal = count($aSequences);
+        $aTopPaths = [];
+        foreach(array_slice($aPaths, 0, 10, true) as $sPath => $iCount)
+            $aTopPaths[] = ['pages' => explode("\n", (string)$sPath), 'sessions' => $iCount, 'share' => $iTotal ? round(100 * $iCount / $iTotal, 1) : 0];
+
+        $aEntryList = [];
+        foreach(array_slice($aEntries, 0, 10, true) as $sPage => $iCount)
+            $aEntryList[] = ['page' => (string)$sPage, 'sessions' => $iCount, 'active' => (string)$sPage === $sFrom];
+
+        return [
+            'jfrom' => $sFrom,
+            'via' => $sVia !== '' && $iViaSessions ? $sVia : '',
+            'sessions' => $iTotal,
+            'truncated' => $aRead['rows'] >= $iLimit,
+            'entries' => $aEntryList,
+            'from_sessions' => $iFromSessions,
+            'next' => $fStep($aNext, $iNextExits, $iFromSessions, 8),
+            'after' => $iViaSessions ? $fStep($aAfter, $iAfterExits, $iViaSessions, 8) : [],
+            'via_sessions' => $iViaSessions,
+            'paths' => $aTopPaths,
+        ];
+    }
+
+    /**
+     * Weekly cohorts by first visit and the share of each that came back in the weeks after.
+     * Tabs: members (stable ids) or all visitor ids (with cookieless ids a guest is new every
+     * day, so only persistent ids make guest retention meaningful).
+     */
+    public function getRetention(BxAnalyticsFilter $oFilter, $aParams = [])
+    {
+        $aTabs = $this->_aReportTabs['retention'];
+        $sTab = $this->_getTab($aParams, 'retention', $aTabs);
+        $aFilter = $oFilter->get();
+
+        $iWeeks = 12;
+        $iOffset = (int)$aFilter['offset'];
+        $fWeek = function($iTime) use ($iOffset) {
+            return (int)(floor(($iTime + $iOffset - 345600) / 604800) * 604800 + 345600 - $iOffset);
+        };
+
+        $iNow = (int)$aFilter['now'];
+        $iThisWeek = $fWeek($iNow);
+        $iFrom = $iThisWeek - ($iWeeks - 1) * 604800;
+
+        $aCohorts = [];
+        for($i = 0; $i < $iWeeks; $i++)
+            $aCohorts[$iFrom + $i * 604800] = ['size' => 0, 'back' => array_fill(0, $iWeeks - $i, 0)];
+
+        foreach($this->_oDb->getRetentionCohorts($iFrom, $iNow + 1, $aFilter['platform'], $aTabs[$sTab]) as $aRow) {
+            $iCohort = $iFrom + (int)$aRow['cohort'] * 604800;
+            if(!isset($aCohorts[$iCohort]))
+                continue;
+
+            $iVisitors = (int)$aRow['visitors'];
+            $iMask = (int)$aRow['weeks'];
+            $aCohorts[$iCohort]['size'] += $iVisitors;
+            foreach(array_keys($aCohorts[$iCohort]['back']) as $iIndex)
+                if($iMask & (1 << ((int)$aRow['cohort'] + $iIndex)))
+                    $aCohorts[$iCohort]['back'][$iIndex] += $iVisitors;
+        }
+
+        $aRows = [];
+        foreach($aCohorts as $iStart => $aCohort)
+            $aRows[] = [
+                'start' => $iStart,
+                'size' => $aCohort['size'],
+                'weeks' => array_map(function($iCount) use ($aCohort) {
+                    return $aCohort['size'] ? round(100 * $iCount / $aCohort['size'], 1) : null;
+                }, $aCohort['back']),
+            ];
+
+        return [
+            'tab' => $sTab,
+            'tabs' => $this->_getTabs($aTabs, $sTab, '_bx_analytics_tab_retention_'),
+            'weeks' => $iWeeks,
+            'tz' => $aFilter['tz'],
+            'rows' => $aRows,
+            'cookieless' => $sTab == 'visitors' && $this->_oConfig->getIdentityMode() == BX_ANALYTICS_IDENTITY_COOKIELESS,
+        ];
+    }
+
+    /**
+     * Goals with their conversions: visits and visitors that reached them, the conversion
+     * rate of all visits, and the change against the previous period.
+     */
+    public function getGoals(BxAnalyticsFilter $oFilter, $aParams = [])
+    {
+        $aFilter = $oFilter->get();
+        $aPrevious = $oFilter->getPrevious();
+
+        $aTotals = $this->_oDb->getTotals($aFilter);
+        $aTotalsPrevious = $this->_oDb->getTotals($aPrevious);
+        $iSessions = (int)$aTotals['sessions'];
+        $iSessionsPrevious = (int)$aTotalsPrevious['sessions'];
+
+        $aGoals = [];
+        foreach($this->_oDb->getGoals($this->_oConfig->CNF['MAX_GOALS']) as $aGoal) {
+            $aCurrent = $this->_oDb->getGoalTotals($aFilter, $aGoal);
+            $aBefore = $this->_oDb->getGoalTotals($aPrevious, $aGoal);
+            $fRate = $iSessions ? round(100 * $aCurrent['sessions'] / $iSessions, 2) : 0;
+            $fRateBefore = $iSessionsPrevious ? round(100 * $aBefore['sessions'] / $iSessionsPrevious, 2) : 0;
+
+            $aGoals[] = [
+                'id' => (int)$aGoal['id'],
+                'name' => $aGoal['name'],
+                'type' => $aGoal['type'],
+                'pattern' => $aGoal['pattern'],
+                'conversions' => (int)$aCurrent['sessions'],
+                'visitors' => (int)$aCurrent['visitors'],
+                'rate' => $fRate,
+                'change' => $this->_change($fRate, $fRateBefore),
+            ];
+        }
+
+        return [
+            'goals' => $aGoals,
+            'sessions' => $iSessions,
+            'manage' => $this->_oModule->checkAllowedManage() === CHECK_ACTION_RESULT_ALLOWED,
+            'edit' => (string)$this->_param($aParams, 'edit_goal', ''),
+        ];
+    }
+
+    /**
+     * A funnel's steps with the visits that reached each one in order within the visit, and
+     * the drop between steps. Param `funnel` (default: the first).
+     */
+    public function getFunnels(BxAnalyticsFilter $oFilter, $aParams = [])
+    {
+        $aFunnels = $this->_oDb->getFunnels();
+        $iFunnel = (int)$this->_param($aParams, 'funnel', 0);
+
+        $aFunnel = !empty($aFunnels) ? $aFunnels[0] : null;
+        foreach($aFunnels as $a)
+            if((int)$a['id'] === $iFunnel)
+                $aFunnel = $a;
+
+        $aSteps = [];
+        if($aFunnel) {
+            $aDefinition = json_decode($aFunnel['steps'], true);
+            $aDefinition = is_array($aDefinition) ? $aDefinition : [];
+
+            // each visit walks the steps in order: a step counts when it happens after the previous one
+            $aDefinition = array_values(array_filter($aDefinition, function($aStep) {
+                return is_array($aStep) && isset($aStep['type'], $aStep['pattern']);
+            }));
+            foreach($this->_oDb->getFunnelCounts($oFilter->get(), $aDefinition) as $i => $iSessions)
+                $aSteps[] = [
+                    'type' => $aDefinition[$i]['type'],
+                    'pattern' => $aDefinition[$i]['pattern'],
+                    'sessions' => $iSessions,
+                ];
+
+            $iFirst = !empty($aSteps) ? $aSteps[0]['sessions'] : 0;
+            foreach($aSteps as $i => $aStep) {
+                $aSteps[$i]['rate'] = $iFirst ? round(100 * $aStep['sessions'] / $iFirst, 1) : 0;
+                $aSteps[$i]['drop'] = $i > 0 && $aSteps[$i - 1]['sessions'] ? round(100 * ($aSteps[$i - 1]['sessions'] - $aStep['sessions']) / $aSteps[$i - 1]['sessions'], 1) : null;
+            }
+        }
+
+        $aList = [];
+        foreach($aFunnels as $a)
+            $aList[] = ['id' => (int)$a['id'], 'name' => $a['name'], 'active' => $aFunnel && (int)$a['id'] === (int)$aFunnel['id']];
+
+        return [
+            'funnels' => $aList,
+            'funnel' => $aFunnel ? ['id' => (int)$aFunnel['id'], 'name' => $aFunnel['name']] : null,
+            'steps' => $aSteps,
+            'manage' => $this->_oModule->checkAllowedManage() === CHECK_ACTION_RESULT_ALLOWED,
+            'edit' => (string)$this->_param($aParams, 'edit_funnel', ''),
+            'definition' => $aFunnel ? json_decode($aFunnel['steps'], true) : [],
+            'max_steps' => $this->_oConfig->CNF['MAX_FUNNEL_STEPS'],
+        ];
+    }
+
+    protected function _inSequences($aSequences, $sPage)
+    {
+        foreach($aSequences as $aSequence)
+            if(in_array($sPage, $aSequence, true))
+                return true;
+
+        return false;
+    }
+
+    /*
+     * Health: client errors and performance
+     */
+
+    /**
+     * Client errors grouped by fingerprint, with their hits, visitors and platforms in the
+     * period and a trend per group. With `error` (a group id) one group in detail. Tabs: open,
+     * new (first seen in the period), resolved, ignored, all.
+     */
+    public function getErrors(BxAnalyticsFilter $oFilter, $aParams = [])
+    {
+        $iError = (int)$this->_param($aParams, 'error', 0);
+        if($iError)
+            return $this->_getErrorDetail($oFilter, $iError);
+
+        // ignored errors keep counting but stay out of everything but their own tab
+        $aCounted = ['open', 'resolved'];
+        $aTabs = $this->_aReportTabs['errors'];
+        $sTab = $this->_getTab($aParams, 'errors', $aTabs);
+        $aFilter = $oFilter->get();
+        $iNewFrom = $sTab == 'new' ? (int)$aFilter['from'] : 0;
+
+        // crashes are fatal errors in the apps, against the apps' visits
+        $aApps = array_values(array_filter(array_keys($this->_oConfig->getPlatforms()), [$this->_oConfig, 'isAppPlatform']));
+        $aTotals = $this->_oDb->getErrorTotals($aFilter, $aCounted, $aApps);
+        $aBefore = $this->_oDb->getErrorTotals($oFilter->getPrevious(), $aCounted);
+        $aSessions = $this->_oDb->getSessionsPerPlatform($aFilter);
+        $iSessions = (int)array_sum($aSessions);
+        $iAppSessions = 0;
+        foreach($aApps as $sApp)
+            $iAppSessions += isset($aSessions[$sApp]) ? (int)$aSessions[$sApp] : 0;
+
+        $aTiles = [
+            ['title' => _t('_bx_analytics_metric_errors'), 'value' => (int)$aTotals['hits'], 'type' => 'number', 'metric' => 'errors', 'change' => $this->_change((int)$aTotals['hits'], (int)$aBefore['hits'])],
+            ['title' => _t('_bx_analytics_metric_error_sessions'), 'value' => $iSessions ? round(100 * $aTotals['sessions'] / $iSessions, 1) : null, 'type' => 'percent', 'sub' => _t('_bx_analytics_txt_n_sessions', (int)$aTotals['sessions'])],
+        ];
+        if($aFilter['platform'] === '' || $this->_oConfig->isAppPlatform($aFilter['platform']))
+            $aTiles[] = ['title' => _t('_bx_analytics_metric_crash_free'), 'value' => $iAppSessions ? round(100 - 100 * min($iAppSessions, (int)$aTotals['fatal_sessions']) / $iAppSessions, 2) : null, 'type' => 'percent_precise', 'sub' => _t('_bx_analytics_txt_crash_free_apps')];
+        $aTiles[] = ['title' => _t('_bx_analytics_metric_new_errors'), 'value' => $this->_oDb->getErrorListCount($aFilter, $aCounted, (int)$aFilter['from']), 'type' => 'number'];
+
+        $iTotal = $this->_oDb->getErrorListCount($aFilter, $aTabs[$sTab], $iNewFrom);
+        $iPages = max(1, (int)ceil($iTotal / $this->_iPerPage));
+        $iPage = min($iPages, max(1, (int)$this->_param($aParams, 'page_errors', 1)));
+
+        $aList = $this->_oDb->getErrorList($aFilter, $aTabs[$sTab], $iNewFrom, $this->_iPerPage, ($iPage - 1) * $this->_iPerPage);
+        $aIds = array_map('intval', array_column($aList, 'error_id'));
+        $aGroups = $this->_oDb->getErrorGroups($aIds);
+        $aTrends = $this->_oDb->getErrorTrends($aFilter, $aIds, $aFilter['bucket'], $aFilter['offset'], $aFilter['starts']);
+
+        $iMax = 1;
+        foreach($aList as $aRow)
+            $iMax = max($iMax, (int)$aRow['count']);
+
+        $aRows = [];
+        foreach($aList as $aRow) {
+            $iId = (int)$aRow['error_id'];
+            if(!isset($aGroups[$iId]))
+                continue;
+
+            $aGroup = $aGroups[$iId];
+            $aTrend = [];
+            foreach($aFilter['buckets'] as $iBucket)
+                $aTrend[] = isset($aTrends[$iId][$iBucket]) ? $aTrends[$iId][$iBucket] : 0;
+
+            $aRows[] = [
+                'label' => $this->getErrorLabel($aGroup),
+                'sub' => implode(' · ', array_filter([
+                    _t('_bx_analytics_error_kind_' . $aGroup['kind']),
+                    $this->_getErrorLocation($aGroup),
+                    (int)$aRow['fatal'] ? _t('_bx_analytics_txt_fatal') : '',
+                    $sTab == 'all' ? _t('_bx_analytics_error_status_' . $aGroup['status']) : '',
+                ])),
+                'open' => ['param' => 'error', 'value' => $iId],
+                'bar' => round(100 * $aRow['count'] / $iMax, 1),
+                'values' => [
+                    'trend' => $aTrend,
+                    'count' => (int)$aRow['count'],
+                    'users' => (int)$aRow['users'],
+                    'platforms' => $this->_getPlatformsText($aRow['platforms']),
+                    'first_seen' => (int)$aGroup['first_seen'],
+                    'last_seen' => (int)$aRow['last_in'],
+                ],
+            ];
+        }
+
+        return [
+            'view' => 'list',
+            'tiles' => $aTiles,
+            'name' => 'errors',
+            'caption' => _t('_bx_analytics_lmi_cpt_errors'),
+            'tab' => $sTab,
+            'tabs' => $this->_getTabs($aTabs, $sTab, '_bx_analytics_tab_errors_'),
+            'columns' => [
+                ['key' => 'label', 'type' => 'label', 'title' => _t('_bx_analytics_txt_error')],
+                ['key' => 'trend', 'type' => 'spark', 'title' => _t('_bx_analytics_txt_trend')],
+                ['key' => 'count', 'type' => 'number', 'title' => _t('_bx_analytics_metric_occurrences')],
+                ['key' => 'users', 'type' => 'number', 'title' => _t('_bx_analytics_metric_visitors')],
+                ['key' => 'platforms', 'type' => 'text', 'title' => _t('_bx_analytics_dim_platform')],
+                ['key' => 'last_seen', 'type' => 'datetime', 'title' => _t('_bx_analytics_txt_last_seen')],
+            ],
+            'rows' => $aRows,
+            'page' => $iPage,
+            'pages' => $iPages,
+            'empty' => _t($sTab == 'open' || $sTab == 'new' ? '_bx_analytics_txt_no_errors' : '_bx_analytics_txt_no_data'),
+        ];
+    }
+
+    /**
+     * One error group: what and where, its hits over time and by client, and the latest
+     * occurrences. The stack and the visits behind the occurrences can carry what a visitor
+     * typed or opened, so they need the people permission.
+     */
+    protected function _getErrorDetail(BxAnalyticsFilter $oFilter, $iId)
+    {
+        $aGroup = $this->_oDb->getErrorGroup($iId);
+        if(empty($aGroup))
+            return ['view' => 'detail', 'missing' => true, 'title' => _t('_bx_analytics_txt_error_missing')];
+
+        $aFilter = $oFilter->get();
+        $bPeople = $this->_oModule->checkAllowedPeople() === CHECK_ACTION_RESULT_ALLOWED;
+        $bManage = $this->_oModule->checkAllowedManage() === CHECK_ACTION_RESULT_ALLOWED;
+
+        $aStats = $this->_oDb->getErrorStats($aFilter, $iId);
+        $aRows = $this->_oDb->getErrorSeries($aFilter, $iId, $aFilter['bucket'], $aFilter['offset'], $aFilter['starts']);
+        $aCounts = [];
+        $aUsers = [];
+        foreach($aFilter['buckets'] as $iBucket) {
+            $aCounts[] = isset($aRows[$iBucket]) ? (int)$aRows[$iBucket]['count'] : 0;
+            $aUsers[] = isset($aRows[$iBucket]) ? (int)$aRows[$iBucket]['users'] : 0;
+        }
+
+        // where it happens: a table per client column that has values
+        $aBreakdowns = [];
+        foreach(['platform', 'route', 'browser', 'os', 'device', 'app_version', 'country'] as $sColumn) {
+            $aValues = $this->_oDb->getErrorBreakdown($aFilter, $iId, $sColumn);
+            if(empty($aValues) || (count($aValues) == 1 && $aValues[0]['value'] === ''))
+                continue;
+
+            $iTotal = 0;
+            foreach($aValues as $aValue)
+                $iTotal += (int)$aValue['count'];
+
+            $aBreakdownRows = [];
+            foreach($aValues as $aValue)
+                $aBreakdownRows[] = [
+                    'label' => $this->getLabel($sColumn, (string)$aValue['value']),
+                    'bar' => $iTotal ? round(100 * $aValue['count'] / $iTotal, 1) : 0,
+                    'values' => [
+                        'count' => (int)$aValue['count'],
+                        'share' => $iTotal ? round(100 * $aValue['count'] / $iTotal, 1) : 0,
+                    ],
+                ];
+
+            $sTitle = _t($sColumn == 'route' ? '_bx_analytics_txt_route' : '_bx_analytics_dim_' . $sColumn);
+            $aBreakdowns[] = [
+                'name' => 'error_' . $sColumn,
+                'caption' => $sTitle,
+                'columns' => [
+                    ['key' => 'label', 'type' => 'label', 'title' => $sTitle],
+                    ['key' => 'count', 'type' => 'number', 'title' => _t('_bx_analytics_metric_occurrences')],
+                    ['key' => 'share', 'type' => 'percent', 'title' => _t('_bx_analytics_txt_share')],
+                ],
+                'rows' => $aBreakdownRows,
+            ];
+        }
+
+        $aOccurrences = [];
+        foreach($this->_oDb->getErrorOccurrences($aFilter, $iId) as $aHit) {
+            $aRow = [
+                'label' => $aHit['path'] !== '' ? $aHit['path'] : '/',
+                'sub' => implode(' · ', array_filter([
+                    $this->getLabel('platform', $aHit['platform']),
+                    $aHit['app_version'],
+                    $aHit['device'] !== '' ? $this->getLabel('device', $aHit['device']) : '',
+                    $aHit['browser'],
+                    $aHit['os'],
+                    $aHit['country'] !== '' ? $this->getLabel('country', $aHit['country']) : '',
+                ])),
+                'values' => [
+                    'ts' => (int)$aHit['ts'],
+                    'repeats' => (int)$aHit['repeats'],
+                ],
+            ];
+            // the visit behind it, for those who may see visits (Studio is for administrators)
+            if($bPeople && (int)$aHit['session_id'] && isAdmin())
+                $aRow['link'] = BX_DOL_URL_STUDIO . 'module.php?name=' . $this->_oModule->getName() . '&page=sessions&session=' . (int)$aHit['session_id'];
+            $aOccurrences[] = $aRow;
+        }
+
+        $aActions = [];
+        if($bManage)
+            foreach(['resolved', 'ignored', 'open'] as $sStatus)
+                if($sStatus != $aGroup['status'])
+                    $aActions[] = ['op' => $sStatus, 'title' => _t('_bx_analytics_txt_error_mark_' . $sStatus)];
+
+        $sLocation = $this->_getErrorLocation($aGroup, true);
+
+        return [
+            'view' => 'detail',
+            'id' => (int)$aGroup['id'],
+            'title' => $this->getErrorLabel($aGroup),
+            'status' => $aGroup['status'],
+            'facts' => array_values(array_filter([
+                ['title' => _t('_bx_analytics_txt_error_status'), 'value' => _t('_bx_analytics_error_status_' . $aGroup['status']), 'type' => 'text'],
+                ['title' => _t('_bx_analytics_txt_error_kind'), 'value' => _t('_bx_analytics_error_kind_' . $aGroup['kind']) . ((int)$aGroup['fatal'] ? ' · ' . _t('_bx_analytics_txt_fatal') : ''), 'type' => 'text'],
+                $sLocation !== '' ? ['title' => _t('_bx_analytics_txt_error_source'), 'value' => $sLocation, 'type' => 'text'] : null,
+                (int)$aGroup['status_code'] ? ['title' => _t('_bx_analytics_txt_error_http_status'), 'value' => (string)(int)$aGroup['status_code'], 'type' => 'text'] : null,
+                ['title' => _t('_bx_analytics_txt_first_seen'), 'value' => (int)$aGroup['first_seen'], 'type' => 'datetime'],
+                ['title' => _t('_bx_analytics_txt_last_seen'), 'value' => (int)$aGroup['last_seen'], 'type' => 'datetime'],
+                ['title' => _t('_bx_analytics_txt_error_all_time'), 'value' => (int)$aGroup['occurrences'], 'type' => 'number'],
+            ])),
+            'stack' => $bPeople ? (string)$aGroup['stack'] : '',
+            'stack_hidden' => !$bPeople && (string)$aGroup['stack'] !== '',
+            'tiles' => [
+                ['title' => _t('_bx_analytics_metric_occurrences'), 'value' => (int)$aStats['count'], 'type' => 'number'],
+                ['title' => _t('_bx_analytics_metric_visitors'), 'value' => (int)$aStats['users'], 'type' => 'number'],
+                ['title' => _t('_bx_analytics_metric_sessions'), 'value' => (int)$aStats['sessions'], 'type' => 'number'],
+            ],
+            'chart' => [
+                'kind' => 'lines',
+                'labels' => $aFilter['buckets'],
+                'bucket' => $aFilter['bucket'],
+                'tz' => $aFilter['tz'],
+                'datasets' => [
+                    ['key' => 'count', 'label' => _t('_bx_analytics_metric_occurrences'), 'values' => $aCounts],
+                    ['key' => 'users', 'label' => _t('_bx_analytics_metric_visitors'), 'values' => $aUsers],
+                ],
+            ],
+            'breakdowns' => $aBreakdowns,
+            'occurrences' => [
+                'name' => 'error_hits',
+                'caption' => _t('_bx_analytics_txt_error_latest'),
+                'columns' => [
+                    ['key' => 'label', 'type' => 'label', 'title' => _t('_bx_analytics_tab_pages')],
+                    ['key' => 'repeats', 'type' => 'number', 'title' => _t('_bx_analytics_metric_occurrences')],
+                    ['key' => 'ts', 'type' => 'datetime', 'title' => _t('_bx_analytics_txt_time')],
+                ],
+                'rows' => $aOccurrences,
+            ],
+            'manage' => $bManage,
+            'actions' => $aActions,
+        ];
+    }
+
+    /**
+     * What an error group is called in lists: its message, or a name for the catch-all group.
+     */
+    public function getErrorLabel($aGroup)
+    {
+        if($aGroup['kind'] == BxAnalyticsErrors::KIND_OVERFLOW)
+            return _t('_bx_analytics_txt_error_overflow');
+
+        return $aGroup['message'] !== '' ? $aGroup['message'] : _t('_bx_analytics_txt_unknown');
+    }
+
+    /**
+     * Script and position of an error, file:line or file:line:column.
+     */
+    protected function _getErrorLocation($aGroup, $bColumn = false)
+    {
+        if($aGroup['source'] === '')
+            return '';
+
+        $sLocation = $aGroup['source'];
+        if((int)$aGroup['line'])
+            $sLocation .= ':' . (int)$aGroup['line'] . ($bColumn && (int)$aGroup['col'] ? ':' . (int)$aGroup['col'] : '');
+
+        return $sLocation;
+    }
+
+    /**
+     * Web vitals and app timings: the 75th percentile of each metric with the share of good,
+     * needing improvement and poor values; the chosen metric over time, by platform and by
+     * page. Params: `vital` (metric), `vital_device` (all, mobile, desktop), tab
+     * (`tab_performance`): route or path.
+     */
+    public function getPerformance(BxAnalyticsFilter $oFilter, $aParams = [])
+    {
+        $aFilter = $oFilter->get();
+        $aThresholds = $this->_oConfig->CNF['VITALS'];
+        $iMinSamples = 20;
+
+        // the metrics the platform has: web vitals in browsers, screen loads and starts in apps
+        $sPlatform = $aFilter['platform'];
+        $aMetrics = BxAnalyticsVitals::METRICS;
+        if($sPlatform !== '' && $this->_oConfig->isBrowserPlatform($sPlatform))
+            $aMetrics = BxAnalyticsVitals::WEB_METRICS;
+        elseif($sPlatform !== '' && $this->_oConfig->isAppPlatform($sPlatform))
+            $aMetrics = BxAnalyticsVitals::APP_METRICS;
+
+        $sMetric = (string)$this->_param($aParams, 'vital', '');
+        if(!in_array($sMetric, $aMetrics, true))
+            $sMetric = $aMetrics[0];
+
+        $sDevice = (string)$this->_param($aParams, 'vital_device', 'all');
+        if(!in_array($sDevice, ['all', 'mobile', 'desktop'], true))
+            $sDevice = 'all';
+
+        $aGroups = $this->_aReportTabs['performance'];
+        $sGroup = $this->_getTab($aParams, 'performance', $aGroups);
+
+        // the cards: exact counts on each side of the thresholds, and the histograms of the
+        // metrics with enough samples, each in one pass
+        $aDistribution = $this->_oDb->getVitalsDistribution($aFilter, $sDevice, $aThresholds);
+        $aCardMetrics = [];
+        foreach($aMetrics as $sCardMetric)
+            if($aDistribution[$sCardMetric]['samples'] >= $iMinSamples)
+                $aCardMetrics[] = $sCardMetric;
+        list($aCardHistograms) = $this->_getHistograms($this->_oDb->getVitalsHistograms($aFilter, $aCardMetrics, $sDevice));
+
+        $aCards = [];
+        foreach($aMetrics as $sCardMetric) {
+            $aCount = $aDistribution[$sCardMetric];
+            $iSamples = $aCount['samples'];
+
+            $mixedP75 = null;
+            if(isset($aCardHistograms[$sCardMetric]))
+                $mixedP75 = BxAnalyticsVitals::getPercentile($aCardHistograms[$sCardMetric], 0.75, $iMinSamples, [$aCount['good'], $iSamples - $aCount['good'] - $aCount['poor'], $aCount['poor']], $aThresholds[$sCardMetric]);
+
+            $aCards[] = [
+                'metric' => $sCardMetric,
+                'title' => _t('_bx_analytics_vital_' . $sCardMetric),
+                'unit' => BxAnalyticsVitals::getUnit($sCardMetric),
+                'value' => $mixedP75 !== null ? BxAnalyticsVitals::toDisplay($sCardMetric, round($mixedP75)) : null,
+                'rating' => $mixedP75 !== null ? BxAnalyticsVitals::getRating(round($mixedP75), $aThresholds[$sCardMetric]) : '',
+                'samples' => $iSamples,
+                'good' => $iSamples ? round(100 * $aCount['good'] / $iSamples, 1) : 0,
+                'needs_improvement' => $iSamples ? round(100 * ($iSamples - $aCount['good'] - $aCount['poor']) / $iSamples, 1) : 0,
+                'poor' => $iSamples ? round(100 * $aCount['poor'] / $iSamples, 1) : 0,
+                'good_max' => BxAnalyticsVitals::toDisplay($sCardMetric, $aThresholds[$sCardMetric][0]),
+                'poor_min' => BxAnalyticsVitals::toDisplay($sCardMetric, $aThresholds[$sCardMetric][1]),
+                'active' => $sCardMetric == $sMetric,
+            ];
+        }
+
+        $sUnit = BxAnalyticsVitals::getUnit($sMetric);
+        $fP75 = function($aHistogram, $aSides, $iMin) use ($sMetric, $aThresholds) {
+            $mixedValue = BxAnalyticsVitals::getPercentile($aHistogram, 0.75, $iMin, $aSides, $aThresholds[$sMetric]);
+            return $mixedValue !== null ? BxAnalyticsVitals::toDisplay($sMetric, round($mixedValue)) : null;
+        };
+        $fRating = function($mixedValue) use ($sMetric, $aThresholds) {
+            return $mixedValue !== null ? BxAnalyticsVitals::getRating($sMetric == 'cls' ? round($mixedValue * 1000) : $mixedValue, $aThresholds[$sMetric]) : '';
+        };
+
+        // the 75th percentile per time bucket, with the thresholds as lines; a bucket with few
+        // samples is left as a gap
+        list($aByBucket, $aBucketSides) = $this->_getHistograms($this->_oDb->getVitalsHistogram($aFilter, $sMetric, $sDevice, 'bucket', [], [$aFilter['bucket'], $aFilter['offset'], $aFilter['starts']]));
+        $aValues = [];
+        foreach($aFilter['buckets'] as $iBucket)
+            $aValues[] = isset($aByBucket[$iBucket]) ? $fP75($aByBucket[$iBucket], $aBucketSides[$iBucket], 5) : null;
+        $iCount = count($aFilter['buckets']);
+
+        $aPlatformRows = [];
+        list($aByPlatform, $aPlatformSides) = $this->_getHistograms($this->_oDb->getVitalsHistogram($aFilter, $sMetric, $sDevice, 'platform'));
+        foreach($aByPlatform as $sValue => $aHistogram) {
+            $mixedValue = $fP75($aHistogram, $aPlatformSides[$sValue], $iMinSamples);
+            $aPlatformRows[] = [
+                'label' => $this->getLabel('platform', (string)$sValue),
+                'values' => ['p75' => $mixedValue, 'rating' => $fRating($mixedValue), 'samples' => (int)array_sum($aHistogram)],
+            ];
+        }
+        usort($aPlatformRows, function($a, $b) { return $b['values']['samples'] - $a['values']['samples']; });
+
+        $iTotal = $this->_oDb->getVitalsPagesCount($aFilter, $sMetric, $sDevice, $sGroup);
+        $iPages = max(1, (int)ceil($iTotal / $this->_iPerPage));
+        $iPage = min($iPages, max(1, (int)$this->_param($aParams, 'page_performance', 1)));
+        $aPages = $this->_oDb->getVitalsPages($aFilter, $sMetric, $sDevice, $sGroup, $this->_iPerPage, ($iPage - 1) * $this->_iPerPage);
+        list($aPageHistograms, $aPageSides) = $this->_getHistograms($this->_oDb->getVitalsHistogram($aFilter, $sMetric, $sDevice, $sGroup, array_map('strval', array_keys($aPages))));
+
+        $iMax = 1;
+        foreach($aPages as $iSamples)
+            $iMax = max($iMax, (int)$iSamples);
+
+        $aPageRows = [];
+        foreach($aPages as $sValue => $iSamples) {
+            $mixedValue = isset($aPageHistograms[$sValue]) ? $fP75($aPageHistograms[$sValue], $aPageSides[$sValue], $iMinSamples) : null;
+            $aPageRows[] = [
+                'label' => (string)$sValue,
+                'bar' => round(100 * $iSamples / $iMax, 1),
+                'values' => ['p75' => $mixedValue, 'rating' => $fRating($mixedValue), 'samples' => (int)$iSamples],
+            ];
+        }
+
+        $aColumns = function($sTitle) use ($sUnit) {
+            return [
+                ['key' => 'label', 'type' => 'label', 'title' => $sTitle],
+                ['key' => 'p75', 'type' => $sUnit, 'title' => _t('_bx_analytics_txt_p75')],
+                ['key' => 'rating', 'type' => 'rating', 'title' => _t('_bx_analytics_txt_rating')],
+                ['key' => 'samples', 'type' => 'number', 'title' => _t('_bx_analytics_txt_samples')],
+            ];
+        };
+
+        $aDevices = [];
+        foreach(['all', 'mobile', 'desktop'] as $sValue)
+            $aDevices[] = ['value' => $sValue, 'title' => _t('_bx_analytics_txt_device_' . $sValue), 'selected' => $sValue == $sDevice];
+
+        return [
+            'metric' => $sMetric,
+            'metric_title' => _t('_bx_analytics_vital_' . $sMetric),
+            'unit' => $sUnit,
+            'device' => $sDevice,
+            'devices' => $aDevices,
+            'min_samples' => $iMinSamples,
+            'cards' => $aCards,
+            'chart' => [
+                'kind' => 'lines',
+                'metric' => $sUnit,
+                'labels' => $aFilter['buckets'],
+                'bucket' => $aFilter['bucket'],
+                'tz' => $aFilter['tz'],
+                'datasets' => [
+                    ['key' => 'p75', 'label' => _t('_bx_analytics_txt_p75'), 'values' => $aValues],
+                    ['key' => 'good', 'label' => _t('_bx_analytics_txt_threshold_good'), 'values' => array_fill(0, $iCount, BxAnalyticsVitals::toDisplay($sMetric, $aThresholds[$sMetric][0])), 'dashed' => true, 'tone' => 'good'],
+                    ['key' => 'poor', 'label' => _t('_bx_analytics_txt_threshold_poor'), 'values' => array_fill(0, $iCount, BxAnalyticsVitals::toDisplay($sMetric, $aThresholds[$sMetric][1])), 'dashed' => true, 'tone' => 'bad'],
+                ],
+            ],
+            'platforms' => [
+                'name' => 'vital_platforms',
+                'caption' => _t('_bx_analytics_tab_platforms'),
+                'columns' => $aColumns(_t('_bx_analytics_dim_platform')),
+                'rows' => $aPlatformRows,
+                'empty' => _t('_bx_analytics_txt_no_vitals'),
+            ],
+            // named after the block, so its pager and the tabs (which reset the page) share page_performance
+            'pages' => [
+                'name' => 'performance',
+                'caption' => _t('_bx_analytics_txt_vitals_pages'),
+                'tab' => $sGroup,
+                'tabs' => $this->_getTabs($aGroups, $sGroup, '_bx_analytics_tab_vitals_'),
+                'columns' => $aColumns(_t($sGroup == 'route' ? '_bx_analytics_txt_route' : '_bx_analytics_tab_pages')),
+                'rows' => $aPageRows,
+                'page' => $iPage,
+                'pages' => $iPages,
+                'empty' => _t('_bx_analytics_txt_no_vitals'),
+            ],
+        ];
+    }
+
+    /**
+     * Histogram rows (grp, hb, n, and side when the query has it) as [[group => [bucket =>
+     * count]], [group => [counts up to the good threshold, between, above the poor one]]].
+     */
+    protected function _getHistograms($aRows)
+    {
+        $aHistograms = [];
+        $aSides = [];
+        foreach($aRows as $aRow) {
+            $sGroup = (string)$aRow['grp'];
+            $iBucket = (int)$aRow['hb'];
+            $aHistograms[$sGroup][$iBucket] = (isset($aHistograms[$sGroup][$iBucket]) ? $aHistograms[$sGroup][$iBucket] : 0) + (int)$aRow['n'];
+
+            if(!isset($aSides[$sGroup]))
+                $aSides[$sGroup] = isset($aRow['side']) ? [0, 0, 0] : [];
+            if(isset($aRow['side']))
+                $aSides[$sGroup][min(2, max(0, (int)$aRow['side']))] += (int)$aRow['n'];
+        }
+
+        return [$aHistograms, $aSides];
+    }
+
+    /*
+     * Revenue. Amounts come from the ledger in the site's default currency, as the payment
+     * module recorded them; orders paid with credits are reported apart from money.
+     */
+
+    /**
+     * Gross and net revenue, refunds, orders, paying customers and revenue per customer, over
+     * time and split by source, provider, platform and the buyers' traffic sources. Param
+     * `tab` (or tab_revenue): sources, providers, platforms, first_touch, last_touch, campaigns.
+     */
+    public function getRevenue(BxAnalyticsFilter $oFilter, $aParams = [])
+    {
+        $aMoney = $this->getMoney();
+        $aFilter = $this->_getRevenueFilter($oFilter->get(), $aMoney);
+        $aPrevious = $this->_getRevenueFilter($oFilter->getPrevious(), $aMoney);
+
+        $aCurrent = $this->_oDb->getRevenueTotals($aFilter);
+        $aBefore = $this->_oDb->getRevenueTotals($aPrevious);
+
+        $aTiles = [];
+        foreach(['gross' => 'money', 'refunds' => 'money', 'net' => 'money', 'orders' => 'number', 'customers' => 'number', 'arppu' => 'money'] as $sKey => $sType) {
+            $fValue = $sKey == 'arppu' ? ((int)$aCurrent['customers'] ? (float)$aCurrent['net'] / (int)$aCurrent['customers'] : 0) : (float)$aCurrent[$sKey];
+            $fPrevious = $sKey == 'arppu' ? ((int)$aBefore['customers'] ? (float)$aBefore['net'] / (int)$aBefore['customers'] : 0) : (float)$aBefore[$sKey];
+
+            $aTiles[] = [
+                'key' => $sKey,
+                'title' => _t('_bx_analytics_metric_revenue_' . $sKey),
+                'value' => $sType == 'money' ? round($fValue, 2) : (int)$fValue,
+                'previous' => $sType == 'money' ? round($fPrevious, 2) : (int)$fPrevious,
+                'change' => $this->_change($fValue, $fPrevious),
+                'type' => $sType,
+                'metric' => $sKey,
+            ];
+        }
+
+        $aRows = $this->_oDb->getRevenueSeries($aFilter);
+        $aSeries = ['gross' => [], 'net' => [], 'refunds' => []];
+        foreach($aFilter['buckets'] as $iBucket)
+            foreach(array_keys($aSeries) as $sKey)
+                $aSeries[$sKey][] = $iBucket > $aFilter['now'] ? null : (isset($aRows[$iBucket]) ? round((float)$aRows[$iBucket][$sKey], 2) : 0);
+
+        $aDatasets = [];
+        foreach($aSeries as $sKey => $aValues)
+            $aDatasets[] = ['key' => $sKey, 'label' => _t('_bx_analytics_metric_revenue_' . $sKey), 'values' => $aValues];
+
+        $aTabs = $this->_aReportTabs['revenue'];
+        $sTab = $this->_getTab($aParams, 'revenue', $aTabs);
+
+        $fNet = (float)$aCurrent['net'];
+        $aSplit = [];
+        foreach($this->_oDb->getRevenueSplit($aFilter, $aTabs[$sTab], 25) as $aRow) {
+            $fRowNet = (float)$aRow['net'];
+            $aSplit[] = [
+                'label' => $this->getRevenueLabel($aTabs[$sTab], (string)$aRow['value'], $aMoney['payment']),
+                'bar' => $fNet > 0 ? max(0, round(100 * $fRowNet / $fNet, 1)) : 0,
+                'values' => [
+                    'net' => round($fRowNet, 2),
+                    'share' => $fNet > 0 ? round(100 * $fRowNet / $fNet, 1) : 0,
+                    'customers' => (int)$aRow['customers'],
+                    'orders' => (int)$aRow['orders'],
+                ],
+            ];
+        }
+
+        return [
+            'money' => $aMoney,
+            'tiles' => $aTiles,
+            'chart' => [
+                'kind' => 'lines',
+                'metric' => 'money',
+                'currency' => $aMoney['code'],
+                'labels' => $aFilter['buckets'],
+                'bucket' => $aFilter['bucket'],
+                'tz' => $aFilter['tz'],
+                'datasets' => $aDatasets,
+            ],
+            'split' => [
+                'name' => 'revenue',
+                'caption' => _t('_bx_analytics_txt_revenue_split'),
+                'tab' => $sTab,
+                'tabs' => $this->_getTabs($aTabs, $sTab, '_bx_analytics_tab_revenue_'),
+                'columns' => [
+                    ['key' => 'label', 'type' => 'label', 'title' => _t('_bx_analytics_tab_revenue_' . $sTab)],
+                    ['key' => 'net', 'type' => 'money', 'title' => _t('_bx_analytics_metric_revenue_net')],
+                    ['key' => 'share', 'type' => 'percent', 'title' => _t('_bx_analytics_txt_share')],
+                    ['key' => 'customers', 'type' => 'number', 'title' => _t('_bx_analytics_metric_revenue_customers')],
+                    ['key' => 'orders', 'type' => 'number', 'title' => _t('_bx_analytics_metric_revenue_orders')],
+                ],
+                'rows' => $aSplit,
+                'empty' => _t('_bx_analytics_txt_no_revenue'),
+            ],
+            'notes' => $this->_getRevenueNotes($aMoney, (int)$aCurrent['unconverted']),
+            'import' => $this->_getImportStatus(),
+        ];
+    }
+
+    /**
+     * Monthly recurring revenue, new, renewed and cancelled subscriptions and churn per
+     * bucket, the plans that bring the MRR, and why members cancel.
+     */
+    public function getSubscriptions(BxAnalyticsFilter $oFilter, $aParams = [])
+    {
+        $aMoney = $this->getMoney();
+        $aFilter = $this->_getRevenueFilter($oFilter->get(), $aMoney);
+        $aPrevious = $this->_getRevenueFilter($oFilter->getPrevious(), $aMoney);
+        $iNow = (int)$aFilter['now'];
+
+        bx_import('Revenue', $this->_oModule->_aModule);
+
+        // subscriptions alive in either period, read once
+        $aRows = $this->_oDb->getSubscriptionRows(array_merge($aFilter, ['from' => $aPrevious['from']]), [
+            'unit_seconds' => BxAnalyticsRevenue::getUnitSeconds(),
+            'unpaid' => BxAnalyticsRevenue::STATUS_UNPAID,
+            'grace' => BxAnalyticsRevenue::UNPAID_GRACE,
+        ]);
+
+        list($aCurrent) = $this->_sweepSubscriptions($aRows, [[$aFilter['from'], min($aFilter['to'], $iNow)]]);
+        list($aBefore) = $this->_sweepSubscriptions($aRows, [[$aPrevious['from'], min($aPrevious['to'], $iNow)]]);
+
+        $aTiles = [];
+        foreach(['mrr' => 'money', 'active' => 'number', 'trials' => 'number', 'new' => 'number', 'cancelled' => 'number', 'churn' => 'percent'] as $sKey => $sType) {
+            $sSub = '';
+            if($sKey == 'trials' && $aCurrent['trial_mrr'] > 0)
+                $sSub = _t('_bx_analytics_txt_sbs_trial_mrr', _t_format_currency($aCurrent['trial_mrr']));
+            else if($sKey == 'churn' && $aCurrent['churn'] === null)
+                $sSub = _t('_bx_analytics_txt_sbs_churn_no_base');
+
+            $aTiles[] = [
+                'key' => $sKey,
+                'title' => _t('_bx_analytics_metric_sbs_' . $sKey),
+                'value' => $aCurrent[$sKey],
+                'previous' => $aBefore[$sKey],
+                'change' => $aCurrent[$sKey] !== null && $aBefore[$sKey] !== null ? $this->_change($aCurrent[$sKey], $aBefore[$sKey]) : null,
+                'type' => $sType,
+                'metric' => $sKey,
+                'sub' => $sSub,
+            ];
+        }
+
+        // per bucket, within the period (the first bucket may start before it); renewals paid
+        // with credits too, like the subscriptions
+        $aRenewals = $this->_oDb->getRevenueSeries($aFilter, null);
+        $aBuckets = $aFilter['buckets'];
+        $aSpans = [];
+        foreach($aBuckets as $i => $iBucket)
+            if($iBucket <= $iNow)
+                $aSpans[$i] = [max($iBucket, $aFilter['from']), min(isset($aBuckets[$i + 1]) ? $aBuckets[$i + 1] : $aFilter['to'], $iNow)];
+        $aSwept = array_combine(array_keys($aSpans), $this->_sweepSubscriptions($aRows, $aSpans));
+
+        $aMrr = $aNew = $aRenewed = $aCancelled = [];
+        foreach($aBuckets as $i => $iBucket) {
+            if(!isset($aSwept[$i])) {
+                $aMrr[] = $aNew[] = $aRenewed[] = $aCancelled[] = null;
+                continue;
+            }
+
+            $aMrr[] = $aSwept[$i]['mrr'];
+            $aNew[] = $aSwept[$i]['new'];
+            $aCancelled[] = $aSwept[$i]['cancelled'];
+            $aRenewed[] = isset($aRenewals[$iBucket]) ? (int)$aRenewals[$iBucket]['renewals'] : 0;
+        }
+
+        // plans paying at the end of the period, by MRR
+        $iEnd = min($aFilter['to'], $iNow);
+        $aPlans = [];
+        foreach($aRows as $aRow) {
+            if(!$this->_isPaying($aRow, $iEnd))
+                continue;
+
+            $sKey = $aRow['module'] . '|' . (int)$aRow['item_id'];
+            if(!isset($aPlans[$sKey]))
+                $aPlans[$sKey] = ['module' => $aRow['module'], 'item_id' => (int)$aRow['item_id'], 'active' => 0, 'mrr' => 0];
+            $aPlans[$sKey]['active']++;
+            $aPlans[$sKey]['mrr'] += (float)$aRow['mrr_default'];
+        }
+        uasort($aPlans, function($a, $b) { return $b['mrr'] <=> $a['mrr']; });
+
+        $aPlanRows = [];
+        foreach(array_slice($aPlans, 0, 10) as $aPlan) {
+            $aItem = $this->_getCartItem($aPlan['module'], $aPlan['item_id']);
+            $aPlanRows[] = [
+                'label' => $aItem['title'],
+                'sub' => $aItem['module_title'],
+                'link' => $aItem['url'],
+                'bar' => $aCurrent['mrr'] > 0 ? round(100 * $aPlan['mrr'] / $aCurrent['mrr'], 1) : 0,
+                'values' => ['active' => $aPlan['active'], 'mrr' => round($aPlan['mrr'], 2)],
+            ];
+        }
+
+        $aReasons = $this->_oDb->getSubscriptionReasons($aFilter, 10);
+        $iReasons = array_sum($aReasons);
+        $aReasonRows = [];
+        foreach($aReasons as $sReason => $iCount)
+            $aReasonRows[] = [
+                'label' => $sReason !== '' && $sReason !== 'cancel' ? $sReason : _t('_bx_analytics_txt_sbs_no_reason'),
+                'bar' => $iReasons ? round(100 * $iCount / $iReasons, 1) : 0,
+                'values' => ['count' => (int)$iCount, 'share' => $iReasons ? round(100 * $iCount / $iReasons, 1) : 0],
+            ];
+
+        $aLabels = $aFilter['buckets'];
+
+        return [
+            'money' => $aMoney,
+            'tiles' => $aTiles,
+            'chart' => [
+                'kind' => 'lines',
+                'metric' => 'money',
+                'currency' => $aMoney['code'],
+                'labels' => $aLabels,
+                'bucket' => $aFilter['bucket'],
+                'tz' => $aFilter['tz'],
+                'datasets' => [['key' => 'mrr', 'label' => _t('_bx_analytics_metric_sbs_mrr'), 'values' => $aMrr]],
+            ],
+            'movement' => [
+                'kind' => 'stacked',
+                'metric' => 'number',
+                'labels' => $aLabels,
+                'bucket' => $aFilter['bucket'],
+                'tz' => $aFilter['tz'],
+                'datasets' => [
+                    ['key' => 'new', 'label' => _t('_bx_analytics_metric_sbs_new'), 'values' => $aNew],
+                    ['key' => 'renewed', 'label' => _t('_bx_analytics_metric_sbs_renewed'), 'values' => $aRenewed],
+                    ['key' => 'cancelled', 'label' => _t('_bx_analytics_metric_sbs_cancelled'), 'values' => $aCancelled],
+                ],
+            ],
+            'plans' => [
+                'name' => 'sbs_plans',
+                'caption' => _t('_bx_analytics_txt_sbs_plans'),
+                'columns' => [
+                    ['key' => 'label', 'type' => 'label', 'title' => _t('_bx_analytics_txt_sbs_plan')],
+                    ['key' => 'active', 'type' => 'number', 'title' => _t('_bx_analytics_metric_sbs_active')],
+                    ['key' => 'mrr', 'type' => 'money', 'title' => _t('_bx_analytics_metric_sbs_mrr')],
+                ],
+                'rows' => $aPlanRows,
+                'empty' => _t('_bx_analytics_txt_no_subscriptions'),
+            ],
+            'reasons' => [
+                'name' => 'sbs_reasons',
+                'caption' => _t('_bx_analytics_txt_sbs_reasons'),
+                'columns' => [
+                    ['key' => 'label', 'type' => 'label', 'title' => _t('_bx_analytics_txt_sbs_reason')],
+                    ['key' => 'count', 'type' => 'number', 'title' => _t('_bx_analytics_metric_sbs_cancelled')],
+                    ['key' => 'share', 'type' => 'percent', 'title' => _t('_bx_analytics_txt_share')],
+                ],
+                'rows' => $aReasonRows,
+                'empty' => _t('_bx_analytics_txt_no_cancellations'),
+            ],
+            'notes' => $this->_getRevenueNotes($aMoney, 0),
+        ];
+    }
+
+    /**
+     * The items that sell best, the sellers of a multi-vendor site, the site's own sales
+     * against its vendors' and the commissions they paid, and commission invoices.
+     */
+    public function getSales(BxAnalyticsFilter $oFilter, $aParams = [])
+    {
+        $aMoney = $this->getMoney();
+        $aFilter = $this->_getRevenueFilter($oFilter->get(), $aMoney);
+
+        $aTotals = $this->_oDb->getRevenueTotals($aFilter);
+        $fNet = (float)$aTotals['net'];
+
+        $aItems = [];
+        foreach($this->_oDb->getRevenueTopItems($aFilter, 10) as $aRow) {
+            $aItem = $this->_getCartItem($aRow['module'], (int)$aRow['item_id']);
+            $aItems[] = [
+                'label' => $aItem['title'],
+                'sub' => $aItem['module_title'],
+                'link' => $aItem['url'],
+                'bar' => $fNet > 0 ? max(0, round(100 * $aRow['net'] / $fNet, 1)) : 0,
+                'values' => [
+                    'net' => round((float)$aRow['net'], 2),
+                    'units' => (int)$aRow['units'],
+                    'orders' => (int)$aRow['orders'],
+                    'renewals' => (int)$aRow['renewals'],
+                ],
+            ];
+        }
+
+        $aSellers = [];
+        foreach($this->_oDb->getRevenueSplit($aFilter, 'seller_id', 10) as $aRow) {
+            $aMember = $this->getMember((int)$aRow['value']);
+            $aSellers[] = [
+                'label' => $aMember ? $aMember['name'] : _t('_bx_analytics_txt_deleted_member'),
+                'link' => $aMember ? $aMember['url'] : '',
+                'bar' => $fNet > 0 ? max(0, round(100 * $aRow['net'] / $fNet, 1)) : 0,
+                'values' => [
+                    'net' => round((float)$aRow['net'], 2),
+                    'orders' => (int)$aRow['orders'],
+                    'customers' => (int)$aRow['customers'],
+                ],
+            ];
+        }
+
+        $iSiteAdmin = (int)BxDolPayments::getInstance()->getOption('site_admin');
+        $aSplit = $this->_oDb->getRevenueSellers($aFilter, $iSiteAdmin);
+
+        $aTiles = [
+            ['title' => _t('_bx_analytics_metric_revenue_net'), 'value' => round($fNet, 2), 'type' => 'money'],
+        ];
+        if($iSiteAdmin)
+            $aTiles[] = ['title' => _t('_bx_analytics_metric_sales_site'), 'value' => round((float)$aSplit['site'], 2), 'type' => 'money'];
+        $aTiles[] = ['title' => _t('_bx_analytics_metric_sales_vendors'), 'value' => round((float)$aSplit['vendors'], 2), 'type' => 'money', 'sub' => _t('_bx_analytics_txt_sales_vendor_count', (int)$aSplit['vendor_count'])];
+        $aTiles[] = ['title' => _t('_bx_analytics_metric_sales_commissions'), 'value' => round((float)$aSplit['commissions'], 2), 'type' => 'money'];
+
+        $aInvoices = [];
+        foreach($this->getRevenueSources(['from' => $aFilter['from'], 'to' => $aFilter['to']], 'money') as $aSource)
+            foreach($aSource['metrics'] as $aMetric)
+                $aInvoices[] = [
+                    'title' => _t($aMetric['title']),
+                    'value' => isset($aMetric['value']) ? $aMetric['value'] : 0,
+                    'type' => 'number',
+                    'sub' => isset($aMetric['money']) && $aMetric['money'] !== null ? _t_format_currency($aMetric['money']) : '',
+                ];
+
+        return [
+            'money' => $aMoney,
+            'tiles' => $aTiles,
+            'items' => [
+                'name' => 'sales_items',
+                'caption' => _t('_bx_analytics_txt_sales_items'),
+                'columns' => [
+                    ['key' => 'label', 'type' => 'label', 'title' => _t('_bx_analytics_txt_item')],
+                    ['key' => 'net', 'type' => 'money', 'title' => _t('_bx_analytics_metric_revenue_net')],
+                    ['key' => 'units', 'type' => 'number', 'title' => _t('_bx_analytics_metric_sales_units')],
+                    ['key' => 'renewals', 'type' => 'number', 'title' => _t('_bx_analytics_metric_sbs_renewed')],
+                ],
+                'rows' => $aItems,
+                'empty' => _t('_bx_analytics_txt_no_revenue'),
+            ],
+            'sellers' => [
+                'name' => 'sales_sellers',
+                'caption' => _t('_bx_analytics_txt_sales_sellers'),
+                'columns' => [
+                    ['key' => 'label', 'type' => 'label', 'title' => _t('_bx_analytics_txt_sales_seller')],
+                    ['key' => 'net', 'type' => 'money', 'title' => _t('_bx_analytics_metric_revenue_net')],
+                    ['key' => 'orders', 'type' => 'number', 'title' => _t('_bx_analytics_metric_revenue_orders')],
+                    ['key' => 'customers', 'type' => 'number', 'title' => _t('_bx_analytics_metric_revenue_customers')],
+                ],
+                'rows' => $aSellers,
+                'empty' => _t('_bx_analytics_txt_no_revenue'),
+            ],
+            'invoices' => $aInvoices,
+            'notes' => $this->_getRevenueNotes($aMoney, (int)$aTotals['unconverted']),
+        ];
+    }
+
+    /**
+     * The credits economy as the modules that keep credits report it: bought, spent, granted,
+     * sent and withdrawn, the top spenders and the credits members hold; and from the ledger,
+     * what credits paid for, by module, valued in money. Those modules don't know the
+     * platform, so the whole block is for every platform, the ledger's figures too.
+     */
+    public function getCredits(BxAnalyticsFilter $oFilter, $aParams = [])
+    {
+        $aMoney = $this->getMoney();
+        $aFilter = $this->_getRevenueFilter($oFilter->get(), $aMoney);
+        $bPlatform = $aFilter['platform'] !== '';
+        $aFilter['platform'] = '';
+
+        $aSources = $this->getRevenueSources([
+            'from' => $aFilter['from'],
+            'to' => $aFilter['to'],
+            'starts' => $aFilter['buckets'],
+            'limit' => 10,
+        ], 'credits');
+
+        $aSource = reset($aSources);
+        $sSource = (string)key($aSources);
+
+        $aTiles = $aDatasets = $aSpenders = [];
+        if($aSource) {
+            foreach($aSource['metrics'] as $sKey => $aMetric) {
+                $aTiles[] = [
+                    'key' => $sKey,
+                    'title' => _t($aMetric['title']),
+                    'value' => (float)$aMetric['value'],
+                    'type' => 'credits',
+                    'sub' => isset($aMetric['money']) && $aMetric['money'] !== null && (float)$aMetric['value'] > 0 ? _t_format_currency($aMetric['money']) : '',
+                ];
+
+                if(in_array($sKey, ['bought', 'spent', 'granted', 'withdrawn'], true))
+                    $aDatasets[] = [
+                        'key' => $sKey,
+                        'label' => _t($aMetric['title']),
+                        'values' => array_map(function($iBucket) use ($aMetric, $aFilter) {
+                            return $iBucket > $aFilter['now'] ? null : (isset($aMetric['series'][$iBucket]) ? (float)$aMetric['series'][$iBucket] : 0);
+                        }, $aFilter['buckets']),
+                    ];
+            }
+
+            if(!empty($aSource['snapshot']['balance'])) {
+                $aBalance = $aSource['snapshot']['balance'];
+                $aTiles[] = [
+                    'key' => 'balance',
+                    'title' => _t($aBalance['title']),
+                    'value' => (float)$aBalance['value'],
+                    'type' => 'credits',
+                    'sub' => _t('_bx_analytics_txt_credits_liability', _t_format_currency(isset($aBalance['money_default']) ? $aBalance['money_default'] : 0)),
+                ];
+            }
+
+            if(!empty($aSource['lists']['top_spenders']['rows'])) {
+                $aList = $aSource['lists']['top_spenders']['rows'];
+                $fMax = 0;
+                foreach($aList as $aRow)
+                    $fMax = max($fMax, (float)$aRow['value']);
+
+                foreach($aList as $aRow) {
+                    $aMember = $this->getMember(isset($aRow['profile_id']) ? (int)$aRow['profile_id'] : 0);
+                    $aSpenders[] = [
+                        'label' => $aMember ? $aMember['name'] : (isset($aRow['label']) ? (string)$aRow['label'] : _t('_bx_analytics_txt_deleted_member')),
+                        'link' => $aMember ? $aMember['url'] : '',
+                        'bar' => $fMax > 0 ? round(100 * $aRow['value'] / $fMax, 1) : 0,
+                        'values' => ['value' => (float)$aRow['value']],
+                    ];
+                }
+            }
+        }
+
+        // what orders paid with credits bought, valued in money
+        $aSpentRows = [];
+        $aSpent = $this->_oDb->getRevenueSplit($aFilter, 'module', 10, 0);
+        $fSpent = 0;
+        foreach($aSpent as $aRow)
+            $fSpent += max(0, (float)$aRow['net']);
+        foreach($aSpent as $aRow)
+            $aSpentRows[] = [
+                'label' => $this->getRevenueLabel('module', (string)$aRow['value'], $aMoney['payment']),
+                'bar' => $fSpent > 0 ? max(0, round(100 * $aRow['net'] / $fSpent, 1)) : 0,
+                'values' => ['net' => round((float)$aRow['net'], 2), 'orders' => (int)$aRow['orders']],
+            ];
+
+        // credit bundles sold for money
+        $fBundles = 0;
+        if($sSource !== '')
+            foreach($this->_oDb->getRevenueSplit($aFilter, 'module', 1000) as $aRow)
+                if($aRow['value'] === $sSource)
+                    $fBundles = (float)$aRow['net'];
+
+        if($aSource)
+            $aTiles[] = ['key' => 'bundles', 'title' => _t('_bx_analytics_metric_credits_bundles'), 'value' => round($fBundles, 2), 'type' => 'money'];
+
+        $aNotes = [];
+        if($aMoney['credits_only'])
+            $aNotes[] = _t('_bx_analytics_txt_revenue_credits_only');
+        if($aSource && $bPlatform)
+            $aNotes[] = _t('_bx_analytics_txt_credits_all_platforms');
+        // checkouts the ledger doesn't have: paid before it started, or not imported yet
+        if(empty($aSpentRows) && !empty($aSource['metrics']['spent']['value']) && (float)$aSource['metrics']['spent']['value'] > 0)
+            $aNotes[] = _t('_bx_analytics_txt_credits_not_in_ledger');
+
+        return [
+            'money' => $aMoney,
+            'available' => !empty($aSource),
+            'title' => $aSource ? _t($aSource['title']) : '',
+            'tiles' => $aTiles,
+            'chart' => [
+                'kind' => 'lines',
+                'metric' => 'number',
+                'labels' => $aFilter['buckets'],
+                'bucket' => $aFilter['bucket'],
+                'tz' => $aFilter['tz'],
+                'datasets' => $aDatasets,
+            ],
+            'spenders' => [
+                'name' => 'credits_spenders',
+                'caption' => _t('_bx_analytics_txt_credits_spenders'),
+                'columns' => [
+                    ['key' => 'label', 'type' => 'label', 'title' => _t('_bx_analytics_txt_member_name')],
+                    ['key' => 'value', 'type' => 'credits', 'title' => _t('_bx_analytics_txt_credits_spent')],
+                ],
+                'rows' => $aSpenders,
+                'empty' => _t('_bx_analytics_txt_no_credits'),
+            ],
+            'spent' => [
+                'name' => 'credits_spent',
+                'caption' => _t('_bx_analytics_txt_credits_spent_on'),
+                'columns' => [
+                    ['key' => 'label', 'type' => 'label', 'title' => _t('_bx_analytics_tab_revenue_sources')],
+                    ['key' => 'net', 'type' => 'money', 'title' => _t('_bx_analytics_txt_credits_value')],
+                    ['key' => 'orders', 'type' => 'number', 'title' => _t('_bx_analytics_metric_revenue_orders')],
+                ],
+                'rows' => $aSpentRows,
+                'empty' => _t('_bx_analytics_txt_no_credit_orders'),
+            ],
+            'notes' => $aNotes,
+        ];
+    }
+
+    /**
+     * The site's default currency and the payment module the ledger is read for.
+     */
+    public function getMoney()
+    {
+        $oPayments = BxDolPayments::getInstance();
+        $bActive = $oPayments->isActive();
+
+        return [
+            'active' => $bActive,
+            'payment' => $bActive ? (string)$oPayments->getActive() : '',
+            'code' => $bActive ? (string)$oPayments->getCurrencyCode() : '',
+            'credits_only' => $bActive && (bool)$oPayments->isCreditsOnly(),
+        ];
+    }
+
+    /**
+     * What modules report about their own economy through the get_analytics_revenue service,
+     * found in every installed module: [module name => report], the ones of a unit only. The
+     * unit is read first (meta), so a module of another unit doesn't compute its report.
+     *
+     * @param array $aParams from, to, starts, limit; or meta to learn titles and units only
+     * @param string $sUnit money or credits, '' for any
+     */
+    public function getRevenueSources($aParams, $sUnit = '')
+    {
+        $aResult = [];
+        foreach(BxDolModuleQuery::getInstance()->getModulesBy(['type' => 'modules', 'active' => 1]) as $aModule) {
+            if(empty($aModule['name']) || !bx_is_srv($aModule['name'], 'get_analytics_revenue'))
+                continue;
+
+            if($sUnit !== '' && empty($aParams['meta'])) {
+                $aMeta = bx_srv($aModule['name'], 'get_analytics_revenue', [['meta' => true]]);
+                if(empty($aMeta['unit']) || $aMeta['unit'] != $sUnit)
+                    continue;
+            }
+
+            $aReport = bx_srv($aModule['name'], 'get_analytics_revenue', [$aParams]);
+            if(empty($aReport) || !is_array($aReport) || empty($aReport['unit']) || ($sUnit !== '' && $aReport['unit'] != $sUnit))
+                continue;
+
+            if(empty($aParams['meta']) && (empty($aReport['metrics']) || !is_array($aReport['metrics'])))
+                continue;
+
+            $aResult[$aModule['name']] = $aReport;
+        }
+
+        return $aResult;
+    }
+
+    /**
+     * A human name for a ledger value: a module's title, a provider, a platform, a channel.
+     */
+    public function getRevenueLabel($sDimension, $sValue, $sPayment = '')
+    {
+        switch($sDimension) {
+            case 'module':
+                if($sValue === '')
+                    return _t('_bx_analytics_txt_unknown');
+                if($sPayment !== '' && $sValue === $sPayment)
+                    return _t('_bx_analytics_txt_revenue_commissions');
+                return $this->_getModuleTitle($sValue);
+
+            case 'provider':
+                if($sValue === '')
+                    return _t('_bx_analytics_txt_unknown');
+                $sValue = str_replace('_', ' ', $sValue);
+                return mb_strtoupper(get_mb_substr($sValue, 0, 1)) . get_mb_substr($sValue, 1, get_mb_len($sValue) - 1);
+
+            case 'platform':
+                return $sValue !== '' ? $this->getLabel('platform', $sValue) : _t('_bx_analytics_txt_revenue_imported');
+
+            case 'first_channel':
+            case 'last_channel':
+                return $sValue !== '' ? $this->getLabel('channel', $sValue) : _t('_bx_analytics_txt_revenue_untracked');
+
+            case 'last_utm_campaign':
+            case 'first_utm_campaign':
+                return $sValue !== '' ? $sValue : _t('_bx_analytics_txt_revenue_no_campaign');
+        }
+
+        return $sValue;
+    }
+
+    /**
+     * Net revenue of members over all the ledger, for the Users report: [profile id => amount].
+     */
+    public function getLifetimeValues($aProfileIds)
+    {
+        $aMoney = $this->getMoney();
+        if($aMoney['payment'] === '')
+            return [];
+
+        return $this->_oDb->getLifetimeValues($aProfileIds, $aMoney['payment']);
+    }
+
+    /**
+     * Subscription figures over spans of time, sorted and not overlapping. For each span: MRR
+     * and paying subscriptions at its end, trials at its end, new and ended (cancelled, or
+     * unpaid past the grace time) paying subscriptions in it, and churn (of those paying at
+     * its start, the share that ended in it). One pass over the rows, each finding its spans
+     * by binary search.
+     *
+     * @param array $aSpans [[from, to], ...]
+     * @return array a result per span, in the order of $aSpans
+     */
+    protected function _sweepSubscriptions($aRows, $aSpans)
+    {
+        $aSpans = array_values($aSpans);
+        $iSpans = count($aSpans);
+        $aFroms = array_map('intval', array_column($aSpans, 0));
+        $aTos = array_map('intval', array_column($aSpans, 1));
+
+        // counts at the spans' ends (or starts) are ranges of spans: added as differences
+        $aDiff = array_fill_keys(['mrr', 'active', 'trials', 'trial_mrr', 'at_start'], array_fill(0, $iSpans + 1, 0));
+        $aNew = $aEnded = $aChurned = array_fill(0, $iSpans, 0);
+
+        foreach($aRows as $aRow) {
+            $iFirst = (int)$aRow['first_paid'];
+            $iEnd = $this->_getSubscriptionEnd($aRow);
+            $fMrr = (float)$aRow['mrr_default'];
+
+            if($iFirst) {
+                $this->_addSpans($aDiff['mrr'], $aTos, $iFirst, $iEnd, $fMrr);
+                $this->_addSpans($aDiff['active'], $aTos, $iFirst, $iEnd, 1);
+                $this->_addSpans($aDiff['at_start'], $aFroms, $iFirst, $iEnd, 1);
+
+                if(($i = $this->_findSpan($aFroms, $aTos, $iFirst)) !== null)
+                    $aNew[$i]++;
+
+                if($iEnd && ($i = $this->_findSpan($aFroms, $aTos, $iEnd)) !== null) {
+                    $aEnded[$i]++;
+                    if($iFirst < $aFroms[$i])
+                        $aChurned[$i]++;
+                }
+            }
+
+            // in trial from its start until the trial, its first charge or its end is over
+            $iTrialEnd = $this->_getTrialEnd($aRow);
+            if($iTrialEnd !== null) {
+                $iUntil = 0;
+                foreach([$iTrialEnd, $iFirst, $iEnd] as $iTime)
+                    if($iTime)
+                        $iUntil = $iUntil ? min($iUntil, $iTime) : $iTime;
+
+                $this->_addSpans($aDiff['trials'], $aTos, (int)$aRow['started'], $iUntil, 1);
+                $this->_addSpans($aDiff['trial_mrr'], $aTos, (int)$aRow['started'], $iUntil, $fMrr);
+            }
+        }
+
+        $aResults = [];
+        $aSum = array_fill_keys(array_keys($aDiff), 0);
+        for($i = 0; $i < $iSpans; $i++) {
+            foreach($aDiff as $sKey => $aValues)
+                $aSum[$sKey] += $aValues[$i];
+
+            if($aTos[$i] <= $aFroms[$i]) {
+                $aResults[] = ['mrr' => null, 'active' => null, 'trials' => null, 'trial_mrr' => 0, 'new' => null, 'cancelled' => null, 'churn' => null];
+                continue;
+            }
+
+            $aResults[] = [
+                'mrr' => round($aSum['mrr'], 2),
+                'active' => (int)$aSum['active'],
+                'trials' => (int)$aSum['trials'],
+                'trial_mrr' => round($aSum['trial_mrr'], 2),
+                'new' => $aNew[$i],
+                'cancelled' => $aEnded[$i],
+                'churn' => $aSum['at_start'] ? round(100 * $aChurned[$i] / $aSum['at_start'], 1) : null,
+            ];
+        }
+
+        return $aResults;
+    }
+
+    /**
+     * Add a value to the spans whose edge (from `$aEdges`, ascending) is in (`$iAfter`,
+     * `$iUntil`], `$iUntil` 0 for no end.
+     */
+    protected function _addSpans(&$aDiff, $aEdges, $iAfter, $iUntil, $mixedValue)
+    {
+        $iLo = self::_firstAbove($aEdges, $iAfter);
+        $iHi = $iUntil ? self::_firstAbove($aEdges, $iUntil) : count($aEdges);
+        if($iLo >= $iHi)
+            return;
+
+        $aDiff[$iLo] += $mixedValue;
+        $aDiff[$iHi] -= $mixedValue;
+    }
+
+    /**
+     * The span a time falls in, or null.
+     */
+    protected function _findSpan($aFroms, $aTos, $iTime)
+    {
+        $i = self::_firstAbove($aFroms, $iTime) - 1;
+
+        return $i >= 0 && $iTime < $aTos[$i] ? $i : null;
+    }
+
+    /**
+     * Index of the first value greater than `$iTime` in an ascending list, or its length.
+     */
+    protected static function _firstAbove($aList, $iTime)
+    {
+        $iLo = 0;
+        $iHi = count($aList);
+        while($iLo < $iHi) {
+            $iMid = ($iLo + $iHi) >> 1;
+            if($aList[$iMid] > $iTime)
+                $iHi = $iMid;
+            else
+                $iLo = $iMid + 1;
+        }
+
+        return $iLo;
+    }
+
+    /**
+     * A subscription was paying at a time: first paid before it and not ended by then.
+     */
+    protected function _isPaying($aRow, $iTime)
+    {
+        $iFirst = (int)$aRow['first_paid'];
+        $iEnd = $this->_getSubscriptionEnd($aRow);
+
+        return $iFirst && $iFirst < $iTime && (!$iEnd || $iEnd >= $iTime);
+    }
+
+    /**
+     * When a subscription stopped paying, or 0: its cancellation, or for an unpaid one the end
+     * of its last paid period and the grace time (a failed charge the payment module stopped
+     * retrying never cancels it).
+     */
+    protected function _getSubscriptionEnd($aRow)
+    {
+        if((int)$aRow['canceled'])
+            return (int)$aRow['canceled'];
+
+        if($aRow['status'] != BxAnalyticsRevenue::STATUS_UNPAID || !(int)$aRow['last_paid'])
+            return 0;
+
+        return (int)$aRow['last_paid'] + (int)BxAnalyticsRevenue::getPeriodSeconds($aRow['period'], $aRow['period_unit']) + BxAnalyticsRevenue::UNPAID_GRACE;
+    }
+
+    /**
+     * When a subscription's trial ends (0: a trial of unknown length), or null without a trial.
+     */
+    protected function _getTrialEnd($aRow)
+    {
+        if((int)$aRow['trial'] > 0)
+            return (int)$aRow['started'] + 86400 * (int)$aRow['trial'];
+
+        return $aRow['status'] == BxAnalyticsRevenue::STATUS_TRIAL ? 0 : null;
+    }
+
+    /**
+     * Title, link and module of something sold, from its module's get_cart_item.
+     */
+    protected function _getCartItem($sModule, $iItemId)
+    {
+        $aResult = [
+            'title' => _t('_bx_analytics_txt_item_n', (int)$iItemId),
+            'url' => '',
+            'module_title' => $sModule !== '' ? $this->_getModuleTitle($sModule) : '',
+        ];
+
+        if($sModule === '' || !BxDolModuleQuery::getInstance()->isEnabledByName($sModule) || !bx_is_srv($sModule, 'get_cart_item'))
+            return $aResult;
+
+        $aItem = bx_srv($sModule, 'get_cart_item', [(int)$iItemId]);
+        if(!empty($aItem) && is_array($aItem)) {
+            if(!empty($aItem['title']) && is_string($aItem['title']))
+                $aResult['title'] = strip_tags($aItem['title']);
+            if(!empty($aItem['url']) && is_string($aItem['url']))
+                $aResult['url'] = $aItem['url'];
+        }
+
+        return $aResult;
+    }
+
+    protected function _getRevenueFilter($aFilter, $aMoney)
+    {
+        $aFilter['payment'] = $aMoney['payment'];
+
+        return $aFilter;
+    }
+
+    protected function _getRevenueNotes($aMoney, $iUnconverted)
+    {
+        $aNotes = [];
+        if(!$aMoney['active'])
+            $aNotes[] = _t('_bx_analytics_txt_revenue_no_payment');
+        if($aMoney['credits_only'])
+            $aNotes[] = _t('_bx_analytics_txt_revenue_credits_only');
+        if($iUnconverted > 0)
+            $aNotes[] = _t('_bx_analytics_txt_revenue_no_rate', $iUnconverted);
+        // no currency without a payment module: the tab still shows, for other revenue sources to come
+        if($aMoney['code'] !== '')
+            $aNotes[] = _t('_bx_analytics_txt_revenue_as_recorded', $aMoney['code']);
+
+        return $aNotes;
+    }
+
+    /**
+     * Where the import of past payments stands, for administrators.
+     */
+    protected function _getImportStatus()
+    {
+        if(!isAdmin())
+            return [];
+
+        $aStatus = $this->_oModule->getRevenue()->getBackfillStatus(true);
+
+        return $aStatus['state'] == 'unavailable' ? [] : $aStatus;
+    }
+
+    /**
+     * Name, link and picture of a profile, or null when it no longer exists.
+     */
+    public function getMember($iProfileId)
+    {
+        if(!$iProfileId)
+            return null;
+
+        $oProfile = BxDolProfile::getInstance($iProfileId);
+        if(!$oProfile)
+            return null;
+
+        return [
+            'id' => (int)$iProfileId,
+            'name' => $oProfile->getDisplayName(),
+            'url' => $oProfile->getUrl(),
+            'thumb' => $oProfile->getThumb(),
+        ];
+    }
+
+    /**
+     * A readable name for an event: a site action with the module it happened in, or a
+     * custom event's own name.
+     */
+    public function getEventLabel($sType, $sName, $sModule)
+    {
+        $sModuleTitle = $sModule !== '' ? $this->_getModuleTitle($sModule) : '';
+
+        if($sType == BX_ANALYTICS_HIT_UNA) {
+            // an action without a title of its own (a module's, or a newer one) by its name
+            $sKey = '_bx_analytics_action_' . $sName;
+            $sLabel = _t($sKey);
+            if($sLabel === $sKey)
+                $sLabel = $sName;
+            return $sModuleTitle !== '' && $sModule != 'account' ? $sLabel . ' · ' . $sModuleTitle : $sLabel;
+        }
+
+        return $sModuleTitle !== '' ? $sName . ' · ' . $sModuleTitle : $sName;
+    }
+
+    /**
+     * Title of the module an object system belongs to: bx_posts and bx_posts_reactions are both Posts.
+     */
+    protected function _getModuleTitle($sSystem)
+    {
+        if($this->_aModuleTitles === null) {
+            $this->_aModuleTitles = [];
+            foreach(BxDolModuleQuery::getInstance()->getModulesBy(['type' => 'modules', 'active' => 1]) as $aModule)
+                $this->_aModuleTitles[$aModule['name']] = $aModule['title'];
+            uksort($this->_aModuleTitles, function($a, $b) { return get_mb_len($b) - get_mb_len($a); });
+        }
+
+        foreach($this->_aModuleTitles as $sName => $sTitle)
+            if($sSystem === $sName || bx_mb_strpos($sSystem, $sName . '_') === 0)
+                return $sTitle;
+
+        // core objects, like the friends and follows connections, have their own names
+        $sKey = '_bx_analytics_object_' . $sSystem;
+        $sTitle = _t($sKey);
+
+        return $sTitle !== $sKey ? $sTitle : $sSystem;
+    }
+
+    protected function _parseEvent($sValue)
+    {
+        $aParts = explode('|', (string)$sValue);
+        if(count($aParts) != 3 || !in_array($aParts[0], [BX_ANALYTICS_HIT_EVENT, BX_ANALYTICS_HIT_UNA], true) || $aParts[1] === '')
+            return null;
+
+        return ['type' => $aParts[0], 'name' => $aParts[1], 'module' => $aParts[2]];
+    }
+
+    protected function _getLocation($aSession)
+    {
+        $aParts = array_filter([$aSession['city'], $aSession['country'] !== '' ? $this->getLabel('country', $aSession['country']) : '']);
+
+        return !empty($aParts) ? implode(', ', $aParts) : _t('_bx_analytics_txt_unknown');
+    }
+
+    protected function _getDevice($aSession)
+    {
+        $aParts = array_filter([
+            $aSession['device'] !== '' ? $this->getLabel('device', $aSession['device']) : '',
+            $aSession['browser'],
+            trim($aSession['os'] . ' ' . $aSession['os_version']),
+        ]);
+
+        return implode(' · ', $aParts);
+    }
+
+    protected function _getPlatformsText($sPlatforms)
+    {
+        $aLabels = [];
+        foreach(explode(',', (string)$sPlatforms) as $sPlatform)
+            if($sPlatform !== '')
+                $aLabels[] = $this->getLabel('platform', $sPlatform);
+
+        return implode(', ', $aLabels);
+    }
+
+    /**
+     * A human name for a stored dimension value.
+     */
+    public function getLabel($sDimension, $sValue)
+    {
+        if($sValue === '')
+            return _t($sDimension == 'referrer_host' || $sDimension == 'referrer' ? '_bx_analytics_txt_direct' : '_bx_analytics_txt_unknown');
+
+        switch($sDimension) {
+            case 'platform':
+                $aPlatforms = $this->_oConfig->getPlatforms();
+                return isset($aPlatforms[$sValue]) ? _t($aPlatforms[$sValue]) : $sValue;
+
+            case 'channel':
+                return _t('_bx_analytics_channel_' . $sValue);
+
+            case 'device':
+                return _t('_bx_analytics_device_' . $sValue);
+
+            case 'country':
+                if($this->_aCountries === null)
+                    $this->_aCountries = BxDolFormQuery::getDataItems('Country');
+                return isset($this->_aCountries[$sValue]) ? $this->_aCountries[$sValue] : $sValue;
+        }
+
+        return $sValue;
+    }
+
+    /**
+     * Report metrics from summed session columns.
+     */
+    protected function _getKpis($aRow)
+    {
+        $iVisitors = isset($aRow['visitors']) ? (int)$aRow['visitors'] : 0;
+        $iSessions = isset($aRow['sessions']) ? (int)$aRow['sessions'] : 0;
+        $iPageviews = isset($aRow['pageviews']) ? (int)$aRow['pageviews'] : 0;
+        $iBounces = isset($aRow['bounces']) ? (int)$aRow['bounces'] : 0;
+        $iDuration = isset($aRow['duration']) ? (int)$aRow['duration'] : 0;
+
+        return [
+            'visitors' => $iVisitors,
+            'sessions' => $iSessions,
+            'pageviews' => $iPageviews,
+            'pages_per_session' => $iSessions ? round($iPageviews / $iSessions, 2) : 0,
+            'bounce_rate' => $iSessions ? round(100 * $iBounces / $iSessions, 1) : 0,
+            'duration' => $iSessions ? (int)round($iDuration / $iSessions) : 0,
+        ];
+    }
+
+    /**
+     * The tab a report shows: `tab`, or `tab_<name>` from the page URL, or the first one.
+     */
+    protected function _getTab($aParams, $sName, $aTabs)
+    {
+        $sTab = $this->_param($aParams, 'tab', $this->_param($aParams, 'tab_' . $sName, ''));
+
+        return isset($aTabs[$sTab]) ? $sTab : key($aTabs);
+    }
+
+    protected function _getTabs($aTabs, $sActive, $sKeyPrefix = '_bx_analytics_tab_')
+    {
+        $aResult = [];
+        foreach(array_keys($aTabs) as $sName)
+            $aResult[] = ['name' => $sName, 'title' => _t($sKeyPrefix . $sName), 'active' => $sName == $sActive];
+
+        return $aResult;
+    }
+
+    /**
+     * A scalar request parameter, or the default.
+     */
+    protected function _param($aParams, $sKey, $mixedDefault = '')
+    {
+        return isset($aParams[$sKey]) && is_scalar($aParams[$sKey]) ? $aParams[$sKey] : $mixedDefault;
+    }
+
+    /**
+     * Change in percent, or null when there is nothing to compare with.
+     */
+    protected function _change($fCurrent, $fPrevious)
+    {
+        if(!$fPrevious)
+            return null;
+
+        return round(100 * ($fCurrent - $fPrevious) / $fPrevious, 1);
+    }
+}
+
+/** @} */

+ 436 - 0
modules/boonex/analytics/updates/15.0.1_15.0.2/source/classes/BxAnalyticsRequest.php

@@ -0,0 +1,436 @@
+<?php defined('BX_DOL') or die('hack attempt');
+/**
+ * Copyright (c) UNA, Inc - https://una.io
+ * MIT License - https://opensource.org/licenses/MIT
+ *
+ * @defgroup    Analytics Analytics
+ * @ingroup     UnaModules
+ *
+ * @{
+ */
+
+/**
+ * What the HTTP request of a hit tells about the visitor: address, country, privacy signals.
+ * Reads a $_SERVER-like array, so tests can pass their own.
+ */
+class BxAnalyticsRequest
+{
+    /**
+     * Headers that carry the client address when a proxy or CDN sits in front of the site
+     * and there is no X-Forwarded-For, checked in this order.
+     */
+    protected static $_aIpHeaders = [
+        'HTTP_CF_CONNECTING_IP',
+        'HTTP_TRUE_CLIENT_IP',
+        'HTTP_X_REAL_IP',
+    ];
+
+    /**
+     * The shared address space of carrier-grade NAT, where cloud load balancers (Railway)
+     * connect from; PHP doesn't count it as private.
+     */
+    protected static $_aInternalRanges = ['100.64.0.0/10'];
+
+    /**
+     * Location headers set by CDNs and hosting edges, per edge: country, region, city and
+     * coordinates. With no edge chosen, the first one found is read, in this order. An edge
+     * sets its own headers and passes the others through, so a client can add another CDN's.
+     */
+    protected static $_aGeoHeaders = [
+        'cloudflare' => [
+            'country' => ['HTTP_CF_IPCOUNTRY'],
+            'region' => [],
+            'city' => ['HTTP_CF_IPCITY'],
+            'coordinates' => [['HTTP_CF_IPLATITUDE', 'HTTP_CF_IPLONGITUDE']],
+        ],
+        'vercel' => [
+            'country' => ['HTTP_X_VERCEL_IP_COUNTRY'],
+            'region' => ['HTTP_X_VERCEL_IP_COUNTRY_REGION'],
+            'city' => ['HTTP_X_VERCEL_IP_CITY'],
+            'coordinates' => [['HTTP_X_VERCEL_IP_LATITUDE', 'HTTP_X_VERCEL_IP_LONGITUDE']],
+        ],
+        'cloudfront' => [
+            'country' => ['HTTP_CLOUDFRONT_VIEWER_COUNTRY'],
+            'region' => ['HTTP_CLOUDFRONT_VIEWER_COUNTRY_REGION'],
+            'city' => ['HTTP_CLOUDFRONT_VIEWER_CITY'],
+            'coordinates' => [['HTTP_CLOUDFRONT_VIEWER_LATITUDE', 'HTTP_CLOUDFRONT_VIEWER_LONGITUDE']],
+        ],
+        '' => [
+            'country' => ['HTTP_CF_IPCOUNTRY', 'HTTP_X_VERCEL_IP_COUNTRY', 'HTTP_CLOUDFRONT_VIEWER_COUNTRY', 'HTTP_X_COUNTRY_CODE', 'HTTP_X_APPENGINE_COUNTRY'],
+            'region' => ['HTTP_X_VERCEL_IP_COUNTRY_REGION'],
+            'city' => ['HTTP_X_VERCEL_IP_CITY', 'HTTP_CF_IPCITY'],
+            'coordinates' => [['HTTP_X_VERCEL_IP_LATITUDE', 'HTTP_X_VERCEL_IP_LONGITUDE'], ['HTTP_CF_IPLATITUDE', 'HTTP_CF_IPLONGITUDE']],
+        ],
+    ];
+
+    /**
+     * A visitor id kept by a client (persistent identity mode), and the cookie the classic
+     * tracker keeps it in for site requests.
+     */
+    const VISITOR_ID_PATTERN = '/^[a-zA-Z0-9_-]{16,64}$/';
+    const VISITOR_ID_COOKIE = 'bx_analytics_vid';
+
+    protected $_aServer;
+
+    public function __construct($aServer = null)
+    {
+        $this->_aServer = is_array($aServer) ? $aServer : $_SERVER;
+    }
+
+    /**
+     * Client address. Proxy headers count only when the request comes from a trusted proxy:
+     * a private, loopback or shared (100.64.0.0/10) address (load balancers, the NEO server in
+     * the same network), an address from the trusted list, or a request the caller has
+     * already authenticated (the NEO server's API key).
+     *
+     * X-Forwarded-For is read from the right, as core's visitor address is: each proxy
+     * appends the address it got the request from, so trusted proxies are skipped and the
+     * first address that isn't one is the client. Whatever is left of it came from the
+     * client, which could otherwise pick any address for each request: past the rate limits
+     * and the excluded addresses. CF-Connecting-IP, True-Client-IP and X-Real-IP count only
+     * without X-Forwarded-For, since a proxy that doesn't set them passes the client's own.
+     *
+     * @param array $aTrustedProxies addresses and CIDR ranges whose proxy headers are trusted
+     * @param bool $bTrustedRequest the request carries a valid API key
+     * @return string an IP address, or '' when there is none
+     */
+    public function getIp($aTrustedProxies = [], $bTrustedRequest = false)
+    {
+        $sRemote = $this->_valid($this->_get('REMOTE_ADDR'));
+        if($sRemote === '' && !$bTrustedRequest)
+            return '';
+
+        if(!$bTrustedRequest && !self::isTrustedProxy($sRemote, $aTrustedProxies))
+            return $sRemote;
+
+        $sForwarded = $this->_get('HTTP_X_FORWARDED_FOR');
+        if($sForwarded !== '') {
+            $sIp = $sRemote;
+            foreach(array_reverse(explode(',', $sForwarded)) as $sCandidate) {
+                if(trim($sCandidate) === '')
+                    continue;
+
+                // a hop that didn't know the address ("unknown"): nothing left of it is reliable
+                $sCandidate = $this->_valid($sCandidate);
+                if($sCandidate === '')
+                    break;
+
+                $sIp = $sCandidate;
+                if(!self::isTrustedProxy($sCandidate, $aTrustedProxies))
+                    break;
+            }
+
+            return $sIp;
+        }
+
+        foreach(self::$_aIpHeaders as $sHeader) {
+            $sIp = $this->_valid($this->_get($sHeader));
+            if($sIp !== '')
+                return $sIp;
+        }
+
+        return $sRemote;
+    }
+
+    /**
+     * Whether the proxy headers of a request from an address are trusted: a private,
+     * loopback or shared address, or one in the trusted list.
+     */
+    public static function isTrustedProxy($sIp, $aTrustedProxies = [])
+    {
+        return self::isPrivate($sIp) || self::inList($sIp, self::$_aInternalRanges) || self::inList($sIp, $aTrustedProxies);
+    }
+
+    /**
+     * The address the request came from, before any proxy header is read, or ''.
+     */
+    public function getRemoteAddr()
+    {
+        return $this->_valid($this->_get('REMOTE_ADDR'));
+    }
+
+    /**
+     * ISO 3166-1 alpha-2 country from CDN headers, or ''. "XX" and "T1" (Tor) mean unknown.
+     *
+     * @param string $sEdge the CDN whose headers are read (cloudflare, vercel, cloudfront), '' for any
+     */
+    public function getCountry($sEdge = '')
+    {
+        $aCountry = $this->getCountryHeader($sEdge);
+
+        return $aCountry ? $aCountry['country'] : '';
+    }
+
+    /**
+     * The CDN header the country comes from and its value, or null when there is none.
+     *
+     * @param string $sEdge as for getCountry()
+     * @return array|null header (as in $_SERVER) and country
+     */
+    public function getCountryHeader($sEdge = '')
+    {
+        foreach(self::_getGeoHeaders($sEdge, 'country') as $sHeader) {
+            $sValue = mb_strtoupper(trim($this->_get($sHeader)));
+            if(preg_match('/^[A-Z]{2}$/', $sValue) && !in_array($sValue, ['XX', 'T1', 'ZZ'], true))
+                return ['header' => $sHeader, 'country' => $sValue];
+        }
+
+        return null;
+    }
+
+    /**
+     * Region and city where the edge provides them, URL-decoded, or ''.
+     *
+     * @param string $sEdge as for getCountry()
+     */
+    public function getRegion($sEdge = '')
+    {
+        return $this->_clean(rawurldecode($this->_getFirst(self::_getGeoHeaders($sEdge, 'region'))), 64);
+    }
+
+    public function getCity($sEdge = '')
+    {
+        return $this->_clean(rawurldecode($this->_getFirst(self::_getGeoHeaders($sEdge, 'city'))), 64);
+    }
+
+    /**
+     * Visitor coordinates from the edge (Vercel, CloudFront, or Cloudflare's visitor location
+     * headers), rounded to one decimal (about 10 km), or null.
+     *
+     * @param string $sEdge as for getCountry()
+     * @return array|null [lat, lng]
+     */
+    public function getCoordinates($sEdge = '')
+    {
+        foreach(self::_getGeoHeaders($sEdge, 'coordinates') as list($sLat, $sLng)) {
+            $sLatValue = $this->_get($sLat);
+            $sLngValue = $this->_get($sLng);
+            if(!is_numeric($sLatValue) || !is_numeric($sLngValue))
+                continue;
+
+            $fLat = (float)$sLatValue;
+            $fLng = (float)$sLngValue;
+            if($fLat < -90 || $fLat > 90 || $fLng < -180 || $fLng > 180)
+                continue;
+
+            return [round($fLat, 1), round($fLng, 1)];
+        }
+
+        return null;
+    }
+
+    /**
+     * The client of the current request, for actions recorded on the server.
+     * - no request (cron, background jobs, command line): core
+     * - a page or form of the classic UI: core-web
+     * - NEO says which client it is in X-Neo-Platform (web, ios, android)
+     * - otherwise the NEO web server is the request with the API key, and the native apps
+     *   are told apart by their HTTP stack (CFNetwork on iOS, okhttp on Android)
+     * - any other API client: api
+     *
+     * @param bool $bApi the request came through api.php
+     * @param bool $bApiKey it carries an API key api.php accepted
+     */
+    public function getPlatform($bApi, $bApiKey = false)
+    {
+        // cron and the command line have no request method
+        if($this->_get('REQUEST_METHOD') === '')
+            return BX_ANALYTICS_PLATFORM_CORE;
+
+        if(!$bApi)
+            return BX_ANALYTICS_PLATFORM_CORE_WEB;
+
+        $aNeo = [
+            'web' => BX_ANALYTICS_PLATFORM_NEXT_WEB,
+            'ios' => BX_ANALYTICS_PLATFORM_RN_IOS,
+            'android' => BX_ANALYTICS_PLATFORM_RN_ANDROID,
+        ];
+        $sNeo = mb_strtolower($this->_get('HTTP_X_NEO_PLATFORM'));
+        if(isset($aNeo[$sNeo]))
+            return $aNeo[$sNeo];
+
+        if($bApiKey)
+            return BX_ANALYTICS_PLATFORM_NEXT_WEB;
+
+        $sUserAgent = $this->_get('HTTP_USER_AGENT');
+        if($this->_get('HTTP_ORIGIN') !== '') {
+            if(preg_match('/CFNetwork|Darwin/i', $sUserAgent))
+                return BX_ANALYTICS_PLATFORM_RN_IOS;
+            if(preg_match('/okhttp|Dalvik/i', $sUserAgent))
+                return BX_ANALYTICS_PLATFORM_RN_ANDROID;
+        }
+
+        return BX_ANALYTICS_PLATFORM_API;
+    }
+
+    /**
+     * The visitor id the client keeps (persistent identity mode), on a site request rather
+     * than in a tracker payload: the classic tracker's `bx_analytics_vid` cookie, or NEO's
+     * X-Analytics-Vid header. '' when there is none or it isn't one.
+     */
+    public function getVisitorId()
+    {
+        $sId = $this->_get('HTTP_X_ANALYTICS_VID');
+        if($sId === '')
+            $sId = $this->_getCookie(self::VISITOR_ID_COOKIE);
+
+        return self::isVisitorId($sId) ? $sId : '';
+    }
+
+    /**
+     * Whether a string has the form of a client's visitor id.
+     */
+    public static function isVisitorId($s)
+    {
+        return is_string($s) && preg_match(self::VISITOR_ID_PATTERN, $s) === 1;
+    }
+
+    public function getUserAgent()
+    {
+        return $this->_clean($this->_get('HTTP_USER_AGENT'), 512);
+    }
+
+    public function getLanguage()
+    {
+        $sValue = $this->_get('HTTP_ACCEPT_LANGUAGE');
+        if(!preg_match('/^\s*([a-zA-Z]{2,3}(?:-[a-zA-Z0-9]{2,4})?)/', $sValue, $aMatch))
+            return '';
+
+        return $aMatch[1];
+    }
+
+    /**
+     * The browser asks not to be tracked: Do Not Track or Global Privacy Control.
+     */
+    public function isDoNotTrack()
+    {
+        return $this->_get('HTTP_DNT') === '1' || $this->_get('HTTP_SEC_GPC') === '1';
+    }
+
+    /**
+     * The request comes from another site's page, which a same-origin tracker never does.
+     * Browsers send Sec-Fetch-Site with fetch and sendBeacon; old ones send nothing, which passes.
+     */
+    public function isCrossSite()
+    {
+        $sSite = mb_strtolower($this->_get('HTTP_SEC_FETCH_SITE'));
+        return $sSite === 'cross-site';
+    }
+
+    public static function isPrivate($sIp)
+    {
+        if(!filter_var($sIp, FILTER_VALIDATE_IP))
+            return false;
+
+        return filter_var($sIp, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) === false;
+    }
+
+    /**
+     * Whether an address matches a list of addresses and CIDR ranges (IPv4 and IPv6).
+     */
+    public static function inList($sIp, $aList)
+    {
+        $sIpBin = @inet_pton($sIp);
+        if($sIpBin === false)
+            return false;
+
+        foreach($aList as $sEntry) {
+            $sEntry = trim($sEntry);
+            if($sEntry === '')
+                continue;
+
+            $iBits = null;
+            if(bx_mb_strpos($sEntry, '/') !== false)
+                list($sEntry, $iBits) = explode('/', $sEntry, 2);
+
+            $sNetBin = @inet_pton($sEntry);
+            if($sNetBin === false || strlen($sNetBin) != strlen($sIpBin))
+                continue;
+
+            if($iBits === null || $iBits === '') {
+                if($sNetBin === $sIpBin)
+                    return true;
+                continue;
+            }
+
+            $iBits = (int)$iBits;
+            $iBytes = intdiv($iBits, 8);
+            if(substr($sNetBin, 0, $iBytes) !== substr($sIpBin, 0, $iBytes))
+                continue;
+
+            $iRest = $iBits % 8;
+            if($iRest == 0)
+                return true;
+
+            $iMask = (0xFF << (8 - $iRest)) & 0xFF;
+            if((ord($sNetBin[$iBytes]) & $iMask) === (ord($sIpBin[$iBytes]) & $iMask))
+                return true;
+        }
+
+        return false;
+    }
+
+    protected function _get($sKey)
+    {
+        return isset($this->_aServer[$sKey]) && is_string($this->_aServer[$sKey]) ? trim($this->_aServer[$sKey]) : '';
+    }
+
+    /**
+     * A cookie's value from the request's Cookie header, or ''.
+     */
+    protected function _getCookie($sName)
+    {
+        foreach(explode(';', $this->_get('HTTP_COOKIE')) as $sPair) {
+            $aPair = explode('=', $sPair, 2);
+            if(count($aPair) == 2 && trim($aPair[0]) === $sName)
+                return trim(rawurldecode($aPair[1]));
+        }
+
+        return '';
+    }
+
+    /**
+     * The first of some headers that isn't empty, or ''.
+     */
+    protected function _getFirst($aKeys)
+    {
+        foreach($aKeys as $sKey)
+            if(($sValue = $this->_get($sKey)) !== '')
+                return $sValue;
+
+        return '';
+    }
+
+    /**
+     * Location headers of an edge ('' for any) of a kind: country, region, city, coordinates.
+     * An unknown edge has none.
+     */
+    protected static function _getGeoHeaders($sEdge, $sKind)
+    {
+        $sEdge = (string)$sEdge;
+
+        return isset(self::$_aGeoHeaders[$sEdge][$sKind]) ? self::$_aGeoHeaders[$sEdge][$sKind] : [];
+    }
+
+    protected function _valid($sIp)
+    {
+        $sIp = trim((string)$sIp);
+
+        // "1.2.3.4:5678" and "[::1]:5678" from some proxies
+        if(preg_match('/^\[([0-9a-f:.]+)\](?::\d+)?$/i', $sIp, $aMatch))
+            $sIp = $aMatch[1];
+        elseif(preg_match('/^(\d{1,3}(?:\.\d{1,3}){3}):\d+$/', $sIp, $aMatch))
+            $sIp = $aMatch[1];
+
+        return filter_var($sIp, FILTER_VALIDATE_IP) ? $sIp : '';
+    }
+
+    protected function _clean($s, $iMaxLength)
+    {
+        $s = trim(preg_replace('/[\x00-\x1F\x7F]+/u', ' ', (string)$s));
+
+        return get_mb_substr($s, 0, $iMaxLength);
+    }
+}
+
+/** @} */

+ 1107 - 0
modules/boonex/analytics/updates/15.0.1_15.0.2/source/classes/BxAnalyticsRevenue.php

@@ -0,0 +1,1107 @@
+<?php defined('BX_DOL') or die('hack attempt');
+/**
+ * Copyright (c) UNA, Inc - https://una.io
+ * MIT License - https://opensource.org/licenses/MIT
+ *
+ * @defgroup    Analytics Analytics
+ * @ingroup     UnaModules
+ *
+ * @{
+ */
+
+/**
+ * The revenue ledger. Listens to the alerts every payment module fires on the `system` unit
+ * and writes one row per money movement: a sale, a renewal, a refund or a reversal of a
+ * processed order. Amounts are what the payment module records, also converted to the site's
+ * default currency with the rate the order was placed at. Subscriptions are kept beside it,
+ * with their price per month (MRR).
+ *
+ * A subscription with a trial is registered by the payment module when the trial starts, with
+ * transactions at the full price although nothing was charged. Those rows are kept with the
+ * kind `trial` (amounts for reference only): no revenue sum, count or chart reads them, they
+ * are never refunded or reversed, and they make a `trial_start` action with no value instead
+ * of a purchase. The first charge after the trial is the order's sale, the later ones renewals.
+ *
+ * A movement the buyer's own request makes carries the buyer's platform, visit and traffic
+ * sources; a webhook or a cron job is the platform `core`. Money is always written; the visit
+ * and the sources only when the request could be tracked.
+ *
+ * Past payments are read in steps through the payment module's paged services (backfill).
+ */
+class BxAnalyticsRevenue
+{
+    const KIND_SALE = 'sale';
+    const KIND_RENEWAL = 'renewal';
+    const KIND_REFUND = 'refund';
+    const KIND_REVERSAL = 'reversal';
+    const KIND_TRIAL = 'trial';
+
+    const TYPE_SINGLE = 'single';
+    const TYPE_RECURRING = 'recurring';
+
+    const STATUS_TRIAL = 'trial';
+    const STATUS_ACTIVE = 'active';
+    const STATUS_UNPAID = 'unpaid';
+    const STATUS_CANCELED = 'canceled';
+
+    const PROVIDER_CREDITS = 'credits';
+    const PROVIDER_APPLE = 'apple_in_app';
+
+    /**
+     * A payment registered further than this from now is history the alert brought along
+     * (renewals of an order recorded before analytics was installed), not something the
+     * current request did.
+     */
+    const LIVE_WINDOW = 86400;
+
+    /**
+     * How long an unpaid subscription still counts as paying after its paid period ends: the
+     * payment module retries a failed charge for a few days and never cancels it by itself.
+     */
+    const UNPAID_GRACE = 604800;
+
+    /**
+     * Subscription period units per month, for MRR.
+     */
+    protected static $_aMonthFactors = [
+        'year' => 1 / 12,
+        'month' => 1,
+        'week' => 52 / 12,
+        'day' => 365.25 / 12,
+        'hour' => 730.5,
+        'minute' => 43830,
+    ];
+
+    /**
+     * Subscription period units in seconds.
+     */
+    protected static $_aUnitSeconds = [
+        'year' => 31557600,
+        'month' => 2629800,
+        'week' => 604800,
+        'day' => 86400,
+        'hour' => 3600,
+        'minute' => 60,
+    ];
+
+    protected $_oModule;
+    protected $_oConfig;
+    protected $_oDb;
+    protected $_oRequest;
+    protected $_iNow;
+
+    /**
+     * What the request knows that tests set instead: `api` (the request came through
+     * api.php), `api_key` (with an accepted API key), `logged` (the signed-in profile).
+     */
+    protected $_aOptions;
+
+    protected $_oCollector = null;
+    protected $_sDefaultCurrency = null;
+    protected $_aModuleNames = [];
+    protected $_aSources = [];
+
+    public function __construct($oModule, $oRequest = null, $iNow = null, $aOptions = [])
+    {
+        $this->_oModule = $oModule;
+        $this->_oConfig = $oModule->_oConfig;
+        $this->_oDb = $oModule->_oDb;
+        $this->_oRequest = $oRequest ? $oRequest : new BxAnalyticsRequest();
+        $this->_iNow = $iNow ? (int)$iNow : time();
+        $this->_aOptions = is_array($aOptions) ? $aOptions : [];
+    }
+
+    /*
+     * Pure helpers
+     */
+
+    /**
+     * How many of a period unit make a month, or null for a unit the payment module doesn't use.
+     */
+    public static function getMonthFactor($sUnit)
+    {
+        return isset(self::$_aMonthFactors[$sUnit]) ? self::$_aMonthFactors[$sUnit] : null;
+    }
+
+    /**
+     * Monthly recurring revenue of a price charged every `$iPeriod` units, or null when the
+     * price or the unit is unknown.
+     */
+    public static function getMrr($fAmount, $iPeriod, $sUnit)
+    {
+        $fFactor = self::getMonthFactor($sUnit);
+        if($fAmount === null || $fFactor === null)
+            return null;
+
+        return round((float)$fAmount * $fFactor / max(1, (int)$iPeriod), 4);
+    }
+
+    /**
+     * Seconds per subscription period unit: [unit => seconds].
+     */
+    public static function getUnitSeconds()
+    {
+        return self::$_aUnitSeconds;
+    }
+
+    /**
+     * Length of a subscription period in seconds, or null for a unit the payment module doesn't use.
+     */
+    public static function getPeriodSeconds($iPeriod, $sUnit)
+    {
+        return isset(self::$_aUnitSeconds[$sUnit]) ? max(1, (int)$iPeriod) * self::$_aUnitSeconds[$sUnit] : null;
+    }
+
+    /**
+     * A charge of a subscription with a trial is its registration when it came in the first
+     * half of the trial: the payment module registers the order when the trial starts, and
+     * the first real charge comes when it ends.
+     *
+     * @param array $aSubscription ledger subscription row: started, trial (days)
+     */
+    public static function isTrialRegistration($aSubscription, $iTs)
+    {
+        $iTrial = !empty($aSubscription['trial']) ? (int)$aSubscription['trial'] : 0;
+
+        return $iTrial > 0 && (int)$iTs < self::getTrialPaidFrom($aSubscription);
+    }
+
+    /**
+     * The time from which charges of a subscription are paid ones, see isTrialRegistration().
+     */
+    public static function getTrialPaidFrom($aSubscription)
+    {
+        $iTrial = !empty($aSubscription['trial']) ? (int)$aSubscription['trial'] : 0;
+
+        return $iTrial > 0 ? (int)$aSubscription['started'] + $iTrial * 43200 : 0;
+    }
+
+    /**
+     * The rate of an order's currency against the default currency, from the snapshot the
+     * payment module stores with the order (`data`: serialized cur_def and cur_rate): 1 for
+     * the default currency (today's, whatever it was when the order was placed), the
+     * snapshot's rate, or null when the snapshot doesn't tell (no rate, or the site's default
+     * currency has changed since).
+     */
+    public static function getSnapshotRate($sCurrency, $sData, $sDefault)
+    {
+        if($sCurrency === '' || $sCurrency === $sDefault)
+            return 1.0;
+
+        $aData = is_string($sData) && $sData !== '' ? @unserialize($sData, ['allowed_classes' => false]) : [];
+        if(!is_array($aData))
+            $aData = [];
+
+        $sSnapshotDefault = !empty($aData['cur_def']) && is_string($aData['cur_def']) ? $aData['cur_def'] : $sDefault;
+        if($sSnapshotDefault !== $sDefault)
+            return null;
+
+        if(!empty($aData['cur_rate']) && is_numeric($aData['cur_rate']) && (float)$aData['cur_rate'] > 0)
+            return (float)$aData['cur_rate'];
+
+        return null;
+    }
+
+    /**
+     * An amount in the default currency, or null without a rate. Rates are quoted against the
+     * default currency, so the order's amount is divided by its rate.
+     */
+    public static function toDefault($fAmount, $mixedRate)
+    {
+        if($mixedRate === null || (float)$mixedRate <= 0)
+            return null;
+
+        return round((float)$fAmount / (float)$mixedRate, 4);
+    }
+
+    /**
+     * A charge of a recurring order is its sale when it is the order's first charge and a
+     * renewal otherwise; every transaction of a single order is a sale. A charge is the
+     * transactions one registration wrote (one per item, add-ons included): they share their time.
+     *
+     * @param int $iChargeTs time of the transaction
+     * @param int $iFirstTs time of the order's first charge
+     */
+    public static function getKind($sType, $iChargeTs, $iFirstTs)
+    {
+        return $sType == self::TYPE_RECURRING && (int)$iChargeTs != (int)$iFirstTs ? self::KIND_RENEWAL : self::KIND_SALE;
+    }
+
+    /**
+     * The kind of a charge, trials included: a charge of a recurring order that registered its
+     * trial (see isTrialRegistration()) is a `trial`, any other follows getKind() with the
+     * order's first paid charge.
+     *
+     * @param array $aSubscription ledger subscription row (started, trial), [] for none
+     * @param int $iFirstPaidTs time of the order's first charge that is not a trial registration
+     */
+    public static function getChargeKind($sType, $aSubscription, $iChargeTs, $iFirstPaidTs)
+    {
+        if($sType == self::TYPE_RECURRING && self::isTrialRegistration($aSubscription, $iChargeTs))
+            return self::KIND_TRIAL;
+
+        return self::getKind($sType, $iChargeTs, $iFirstPaidTs);
+    }
+
+    /**
+     * The action a charge of the current request records: a purchase, a renewal, or the start
+     * of a trial (with no value, it took no money).
+     */
+    public static function getActionName($sKind)
+    {
+        switch($sKind) {
+            case self::KIND_RENEWAL:
+                return 'renewal';
+            case self::KIND_TRIAL:
+                return 'trial_start';
+        }
+
+        return 'purchase';
+    }
+
+    /**
+     * Kinds of an order's charges, from its ledger rows (sale, renewal and trial rows; refund and
+     * reversal rows are read to leave the charges they negate as they are) and its subscription:
+     * [row id => kind] for the rows whose kind differs. Used when the subscription becomes known
+     * after its charges were read (the import reads transactions before subscriptions).
+     */
+    public static function getKindChanges($sType, $aSubscription, $aRows)
+    {
+        $aNegated = [];
+        $iFirst = 0;
+        foreach($aRows as $aRow) {
+            if(in_array($aRow['kind'], [self::KIND_REFUND, self::KIND_REVERSAL], true))
+                $aNegated[(int)$aRow['transaction_id']] = true;
+            else if($sType != self::TYPE_RECURRING || !self::isTrialRegistration($aSubscription, (int)$aRow['ts']))
+                $iFirst = $iFirst ? min($iFirst, (int)$aRow['ts']) : (int)$aRow['ts'];
+        }
+
+        $aChanges = [];
+        foreach($aRows as $aRow) {
+            if(!in_array($aRow['kind'], [self::KIND_SALE, self::KIND_RENEWAL, self::KIND_TRIAL], true) || isset($aNegated[(int)$aRow['transaction_id']]))
+                continue;
+
+            $sKind = self::getChargeKind($sType, $aSubscription, (int)$aRow['ts'], $iFirst);
+            if($sKind != $aRow['kind'])
+                $aChanges[(int)$aRow['id']] = $sKind;
+        }
+
+        return $aChanges;
+    }
+
+    /**
+     * Module and item of an order's first item, from the payment module's item descriptors
+     * (vendor_module_item_count, joined with ":").
+     *
+     * @return array [module id, item id]
+     */
+    public static function getFirstItem($sItems)
+    {
+        $aItems = explode(':', (string)$sItems);
+        $aParts = explode('_', (string)reset($aItems));
+
+        return [isset($aParts[1]) ? (int)$aParts[1] : 0, isset($aParts[2]) ? (int)$aParts[2] : 0];
+    }
+
+    /*
+     * Alerts
+     */
+
+    /**
+     * A payment alert on the `system` unit: extras carry the payment module's name.
+     */
+    public function onPaymentAlert($oAlert)
+    {
+        $aExtras = is_array($oAlert->aExtras) ? $oAlert->aExtras : [];
+        $sPayment = isset($aExtras['module']) && is_string($aExtras['module']) && preg_match('/^[a-z0-9_]{1,32}$/', $aExtras['module']) ? $aExtras['module'] : '';
+        if($sPayment === '')
+            return false;
+
+        $aPending = isset($aExtras['pending']) && is_array($aExtras['pending']) ? $aExtras['pending'] : [];
+        $aSubscription = isset($aExtras['subscription']) && is_array($aExtras['subscription']) ? $aExtras['subscription'] : [];
+
+        switch($oAlert->sAction) {
+            case 'register_payment':
+                return $this->registerPayment($sPayment, $aPending);
+
+            case 'refund_payment':
+                return $this->refundPayment($sPayment, $aPending);
+
+            case 'create_subscription':
+                return $this->createSubscription($sPayment, $aPending, $aSubscription);
+
+            case 'prolong_subscription':
+                return $this->setSubscriptionStatus($sPayment, $aPending, self::STATUS_ACTIVE);
+
+            case 'overdue_subscription':
+                return $this->setSubscriptionStatus($sPayment, $aPending, self::STATUS_UNPAID);
+
+            case 'cancel_subscription':
+                return $this->cancelSubscription($sPayment, $aPending, $aSubscription, isset($aExtras['reason']) && is_string($aExtras['reason']) ? $aExtras['reason'] : '');
+
+            case 'cancel_order':
+                // a pending order never took money
+                if(isset($aExtras['type']) && $aExtras['type'] == 'processed' && isset($aExtras['order']) && is_array($aExtras['order']))
+                    return $this->reverseTransaction($sPayment, $aExtras['order']);
+                return false;
+
+            case 'finalize_checkout':
+                return $this->finalizeCheckout($sPayment, $aPending);
+        }
+
+        return false;
+    }
+
+    /**
+     * An order was paid, a subscription charged or its trial registered: every transaction of
+     * the order the ledger doesn't have yet. A transaction the current request made gets its
+     * context and becomes a purchase, renewal or trial_start event, so goals and funnels can
+     * end in a sale or a trial.
+     *
+     * @return int movements added
+     */
+    public function registerPayment($sPayment, $aPending)
+    {
+        $iPendingId = isset($aPending['id']) ? (int)$aPending['id'] : 0;
+        if(!$iPendingId)
+            return 0;
+
+        // paid before joining: the order gets its client when the account is created
+        if(empty($aPending['client_id']) || !isset($aPending['type'])) {
+            $aFresh = $this->_getPendingOrder($sPayment, $iPendingId);
+            if(!empty($aFresh))
+                $aPending = $aFresh;
+        }
+
+        $aTransactions = $this->_getTransactions($sPayment, $iPendingId);
+        if(empty($aTransactions))
+            return 0;
+
+        $aPending['client_id'] = isset($aPending['client_id']) ? (int)$aPending['client_id'] : 0;
+
+        $sType = isset($aPending['type']) ? $aPending['type'] : self::TYPE_SINGLE;
+        $fRate = $this->getRate(isset($aPending['currency']) ? (string)$aPending['currency'] : '', isset($aPending['data']) ? $aPending['data'] : '');
+
+        // a renewal brings the whole order along: skip the charges the ledger has
+        $aRows = $this->_oDb->getRevenueRows($sPayment, $iPendingId, [self::KIND_SALE, self::KIND_RENEWAL, self::KIND_TRIAL]);
+        $aKnown = array_flip(array_map('intval', array_column($aRows, 'transaction_id')));
+
+        $aNew = [];
+        foreach($aTransactions as $aTransaction)
+            if(!isset($aKnown[(int)$aTransaction['id']]))
+                $aNew[] = $aTransaction;
+        if(empty($aNew))
+            return 0;
+
+        // the subscription tells a trial's registration from a charge: its row is made first
+        // when the subscription began before analytics was installed. A subscription's first
+        // sale a webhook registers keeps the platform of the buyer's checkout.
+        $aSubscription = [];
+        if($sType == self::TYPE_RECURRING && $this->_ensureSubscription($sPayment, $aPending))
+            $aSubscription = $this->_oDb->getSubscriptionRow($sPayment, $iPendingId);
+        $sCheckoutPlatform = !empty($aSubscription['platform']) ? (string)$aSubscription['platform'] : '';
+
+        // the order's first paid charge is the earliest one either side knows (the payment
+        // module deletes a transaction its seller cancels, the ledger keeps it); the
+        // registration of a trial is not one
+        $iFirst = 0;
+        foreach($aRows as $aRow)
+            if($aRow['kind'] != self::KIND_TRIAL)
+                $iFirst = $iFirst ? min($iFirst, (int)$aRow['ts']) : (int)$aRow['ts'];
+        foreach($aNew as $aTransaction) {
+            $iTs = !empty($aTransaction['date']) ? (int)$aTransaction['date'] : $this->_iNow;
+            if(self::getChargeKind($sType, $aSubscription, $iTs, $iTs) != self::KIND_TRIAL)
+                $iFirst = $iFirst ? min($iFirst, $iTs) : $iTs;
+        }
+
+        $iAdded = 0;
+        $aLive = [];
+        $aPaid = [];
+        foreach($aNew as $aTransaction) {
+            $iTs = !empty($aTransaction['date']) ? (int)$aTransaction['date'] : $this->_iNow;
+            $sKind = self::getChargeKind($sType, $aSubscription, $iTs, $iFirst);
+            $bLive = abs($this->_iNow - $iTs) <= self::LIVE_WINDOW;
+
+            // a trial starts at the checkout, like a sale
+            $sContextKind = $sKind == self::KIND_TRIAL ? self::KIND_SALE : $sKind;
+            $aContext = $bLive ? $this->getContext((int)$aPending['client_id'], isset($aPending['provider']) ? $aPending['provider'] : '', $sContextKind, $iTs, $sContextKind == self::KIND_SALE ? $sCheckoutPlatform : '') : $this->_getImportContext((int)$aPending['client_id'], $iTs);
+            $aRow = $this->_getLedgerRow($sPayment, $aPending, $aTransaction, $sKind, $iTs, $fRate, $aContext);
+            if(!$this->_oDb->insertRevenue($aRow))
+                continue;
+
+            $iAdded++;
+            if($sKind != self::KIND_TRIAL)
+                $aPaid[$iTs] = $iTs;
+            if($bLive)
+                $aLive[] = [$aRow, $aContext];
+        }
+
+        // the registration of a trial took no money: the subscription pays from its first charge
+        if(!empty($aPaid) && $sType == self::TYPE_RECURRING)
+            $this->_oDb->addSubscriptionPayments($sPayment, $iPendingId, count($aPaid), min($aPaid), max($aPaid));
+
+        // no amounts or order numbers in the properties: the events report shows them to
+        // everyone who may use analytics, revenue or not
+        foreach($aLive as list($aRow, $aContext))
+            if($aContext['track'])
+                $this->_getCollector()->collectAction(self::getActionName($aRow['kind']), $aRow['module'], $aRow['item_id'], $aRow['client_id'], [
+                    'platform' => $aContext['platform'],
+                    'api_key' => $this->_isApiKey(),
+                    // decided by getContext: a webhook or a cron job is not the buyer's request
+                    'request' => $aContext['buyer'],
+                    'value' => $aRow['kind'] != self::KIND_TRIAL && $aRow['amount_default'] !== null ? (float)$aRow['amount_default'] : 0,
+                    'props' => [
+                        'provider' => $aRow['provider'],
+                    ],
+                ]);
+
+        return $iAdded;
+    }
+
+    /**
+     * An order was refunded. The payment module has deleted its transactions by now, so the
+     * ledger negates its own rows: every sale of a single order, and only the latest charge of
+     * a recurring one (the provider refunds one charge). A refund keeps the platform and the
+     * sources of the sale it takes back, so net revenue per source stays right. A transaction
+     * its seller already cancelled (a reversal) is not taken back again, a trial's registration
+     * took no money and is never taken back (a refund during the trial adds nothing), and an
+     * order is refunded once: the payment module deletes it.
+     *
+     * @return int movements added
+     */
+    public function refundPayment($sPayment, $aPending)
+    {
+        $iPendingId = isset($aPending['id']) ? (int)$aPending['id'] : 0;
+        if(!$iPendingId)
+            return 0;
+
+        $aNegated = [];
+        foreach($this->_oDb->getRevenueRows($sPayment, $iPendingId, [self::KIND_REFUND, self::KIND_REVERSAL]) as $aRow) {
+            if($aRow['kind'] == self::KIND_REFUND)
+                return 0;
+
+            $aNegated[(int)$aRow['transaction_id']] = true;
+        }
+
+        $aRows = [];
+        foreach($this->_oDb->getRevenueRows($sPayment, $iPendingId, [self::KIND_SALE, self::KIND_RENEWAL]) as $aRow)
+            if(!isset($aNegated[(int)$aRow['transaction_id']]))
+                $aRows[] = $aRow;
+
+        if(empty($aRows))
+            return 0;
+
+        // the latest charge: every item of it
+        if(isset($aPending['type']) && $aPending['type'] == self::TYPE_RECURRING) {
+            $iLast = max(array_map('intval', array_column($aRows, 'ts')));
+            $aRows = array_filter($aRows, function($aRow) use ($iLast) { return (int)$aRow['ts'] == $iLast; });
+        }
+
+        $iAdded = 0;
+        foreach($aRows as $aRow)
+            $iAdded += $this->_insertNegation($aRow, self::KIND_REFUND);
+
+        return $iAdded;
+    }
+
+    /**
+     * A processed order (one transaction) was cancelled by its seller: the money goes back.
+     */
+    public function reverseTransaction($sPayment, $aOrder)
+    {
+        $iTransactionId = isset($aOrder['id']) ? (int)$aOrder['id'] : 0;
+        if(!$iTransactionId)
+            return 0;
+
+        $aRow = $this->_oDb->getRevenueRow($sPayment, $iTransactionId, [self::KIND_SALE, self::KIND_RENEWAL]);
+        if(empty($aRow) || !empty($this->_oDb->getRevenueRow($sPayment, $iTransactionId, [self::KIND_REFUND, self::KIND_REVERSAL])))
+            return 0;
+
+        return $this->_insertNegation($aRow, self::KIND_REVERSAL);
+    }
+
+    /**
+     * A subscription was registered: its plan and price per month.
+     */
+    public function createSubscription($sPayment, $aPending, $aSubscription)
+    {
+        $iPendingId = isset($aPending['id']) ? (int)$aPending['id'] : 0;
+        if(!$iPendingId)
+            return false;
+
+        $aContext = $this->getContext(isset($aPending['client_id']) ? (int)$aPending['client_id'] : 0, isset($aPending['provider']) ? $aPending['provider'] : '', self::KIND_SALE, $this->_iNow);
+
+        $aRow = $this->_getSubscriptionRow($sPayment, $aPending, $aSubscription);
+        $aRow['started'] = $this->_iNow;
+        $aRow['platform'] = $aContext['platform'];
+
+        return $this->_oDb->saveSubscriptionRow($aRow);
+    }
+
+    public function setSubscriptionStatus($sPayment, $aPending, $sStatus)
+    {
+        $iPendingId = isset($aPending['id']) ? (int)$aPending['id'] : 0;
+        if(!$iPendingId || !$this->_ensureSubscription($sPayment, $aPending))
+            return false;
+
+        $aRow = $this->_oDb->getSubscriptionRow($sPayment, $iPendingId);
+        if(!empty($aRow['canceled']))
+            return false;
+
+        return $this->_oDb->updateSubscriptionRow($sPayment, $iPendingId, ['status' => $sStatus]) > 0;
+    }
+
+    /**
+     * A subscription was cancelled, with the reason the member or the provider gave.
+     */
+    public function cancelSubscription($sPayment, $aPending, $aSubscription, $sReason = '')
+    {
+        $iPendingId = isset($aPending['id']) ? (int)$aPending['id'] : 0;
+        if(!$iPendingId)
+            return false;
+
+        if(empty($this->_oDb->getSubscriptionRow($sPayment, $iPendingId))) {
+            $aRow = $this->_getSubscriptionRow($sPayment, $aPending, $aSubscription);
+            $aRow['started'] = !empty($aSubscription['date_add']) ? (int)$aSubscription['date_add'] : $this->_iNow;
+            $this->_oDb->saveSubscriptionRow($aRow);
+        }
+
+        return $this->_oDb->updateSubscriptionRow($sPayment, $iPendingId, [
+            'status' => self::STATUS_CANCELED,
+            'canceled' => $this->_iNow,
+            'reason' => get_mb_substr(trim($sReason), 0, 255),
+        ]) > 0;
+    }
+
+    /**
+     * The buyer finished the checkout in their own request: the order's rows a webhook
+     * registered first get the buyer's platform, visit and sources.
+     */
+    public function finalizeCheckout($sPayment, $aPending)
+    {
+        $iPendingId = isset($aPending['id']) ? (int)$aPending['id'] : 0;
+        if(!$iPendingId)
+            return 0;
+
+        $iClientId = isset($aPending['client_id']) ? (int)$aPending['client_id'] : 0;
+        $aContext = $this->getContext($iClientId, '', self::KIND_SALE, $this->_iNow);
+        if(!$aContext['buyer'])
+            return 0;
+
+        $aSet = ['platform' => $aContext['platform']];
+        if($aContext['link'])
+            $aSet = array_merge($aSet, ['session_id' => $aContext['session_id']], $this->_getSourceColumns($aContext));
+
+        $iUpdated = $this->_oDb->updateRevenueContext($sPayment, $iPendingId, $aSet);
+
+        $aSubscription = $this->_oDb->getSubscriptionRow($sPayment, $iPendingId);
+        if(!empty($aSubscription) && in_array($aSubscription['platform'], ['', BX_ANALYTICS_PLATFORM_CORE], true))
+            $this->_oDb->updateSubscriptionRow($sPayment, $iPendingId, ['platform' => $aContext['platform']]);
+
+        return $iUpdated;
+    }
+
+    /*
+     * Context and conversion
+     */
+
+    /**
+     * Who made a movement and how: the buyer's own request has its platform (and, when it can
+     * be tracked, the running visit); anything else (a webhook, a cron job, a seller or an
+     * administrator acting on the order) is the platform `core`. An Apple in-app sale outside
+     * the buyer's request still came from the iOS app, and so does a sale whose checkout
+     * platform is known (`$sCheckoutPlatform`, the subscription's, when its first charge
+     * comes by webhook). Traffic sources come from the buyer's first visit and the latest one
+     * before the movement.
+     *
+     * @return array platform, buyer (it is the buyer's request), track (the action may be
+     *      recorded), link (the member may be linked to visits), session_id, first and last
+     *      (channel, referrer_host, utm_campaign)
+     */
+    public function getContext($iClientId, $sProvider, $sKind, $iTs, $sCheckoutPlatform = '')
+    {
+        $iClientId = (int)$iClientId;
+        $bApiKey = $this->_isApiKey();
+
+        $sPlatform = $this->_oRequest->getPlatform($this->_isApi(), $bApiKey);
+        $bBuyer = $sPlatform != BX_ANALYTICS_PLATFORM_CORE && $iClientId && $this->_getLoggedProfileId() == $iClientId;
+        if(!$bBuyer) {
+            if($sKind == self::KIND_SALE && $sCheckoutPlatform !== '' && $sCheckoutPlatform != BX_ANALYTICS_PLATFORM_CORE)
+                $sPlatform = $sCheckoutPlatform;
+            else
+                $sPlatform = $sProvider == self::PROVIDER_APPLE && $sKind == self::KIND_SALE ? BX_ANALYTICS_PLATFORM_RN_IOS : BX_ANALYTICS_PLATFORM_CORE;
+        }
+
+        $bTrack = $this->_getCollector()->isTracked($sPlatform, $iClientId, $bApiKey, $bBuyer);
+        $bLink = $bTrack && $iClientId && $this->_oConfig->isLinkMembers();
+
+        $aContext = [
+            'platform' => $sPlatform,
+            'buyer' => $bBuyer,
+            'track' => $bTrack,
+            'link' => $bLink,
+            'session_id' => 0,
+            'first' => [],
+            'last' => [],
+        ];
+
+        if(!$bLink)
+            return $aContext;
+
+        if($bBuyer) {
+            $aSession = $this->_getCollector()->getRequestSession($sPlatform, $iClientId, $bApiKey);
+            if(!empty($aSession) && (int)$aSession['profile_id'] == $iClientId)
+                $aContext['session_id'] = (int)$aSession['id'];
+        }
+
+        return array_merge($aContext, $this->_getSources($iClientId, $iTs));
+    }
+
+    /**
+     * The rate of an order's currency against the default one: the order's own snapshot, or
+     * today's rate when the snapshot has none, or null when no rate is known.
+     */
+    public function getRate($sCurrency, $sData)
+    {
+        $sDefault = $this->getDefaultCurrency();
+        $mixedRate = self::getSnapshotRate((string)$sCurrency, $sData, $sDefault);
+        if($mixedRate !== null)
+            return $mixedRate;
+
+        if($sDefault === '' || $sCurrency === '')
+            return null;
+
+        $mixedConverted = BxDolPayments::getInstance()->convert(1, $sDefault, $sCurrency);
+
+        return is_numeric($mixedConverted) && (float)$mixedConverted > 0 ? (float)$mixedConverted : null;
+    }
+
+    public function getDefaultCurrency()
+    {
+        if($this->_sDefaultCurrency === null) {
+            $oPayments = BxDolPayments::getInstance();
+            $this->_sDefaultCurrency = $oPayments->isActive() ? (string)$oPayments->getCurrencyCode() : '';
+        }
+
+        return $this->_sDefaultCurrency;
+    }
+
+    /*
+     * Import of past payments
+     */
+
+    /**
+     * Read past transactions, then subscriptions, from the active payment module a page at a
+     * time until the deadline. The cursors are saved after every page, so the next step (the
+     * hourly cron, or the Studio button) continues where this one stopped; rows the ledger has
+     * already are skipped. Refunded orders can't be imported: the payment module deleted them.
+     *
+     * @param int $iDeadline unix time to stop by
+     * @return array the import status, see getBackfillStatus()
+     */
+    public function backfill($iDeadline)
+    {
+        $CNF = &$this->_oConfig->CNF;
+
+        $sPayment = $this->_getActivePayment();
+        if($sPayment === '' || !bx_is_srv($sPayment, 'get_transactions_after', 'Orders'))
+            return $this->getBackfillStatus();
+
+        while(time() < $iDeadline) {
+            $sCursor = (string)getParam($CNF['OPTION_BACKFILL']);
+            if($sCursor !== '' && (int)$sCursor >= 0) {
+                $aRows = bx_srv_ic($sPayment, 'get_transactions_after', [(int)$sCursor, $CNF['BACKFILL_PAGE']], 'Orders');
+                if(empty($aRows) || !is_array($aRows)) {
+                    setParam($CNF['OPTION_BACKFILL'], '-1');
+                    if((string)getParam($CNF['OPTION_BACKFILL_SBS']) === '')
+                        setParam($CNF['OPTION_BACKFILL_SBS'], 'active:0');
+                    continue;
+                }
+
+                $this->importTransactions($sPayment, $aRows);
+                $aLast = end($aRows);
+                setParam($CNF['OPTION_BACKFILL'], (string)(int)$aLast['id']);
+                continue;
+            }
+
+            $sCursor = (string)getParam($CNF['OPTION_BACKFILL_SBS']);
+            if(!preg_match('/^(active|deleted):(\d+)$/', $sCursor, $aMatch) || !bx_is_srv($sPayment, 'get_subscriptions_after', 'Subscriptions'))
+                break;
+
+            $bDeleted = $aMatch[1] == 'deleted';
+            $aRows = bx_srv_ic($sPayment, 'get_subscriptions_after', [(int)$aMatch[2], $CNF['BACKFILL_PAGE'], $bDeleted], 'Subscriptions');
+            if(empty($aRows) || !is_array($aRows)) {
+                setParam($CNF['OPTION_BACKFILL_SBS'], $bDeleted ? '-1' : 'deleted:0');
+                continue;
+            }
+
+            $this->importSubscriptions($sPayment, $aRows, $bDeleted);
+            $aLast = end($aRows);
+            setParam($CNF['OPTION_BACKFILL_SBS'], $aMatch[1] . ':' . (int)$aLast['pending_id']);
+        }
+
+        return $this->getBackfillStatus();
+    }
+
+    /**
+     * Start the import over: every past payment is read again, and the ones the ledger has
+     * are skipped.
+     */
+    public function restartBackfill()
+    {
+        setParam($this->_oConfig->CNF['OPTION_BACKFILL'], '0');
+        setParam($this->_oConfig->CNF['OPTION_BACKFILL_SBS'], '');
+    }
+
+    /**
+     * @param bool $bCount count the rows the import wrote (it reads the whole ledger)
+     * @return array state (unavailable: no payment module; running; done), imported (rows
+     *      the import wrote, 0 unless counted), cursor (the last transaction read)
+     */
+    public function getBackfillStatus($bCount = false)
+    {
+        $CNF = &$this->_oConfig->CNF;
+
+        $sPayment = $this->_getActivePayment();
+        if($sPayment === '' || !bx_is_srv($sPayment, 'get_transactions_after', 'Orders'))
+            return ['state' => 'unavailable', 'imported' => 0, 'cursor' => 0];
+
+        $sCursor = (string)getParam($CNF['OPTION_BACKFILL']);
+        $sSbs = (string)getParam($CNF['OPTION_BACKFILL_SBS']);
+        $bDone = ($sCursor === '' || (int)$sCursor < 0) && !preg_match('/^(active|deleted):\d+$/', $sSbs);
+
+        return [
+            'state' => $bDone ? 'done' : 'running',
+            'imported' => $bCount ? $this->_oDb->getRevenueImportedCount($sPayment) : 0,
+            'cursor' => max(0, (int)$sCursor),
+        ];
+    }
+
+    /**
+     * Past transactions into the ledger, with the time they were registered and no platform.
+     * A transaction the ledger has as a sale, a renewal or a trial is skipped whatever its kind,
+     * and kinds follow the same rule as live payments (see registerPayment()) with the
+     * subscriptions the ledger has. The import reads transactions before subscriptions, so the
+     * trial of a subscription the ledger doesn't have yet is applied when importSubscriptions()
+     * reads it.
+     *
+     * @return int movements added
+     */
+    public function importTransactions($sPayment, $aRows)
+    {
+        $aTypes = [];
+        foreach($aRows as $aRow)
+            $aTypes[(int)$aRow['pending_id']] = !empty($aRow['type']) ? $aRow['type'] : self::TYPE_SINGLE;
+
+        // trials of the recurring orders on this page: [pending id => started, trial]
+        $aTrials = $this->_oDb->getSubscriptionTrials($sPayment, array_keys(array_filter($aTypes, function($sType) { return $sType == self::TYPE_RECURRING; })));
+
+        $aFirst = [];
+        foreach($aRows as $aRow) {
+            $iPendingId = (int)$aRow['pending_id'];
+            $aSubscription = isset($aTrials[$iPendingId]) ? $aTrials[$iPendingId] : [];
+            if(self::getChargeKind($aTypes[$iPendingId], $aSubscription, (int)$aRow['date'], (int)$aRow['date']) != self::KIND_TRIAL)
+                $aFirst[$iPendingId] = isset($aFirst[$iPendingId]) ? min($aFirst[$iPendingId], (int)$aRow['date']) : (int)$aRow['date'];
+        }
+
+        // a renewal on this page may belong to an order whose sale the ledger already has
+        foreach($this->_oDb->getRevenueFirstCharges($sPayment, array_keys($aTypes)) as $iPendingId => $iTs)
+            $aFirst[(int)$iPendingId] = isset($aFirst[(int)$iPendingId]) ? min($aFirst[(int)$iPendingId], (int)$iTs) : (int)$iTs;
+
+        $aKnown = array_flip($this->_oDb->getRevenueCharged($sPayment, array_column($aRows, 'id')));
+
+        $iAdded = 0;
+        foreach($aRows as $aRow) {
+            if(isset($aKnown[(int)$aRow['id']]))
+                continue;
+
+            $aPending = [
+                'id' => (int)$aRow['pending_id'],
+                'client_id' => (int)$aRow['client_id'],
+                'type' => !empty($aRow['type']) ? $aRow['type'] : self::TYPE_SINGLE,
+                'provider' => isset($aRow['provider']) ? (string)$aRow['provider'] : '',
+                'order' => isset($aRow['order']) ? (string)$aRow['order'] : '',
+                'currency' => !empty($aRow['pending_currency']) ? $aRow['pending_currency'] : (isset($aRow['currency']) ? $aRow['currency'] : ''),
+                'data' => isset($aRow['data']) ? $aRow['data'] : '',
+            ];
+
+            $iTs = (int)$aRow['date'];
+            $sKind = self::getChargeKind($aPending['type'], isset($aTrials[$aPending['id']]) ? $aTrials[$aPending['id']] : [], $iTs, isset($aFirst[$aPending['id']]) ? $aFirst[$aPending['id']] : 0);
+            $fRate = $this->getRate($aPending['currency'], $aPending['data']);
+
+            $iAdded += $this->_oDb->insertRevenue($this->_getLedgerRow($sPayment, $aPending, $aRow, $sKind, $iTs, $fRate, $this->_getImportContext((int)$aRow['client_id'], $iTs)));
+        }
+
+        return $iAdded;
+    }
+
+    /**
+     * Past subscriptions, current or cancelled, with their payments taken from the ledger. The
+     * charges of a subscription with a trial follow its trial: the import read them before it
+     * knew it (see importTransactions()).
+     */
+    public function importSubscriptions($sPayment, $aRows, $bDeleted)
+    {
+        $iImported = 0;
+        foreach($aRows as $aRow) {
+            if(empty($aRow['pending_id']) || (isset($aRow['type']) && $aRow['type'] != self::TYPE_RECURRING))
+                continue;
+
+            $aPending = [
+                'id' => (int)$aRow['pending_id'],
+                'client_id' => (int)$aRow['client_id'],
+                'seller_id' => (int)$aRow['seller_id'],
+                'provider' => (string)$aRow['provider'],
+                'items' => (string)$aRow['items'],
+                'amount' => (float)$aRow['amount'],
+                'currency' => (string)$aRow['currency'],
+                'data' => (string)$aRow['data'],
+            ];
+
+            $aSubscription = $this->_getSubscriptionRow($sPayment, $aPending, $aRow);
+            $aTrial = ['trial' => $aSubscription['trial'], 'started' => (int)$aRow['date_add']];
+            if($aTrial['trial'] > 0)
+                foreach(self::getKindChanges(self::TYPE_RECURRING, $aTrial, $this->_oDb->getRevenueRows($sPayment, $aPending['id'])) as $iId => $sKind)
+                    $this->_oDb->updateRevenueKind($iId, $sKind);
+
+            $aPaid = $this->_oDb->getRevenuePaid($sPayment, $aPending['id'], self::getTrialPaidFrom($aTrial));
+
+            $iImported += (int)$this->_oDb->importSubscriptionRow(array_merge($aSubscription, [
+                'status' => $bDeleted ? self::STATUS_CANCELED : $aSubscription['status'],
+                'started' => (int)$aRow['date_add'],
+                'first_paid' => (int)$aPaid['first'],
+                'last_paid' => (int)$aPaid['last'],
+                'paid_count' => (int)$aPaid['count'],
+                'canceled' => $bDeleted ? max(1, (int)$aRow['deleted']) : 0,
+                'reason' => $bDeleted ? get_mb_substr((string)$aRow['reason'], 0, 255) : '',
+                'platform' => '',
+            ]));
+        }
+
+        return $iImported;
+    }
+
+    /*
+     * Internals
+     */
+
+    /**
+     * A ledger row for a transaction of an order.
+     */
+    protected function _getLedgerRow($sPayment, $aPending, $aTransaction, $sKind, $iTs, $fRate, $aContext)
+    {
+        $iModuleId = (int)$aTransaction['module_id'];
+        $sProvider = isset($aPending['provider']) ? (string)$aPending['provider'] : (isset($aTransaction['provider']) ? (string)$aTransaction['provider'] : '');
+        $sCurrency = !empty($aTransaction['currency']) ? (string)$aTransaction['currency'] : (!empty($aPending['currency']) ? (string)$aPending['currency'] : $this->getDefaultCurrency());
+        $fAmount = round((float)$aTransaction['amount'], 4);
+
+        return array_merge([
+            'ts' => (int)$iTs,
+            'kind' => $sKind,
+            'cash' => $sProvider == self::PROVIDER_CREDITS ? 0 : 1,
+            'payment' => $sPayment,
+            'pending_id' => !empty($aTransaction['pending_id']) ? (int)$aTransaction['pending_id'] : (int)$aPending['id'],
+            'transaction_id' => (int)$aTransaction['id'],
+            'order' => get_mb_substr(isset($aPending['order']) ? (string)$aPending['order'] : (isset($aTransaction['order']) ? (string)$aTransaction['order'] : ''), 0, 32),
+            'module_id' => $iModuleId,
+            'module' => $this->_getModuleName($iModuleId),
+            'item_id' => max(0, (int)$aTransaction['item_id']),
+            'item_count' => max(0, (int)$aTransaction['item_count']),
+            'seller_id' => max(0, (int)$aTransaction['seller_id']),
+            'author_id' => max(0, (int)$aTransaction['author_id']),
+            'client_id' => max(0, !empty($aTransaction['client_id']) ? (int)$aTransaction['client_id'] : (isset($aPending['client_id']) ? (int)$aPending['client_id'] : 0)),
+            'provider' => get_mb_substr($sProvider, 0, 32),
+            'type' => isset($aPending['type']) ? (string)$aPending['type'] : self::TYPE_SINGLE,
+            'amount' => $fAmount,
+            'currency' => get_mb_substr($sCurrency, 0, 4),
+            // 0: no rate known, the amount isn't converted
+            'rate' => $fRate !== null ? $fRate : 0,
+            'amount_default' => self::toDefault($fAmount, $fRate),
+            'platform' => $aContext['platform'],
+            'session_id' => (int)$aContext['session_id'],
+        ], $this->_getSourceColumns($aContext));
+    }
+
+    /**
+     * A refund or reversal of a ledger row: the same order, item and sources, negative amounts.
+     */
+    protected function _insertNegation($aRow, $sKind)
+    {
+        unset($aRow['id']);
+
+        return $this->_oDb->insertRevenue(array_merge($aRow, [
+            'ts' => $this->_iNow,
+            'kind' => $sKind,
+            'amount' => -abs((float)$aRow['amount']),
+            'amount_default' => $aRow['amount_default'] !== null ? -abs((float)$aRow['amount_default']) : null,
+            'session_id' => 0,
+        ]));
+    }
+
+    /**
+     * A subscription row from an order and the payment module's subscription.
+     */
+    protected function _getSubscriptionRow($sPayment, $aPending, $aSubscription)
+    {
+        list($iModuleId, $iItemId) = self::getFirstItem(isset($aPending['items']) ? $aPending['items'] : '');
+
+        $iPeriod = max(1, isset($aSubscription['period']) ? (int)$aSubscription['period'] : 1);
+        $sUnit = isset($aSubscription['period_unit']) ? (string)$aSubscription['period_unit'] : '';
+        $fAmount = self::toDefault(isset($aPending['amount']) ? (float)$aPending['amount'] : 0, $this->getRate(isset($aPending['currency']) ? (string)$aPending['currency'] : '', isset($aPending['data']) ? $aPending['data'] : ''));
+
+        $sStatus = isset($aSubscription['status']) ? (string)$aSubscription['status'] : '';
+        if(!in_array($sStatus, [self::STATUS_TRIAL, self::STATUS_ACTIVE, self::STATUS_UNPAID, self::STATUS_CANCELED], true))
+            $sStatus = self::STATUS_UNPAID;
+
+        return [
+            'payment' => $sPayment,
+            'pending_id' => (int)$aPending['id'],
+            'subscription_id' => get_mb_substr(isset($aSubscription['subscription_id']) ? (string)$aSubscription['subscription_id'] : '', 0, 32),
+            'client_id' => isset($aPending['client_id']) ? max(0, (int)$aPending['client_id']) : 0,
+            'seller_id' => isset($aPending['seller_id']) ? max(0, (int)$aPending['seller_id']) : 0,
+            'module_id' => $iModuleId,
+            'module' => $this->_getModuleName($iModuleId),
+            'item_id' => $iItemId,
+            'provider' => get_mb_substr(isset($aPending['provider']) ? (string)$aPending['provider'] : '', 0, 32),
+            'period' => $iPeriod,
+            'period_unit' => get_mb_substr($sUnit, 0, 16),
+            'trial' => isset($aSubscription['trial']) ? max(0, (int)$aSubscription['trial']) : 0,
+            'amount_default' => $fAmount,
+            'mrr_default' => self::getMrr($fAmount, $iPeriod, $sUnit),
+            'status' => $sStatus,
+        ];
+    }
+
+    /**
+     * The ledger's row for a subscription, created from the payment module's when the
+     * subscription began before analytics was installed.
+     */
+    protected function _ensureSubscription($sPayment, $aPending)
+    {
+        $iPendingId = (int)$aPending['id'];
+        if(!empty($this->_oDb->getSubscriptionRow($sPayment, $iPendingId)))
+            return true;
+
+        $aSubscription = [];
+        if(bx_is_srv($sPayment, 'get_subscriptions_info', 'Subscriptions')) {
+            $aList = bx_srv_ic($sPayment, 'get_subscriptions_info', [['pending_id' => $iPendingId]], 'Subscriptions');
+            if(!empty($aList) && is_array($aList))
+                $aSubscription = reset($aList);
+        }
+
+        if(empty($aPending['items']) || !isset($aPending['amount'])) {
+            $aFresh = $this->_getPendingOrder($sPayment, $iPendingId);
+            if(!empty($aFresh))
+                $aPending = $aFresh;
+        }
+
+        $aRow = $this->_getSubscriptionRow($sPayment, $aPending, is_array($aSubscription) ? $aSubscription : []);
+        $aRow['started'] = !empty($aSubscription['date_add']) ? (int)$aSubscription['date_add'] : $this->_iNow;
+
+        return $this->_oDb->saveSubscriptionRow($aRow);
+    }
+
+    /**
+     * Context of a movement read from history: no platform and no visit, sources when the
+     * member's visits may be linked.
+     */
+    protected function _getImportContext($iClientId, $iTs)
+    {
+        $aContext = ['platform' => '', 'buyer' => false, 'track' => false, 'link' => false, 'session_id' => 0, 'first' => [], 'last' => []];
+        if(!$iClientId || !$this->_oConfig->isLinkMembers() || !$this->_getCollector()->isTracked(BX_ANALYTICS_PLATFORM_CORE, $iClientId, false, false))
+            return $aContext;
+
+        return array_merge($aContext, $this->_getSources($iClientId, $iTs));
+    }
+
+    protected function _getSources($iClientId, $iTs)
+    {
+        $sKey = $iClientId . ':' . $iTs;
+        if(!isset($this->_aSources[$sKey]))
+            $this->_aSources[$sKey] = $this->_oDb->getSessionSources($iClientId, $iTs);
+
+        return $this->_aSources[$sKey];
+    }
+
+    protected function _getSourceColumns($aContext)
+    {
+        $aColumns = [];
+        foreach(['first', 'last'] as $sWhich) {
+            $aSource = !empty($aContext[$sWhich]) && is_array($aContext[$sWhich]) ? $aContext[$sWhich] : [];
+            $aColumns[$sWhich . '_channel'] = isset($aSource['channel']) ? (string)$aSource['channel'] : '';
+            $aColumns[$sWhich . '_referrer_host'] = isset($aSource['referrer_host']) ? (string)$aSource['referrer_host'] : '';
+            $aColumns[$sWhich . '_utm_campaign'] = isset($aSource['utm_campaign']) ? (string)$aSource['utm_campaign'] : '';
+        }
+
+        return $aColumns;
+    }
+
+    protected function _getModuleName($iModuleId)
+    {
+        $iModuleId = (int)$iModuleId;
+        if(!$iModuleId)
+            return '';
+
+        if(!isset($this->_aModuleNames[$iModuleId])) {
+            $aModule = BxDolModuleQuery::getInstance()->getModuleById($iModuleId);
+            $this->_aModuleNames[$iModuleId] = !empty($aModule['name']) ? get_mb_substr($aModule['name'], 0, 32) : '';
+        }
+
+        return $this->_aModuleNames[$iModuleId];
+    }
+
+    /**
+     * Transactions of an order from the payment module that reported it.
+     */
+    protected function _getTransactions($sPayment, $iPendingId)
+    {
+        if(!bx_is_srv($sPayment, 'get_orders_info', 'Orders'))
+            return [];
+
+        $aRows = bx_srv_ic($sPayment, 'get_orders_info', [['pending_id' => (int)$iPendingId]], 'Orders');
+
+        return is_array($aRows) ? $aRows : [];
+    }
+
+    protected function _getPendingOrder($sPayment, $iPendingId)
+    {
+        if(!bx_is_srv($sPayment, 'get_pending_orders_info', 'Orders'))
+            return [];
+
+        $aRows = bx_srv_ic($sPayment, 'get_pending_orders_info', [['id' => (int)$iPendingId]], 'Orders');
+
+        return !empty($aRows) && is_array($aRows) ? reset($aRows) : [];
+    }
+
+    protected function _getActivePayment()
+    {
+        $oPayments = BxDolPayments::getInstance();
+
+        return $oPayments->isActive() ? (string)$oPayments->getActive() : '';
+    }
+
+    protected function _getCollector()
+    {
+        if($this->_oCollector === null)
+            $this->_oCollector = new BxAnalyticsCollector($this->_oModule, $this->_oRequest, $this->_iNow);
+
+        return $this->_oCollector;
+    }
+
+    protected function _getLoggedProfileId()
+    {
+        return array_key_exists('logged', $this->_aOptions) ? (int)$this->_aOptions['logged'] : (int)bx_get_logged_profile_id();
+    }
+
+    protected function _isApi()
+    {
+        return array_key_exists('api', $this->_aOptions) ? (bool)$this->_aOptions['api'] : defined('BX_API');
+    }
+
+    protected function _isApiKey()
+    {
+        return array_key_exists('api_key', $this->_aOptions) ? (bool)$this->_aOptions['api_key'] : $this->_oModule->isApiKeyRequest();
+    }
+}
+
+/** @} */

+ 280 - 0
modules/boonex/analytics/updates/15.0.1_15.0.2/source/classes/BxAnalyticsSource.php

@@ -0,0 +1,280 @@
+<?php defined('BX_DOL') or die('hack attempt');
+/**
+ * Copyright (c) UNA, Inc - https://una.io
+ * MIT License - https://opensource.org/licenses/MIT
+ *
+ * @defgroup    Analytics Analytics
+ * @ingroup     UnaModules
+ *
+ * @{
+ */
+
+/**
+ * Traffic source of a session: referrer host, UTM parameters, click ids and the channel they add up to.
+ */
+class BxAnalyticsSource
+{
+    const CHANNEL_DIRECT = 'direct';
+    const CHANNEL_INTERNAL = 'internal';
+    const CHANNEL_SEARCH = 'search';
+    const CHANNEL_PAID_SEARCH = 'paid_search';
+    const CHANNEL_SOCIAL = 'social';
+    const CHANNEL_PAID_SOCIAL = 'paid_social';
+    const CHANNEL_EMAIL = 'email';
+    const CHANNEL_AI = 'ai';
+    const CHANNEL_DISPLAY = 'display';
+    const CHANNEL_AFFILIATE = 'affiliate';
+    const CHANNEL_CAMPAIGN = 'campaign';
+    const CHANNEL_REFERRAL = 'referral';
+
+    /**
+     * Host suffixes per channel. A host matches a suffix when it equals it or ends with "." . suffix.
+     * A suffix ending with "." matches any top level domain (google.com, google.co.uk).
+     */
+    protected static $_aHosts = [
+        self::CHANNEL_AI => [
+            'chatgpt.com', 'chat.openai.com', 'openai.com', 'perplexity.ai', 'claude.ai', 'gemini.google.com', 'bard.google.com',
+            'copilot.microsoft.com', 'copilot.cloud.microsoft', 'you.com', 'phind.com', 'poe.com', 'chat.deepseek.com', 'meta.ai',
+            'grok.com', 'chat.mistral.ai',
+        ],
+        self::CHANNEL_SEARCH => [
+            'google.', 'bing.com', 'yahoo.', 'duckduckgo.com', 'yandex.', 'ya.ru', 'baidu.com', 'ecosia.org', 'qwant.com',
+            'search.brave.com', 'startpage.com', 'naver.com', 'seznam.cz', 'sogou.com', 'so.com', 'ask.com', 'aol.com',
+            'kagi.com', 'mojeek.com', 'search.yahoo.co.jp', 'daum.net', 'coccoc.com', 'petalsearch.com',
+        ],
+        self::CHANNEL_SOCIAL => [
+            'facebook.com', 'fb.com', 'fb.me', 'messenger.com', 'instagram.com', 't.co', 'twitter.com', 'x.com', 'linkedin.com',
+            'lnkd.in', 'reddit.com', 'redd.it', 'pinterest.', 'pin.it', 'tiktok.com', 'youtube.com', 'youtu.be', 'vk.com', 'vk.ru',
+            'ok.ru', 't.me', 'telegram.org', 'telegram.me', 'whatsapp.com', 'wa.me', 'threads.net', 'threads.com', 'bsky.app',
+            'mastodon.social', 'quora.com', 'tumblr.com', 'discord.com', 'discord.gg', 'snapchat.com', 'weibo.com', 'weibo.cn',
+            'news.ycombinator.com', 'line.me', 'kakao.com', 'twitch.tv', 'medium.com', 'substack.com', 'nextdoor.com',
+            'xing.com', 'flickr.com', 'vimeo.com', 'dribbble.com', 'behance.net', 'stackoverflow.com', 'github.com',
+        ],
+        self::CHANNEL_EMAIL => [
+            'mail.google.com', 'inbox.google.com', 'outlook.live.com', 'outlook.office.com', 'outlook.office365.com',
+            'mail.yahoo.com', 'mail.yandex.ru', 'mail.ru', 'e.mail.ru', 'mail.aol.com', 'mail.proton.me', 'mail.zoho.com',
+            'icloud.com', 'fastmail.com', 'gmx.net', 'gmx.com', 'web.de', 't-online.de',
+        ],
+    ];
+
+    /**
+     * Click id parameters that ad networks append, with the channel each one means.
+     */
+    protected static $_aClickIds = [
+        'gclid' => self::CHANNEL_PAID_SEARCH,
+        'gbraid' => self::CHANNEL_PAID_SEARCH,
+        'wbraid' => self::CHANNEL_PAID_SEARCH,
+        'msclkid' => self::CHANNEL_PAID_SEARCH,
+        'yclid' => self::CHANNEL_PAID_SEARCH,
+        'dclid' => self::CHANNEL_DISPLAY,
+        'ttclid' => self::CHANNEL_PAID_SOCIAL,
+        'li_fat_id' => self::CHANNEL_PAID_SOCIAL,
+        'twclid' => self::CHANNEL_PAID_SOCIAL,
+        'sccid' => self::CHANNEL_PAID_SOCIAL,
+        'epik' => self::CHANNEL_PAID_SOCIAL,
+    ];
+
+    public static $aUtm = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content'];
+
+    /**
+     * Work out the source of a session from its first page.
+     *
+     * @param string $sReferrer document.referrer or the app's referrer, may be empty
+     * @param string $sUrl the landing page URL with its query string, for UTM parameters and click ids
+     * @param array $aInternalHosts hosts of the site and its clients: a referrer from one of them is not a source
+     * @return array with keys referrer (scheme and query stripped), referrer_host, channel and the utm_* keys
+     */
+    public static function classify($sReferrer, $sUrl = '', $aInternalHosts = [])
+    {
+        $aQuery = self::_query($sUrl);
+
+        $aResult = [
+            'referrer' => '',
+            'referrer_host' => '',
+            'channel' => self::CHANNEL_DIRECT,
+        ];
+        foreach(self::$aUtm as $sKey)
+            $aResult[$sKey] = isset($aQuery[$sKey]) && is_string($aQuery[$sKey]) ? self::_clean($aQuery[$sKey], 128) : '';
+
+        // ref= / source= are common in links without UTM (Product Hunt, newsletters)
+        if($aResult['utm_source'] === '')
+            foreach(['ref', 'source'] as $sKey)
+                if(isset($aQuery[$sKey]) && is_string($aQuery[$sKey]) && $aQuery[$sKey] !== '') {
+                    $aResult['utm_source'] = self::_clean($aQuery[$sKey], 128);
+                    break;
+                }
+
+        $sHost = self::host($sReferrer);
+        $bInternal = $sHost !== '' && self::_isInternal($sHost, $aInternalHosts);
+        if($sHost !== '' && !$bInternal) {
+            $aResult['referrer_host'] = $sHost;
+            $aResult['referrer'] = self::_clean(preg_replace(['/^[a-z][a-z0-9+.-]*:\/\/(www\.)?/i', '/[?#].*$/'], '', $sReferrer), 255);
+        }
+
+        $aResult['channel'] = self::channel($aResult['referrer_host'], $aResult['utm_source'], $aResult['utm_medium'], array_keys($aQuery));
+        if($aResult['channel'] === self::CHANNEL_DIRECT && $bInternal)
+            $aResult['channel'] = self::CHANNEL_INTERNAL;
+
+        return $aResult;
+    }
+
+    /**
+     * Channel from a referrer host, UTM source and medium, and the landing page's query parameter names.
+     */
+    public static function channel($sReferrerHost, $sUtmSource = '', $sUtmMedium = '', $aQueryKeys = [])
+    {
+        $sMedium = mb_strtolower(trim($sUtmMedium));
+        $sSource = mb_strtolower(trim($sUtmSource));
+        $sSourceChannel = $sSource !== '' ? self::_hostChannel($sSource) : '';
+        $sReferrerChannel = $sReferrerHost !== '' ? self::_hostChannel($sReferrerHost) : '';
+        $sKnown = $sSourceChannel ? $sSourceChannel : $sReferrerChannel;
+
+        $bPaid = (bool)preg_match('/^(.*cpc|ppc|paid.*|cpm|cpv|cpa|retargeting|sem)$/', $sMedium);
+        if($bPaid) {
+            if(preg_match('/social/', $sMedium) || $sKnown === self::CHANNEL_SOCIAL)
+                return self::CHANNEL_PAID_SOCIAL;
+            if($sKnown === self::CHANNEL_SEARCH || preg_match('/search|cpc|ppc|sem/', $sMedium))
+                return self::CHANNEL_PAID_SEARCH;
+            return self::CHANNEL_DISPLAY;
+        }
+
+        // an explicit medium says more than a click id that an ad network appended
+        if($sMedium !== '') {
+            if(preg_match('/^(e[-_ ]?mail|newsletter|mailing)/', $sMedium))
+                return self::CHANNEL_EMAIL;
+            if(preg_match('/^(social|social[-_ ]?(network|media)|sm|post)$/', $sMedium))
+                return self::CHANNEL_SOCIAL;
+            if(preg_match('/^(display|banner|expandable|interstitial)$/', $sMedium))
+                return self::CHANNEL_DISPLAY;
+            if($sMedium === 'affiliate' || $sMedium === 'partner')
+                return self::CHANNEL_AFFILIATE;
+            if($sMedium === 'organic')
+                return $sKnown === self::CHANNEL_SOCIAL ? self::CHANNEL_SOCIAL : self::CHANNEL_SEARCH;
+            if($sMedium === 'referral')
+                return $sKnown ? $sKnown : self::CHANNEL_REFERRAL;
+        }
+
+        foreach(self::$_aClickIds as $sKey => $sChannel)
+            if(in_array($sKey, $aQueryKeys, true))
+                return $sChannel;
+
+        if($sKnown)
+            return $sKnown;
+
+        if($sSource !== '' || $sMedium !== '')
+            return self::CHANNEL_CAMPAIGN;
+
+        return $sReferrerHost !== '' ? self::CHANNEL_REFERRAL : self::CHANNEL_DIRECT;
+    }
+
+    /**
+     * Lower case host of a URL without "www.", or '' when there is none. Android apps send
+     * referrers like android-app://com.google.android.gm, which become the package name.
+     */
+    public static function host($sUrl)
+    {
+        $sUrl = trim((string)$sUrl);
+        if($sUrl === '')
+            return '';
+
+        $sHost = parse_url(bx_mb_strpos($sUrl, '//') === false ? '//' . $sUrl : $sUrl, PHP_URL_HOST);
+        if(!is_string($sHost) || $sHost === '')
+            return '';
+
+        $sHost = mb_strtolower(rtrim($sHost, '.'));
+        if(bx_mb_strpos($sHost, 'www.') === 0)
+            $sHost = get_mb_substr($sHost, 4, get_mb_len($sHost));
+
+        return preg_match('/^[a-z0-9.-]{1,253}$/', $sHost) ? $sHost : '';
+    }
+
+    protected static function _hostChannel($sHost)
+    {
+        $sHost = mb_strtolower($sHost);
+
+        // Android app referrers
+        $aApps = [
+            'com.google.android.gm' => self::CHANNEL_EMAIL,
+            'com.google.android.googlequicksearchbox' => self::CHANNEL_SEARCH,
+            'com.facebook.katana' => self::CHANNEL_SOCIAL,
+            'com.instagram.android' => self::CHANNEL_SOCIAL,
+            'com.twitter.android' => self::CHANNEL_SOCIAL,
+            'com.linkedin.android' => self::CHANNEL_SOCIAL,
+            'com.reddit.frontpage' => self::CHANNEL_SOCIAL,
+            'org.telegram.messenger' => self::CHANNEL_SOCIAL,
+            'com.whatsapp' => self::CHANNEL_SOCIAL,
+            'com.openai.chatgpt' => self::CHANNEL_AI,
+        ];
+        if(isset($aApps[$sHost]))
+            return $aApps[$sHost];
+
+        foreach(self::$_aHosts as $sChannel => $aSuffixes)
+            foreach($aSuffixes as $sSuffix) {
+                if(bx_mb_strpos($sSuffix, '/') !== false)
+                    continue;
+
+                if(get_mb_substr($sSuffix, -1, 1) === '.') {
+                    // google. matches google.com, google.co.uk, news.google.com
+                    if(preg_match('/(^|\.)' . preg_quote($sSuffix, '/') . '[a-z]{2,3}(\.[a-z]{2})?$/', $sHost))
+                        return $sChannel;
+                    continue;
+                }
+
+                if($sHost === $sSuffix || self::_endsWith($sHost, '.' . $sSuffix))
+                    return $sChannel;
+            }
+
+        // a bare UTM source like "google", "facebook" or "newsletter"
+        if(bx_mb_strpos($sHost, '.') === false) {
+            if(preg_match('/^(google|bing|yahoo|duckduckgo|yandex|baidu|ecosia|naver|brave)$/', $sHost))
+                return self::CHANNEL_SEARCH;
+            if(preg_match('/^(facebook|fb|instagram|ig|twitter|x|linkedin|reddit|pinterest|tiktok|youtube|vk|telegram|whatsapp|threads|bluesky|mastodon|discord|snapchat)$/', $sHost))
+                return self::CHANNEL_SOCIAL;
+            if(preg_match('/^(chatgpt|openai|perplexity|claude|gemini|copilot|deepseek|grok)$/', $sHost))
+                return self::CHANNEL_AI;
+            if(preg_match('/^(newsletter|email|mail|gmail|outlook)$/', $sHost))
+                return self::CHANNEL_EMAIL;
+        }
+
+        return '';
+    }
+
+    protected static function _isInternal($sHost, $aInternalHosts)
+    {
+        foreach($aInternalHosts as $sInternal) {
+            $sInternal = self::host($sInternal);
+            if($sInternal !== '' && ($sHost === $sInternal || self::_endsWith($sHost, '.' . $sInternal)))
+                return true;
+        }
+
+        return false;
+    }
+
+    protected static function _endsWith($s, $sEnd)
+    {
+        $iLength = get_mb_len($sEnd);
+
+        return get_mb_len($s) >= $iLength && get_mb_substr($s, -$iLength, $iLength) === $sEnd;
+    }
+
+    protected static function _query($sUrl)
+    {
+        $sQuery = parse_url((string)$sUrl, PHP_URL_QUERY);
+        if(!is_string($sQuery) || $sQuery === '')
+            return [];
+
+        $aQuery = [];
+        parse_str($sQuery, $aQuery);
+
+        return $aQuery;
+    }
+
+    protected static function _clean($s, $iMaxLength)
+    {
+        $s = trim(preg_replace('/[\x00-\x1F\x7F]+/u', ' ', (string)$s));
+
+        return get_mb_substr($s, 0, $iMaxLength);
+    }
+}
+
+/** @} */

+ 55 - 0
modules/boonex/analytics/updates/15.0.1_15.0.2/source/classes/BxAnalyticsStudioMenu.php

@@ -0,0 +1,55 @@
+<?php defined('BX_DOL') or die('hack attempt');
+/**
+ * Copyright (c) UNA, Inc - https://una.io
+ * MIT License - https://opensource.org/licenses/MIT
+ *
+ * @defgroup    Analytics Analytics
+ * @ingroup     UnaModules
+ *
+ * @{
+ */
+
+/**
+ * The Studio side menu in sections (Traffic, Behavior, Conversion...). Each section's items are
+ * drawn by Studio's own menu object, so they look and behave like any Studio menu; this class
+ * only adds the section headings around them.
+ */
+class BxAnalyticsStudioMenu
+{
+    protected $_oTemplate;
+    protected $_aSections;
+
+    /**
+     * @param object $oTemplate the module template
+     * @param array $aSections list of ['name' => section name, 'title' => heading or '', 'menu' => Studio menu object]
+     */
+    public function __construct($oTemplate, $aSections)
+    {
+        $this->_oTemplate = $oTemplate;
+        $this->_aSections = $aSections;
+    }
+
+    public function getCode()
+    {
+        $aSections = [];
+        foreach($this->_aSections as $aSection)
+            $aSections[] = [
+                'name' => $aSection['name'],
+                'bx_if:title' => [
+                    'condition' => $aSection['title'] !== '',
+                    'content' => [
+                        'name' => $aSection['name'],
+                        'title' => $aSection['title'],
+                    ],
+                ],
+                'attrs' => $aSection['title'] !== '' ? 'role="group" aria-labelledby="bx-analytics-std-menu-' . $aSection['name'] . '"' : '',
+                'menu' => $aSection['menu']->getCode(),
+            ];
+
+        return $this->_oTemplate->parseHtmlByName('studio_menu.html', [
+            'bx_repeat:sections' => $aSections,
+        ]);
+    }
+}
+
+/** @} */

+ 49 - 0
modules/boonex/analytics/updates/15.0.1_15.0.2/source/classes/BxAnalyticsStudioMenuItems.php

@@ -0,0 +1,49 @@
+<?php defined('BX_DOL') or die('hack attempt');
+/**
+ * Copyright (c) UNA, Inc - https://una.io
+ * MIT License - https://opensource.org/licenses/MIT
+ *
+ * @defgroup    Analytics Analytics
+ * @ingroup     UnaModules
+ *
+ * @{
+ */
+
+/**
+ * Studio side menu items with Lucide glyphs on Studio's gray tiles, like Studio's own pages.
+ * An item names its glyph in `lucide`; the rest is Studio's menu as is.
+ */
+class BxAnalyticsStudioMenuItems extends BxTemplStudioMenu
+{
+    protected $_oIconset;
+
+    public function __construct($aObject, $oTemplate = false)
+    {
+        parent::__construct($aObject, $oTemplate);
+
+        $this->_oIconset = BxDolIconset::getObjectInstance('sys_lucide');
+    }
+
+    protected function _getMenuItem($aItem)
+    {
+        $sLucide = isset($aItem['lucide']) ? $aItem['lucide'] : '';
+
+        $aItem = parent::_getMenuItem($aItem);
+        if($aItem === false || $sLucide === '' || !$this->_oIconset)
+            return $aItem;
+
+        $sSvg = $this->_oIconset->getIconHtml($sLucide, ['class' => 'bx-analytics-std-menu-icon', 'aria-hidden' => 'true']);
+        if($sSvg === false)
+            return $aItem;
+
+        return array_merge($aItem, [
+            'bx_if:icon' => ['condition' => false, 'content' => ['icon' => '']],
+            'bx_if:image' => ['condition' => false, 'content' => ['icon_url' => '', 'attrs' => '']],
+            'bx_if:image_inline' => ['condition' => true, 'content' => ['image' => $sSvg]],
+            'bx_if:show_icon' => ['condition' => true, 'content' => []],
+            'bx_if:show_icon_bg' => ['condition' => true, 'content' => []],
+        ]);
+    }
+}
+
+/** @} */

+ 128 - 0
modules/boonex/analytics/updates/15.0.1_15.0.2/source/classes/BxAnalyticsStudioPage.php

@@ -0,0 +1,128 @@
+<?php defined('BX_DOL') or die('hack attempt');
+/**
+ * Copyright (c) UNA, Inc - https://una.io
+ * MIT License - https://opensource.org/licenses/MIT
+ *
+ * @defgroup    Analytics Analytics
+ * @ingroup     UnaModules
+ *
+ * @{
+ */
+
+/**
+ * Studio page of the module: every report section in a side menu grouped like the reports
+ * themselves (traffic, behavior, conversion, revenue, health, community, explore), then setup
+ * and settings. A section shows the same block services the site's page builder offers, with
+ * the filter bar on top, so a report looks and counts the same in Studio and on any page.
+ */
+class BxAnalyticsStudioPage extends BxTemplStudioModule
+{
+    protected $_sModule;
+    protected $_oModule;
+
+    protected $sPageDefault = 'overview';
+
+    /**
+     * Section => [page => Lucide glyph]. The empty section has no heading.
+     */
+    protected $_aSections = [
+        '' => ['overview' => 'chart-line'],
+        'traffic' => ['realtime' => 'zap', 'pages' => 'file-text', 'sources' => 'share-2', 'audience' => 'users', 'map' => 'earth', 'bots' => 'bot'],
+        'behavior' => ['sessions' => 'history', 'users' => 'user', 'events' => 'mouse-pointer-click'],
+        'conversion' => ['goals' => 'target', 'funnels' => 'funnel', 'journeys' => 'route', 'retention' => 'repeat'],
+        'revenue' => ['revenue' => 'circle-dollar-sign', 'subscriptions' => 'refresh-cw', 'sales' => 'shopping-cart', 'credits' => 'coins'],
+        'health' => ['errors' => 'triangle-alert', 'performance' => 'gauge'],
+        'community' => ['content' => 'layers'],
+        'explore' => ['query' => 'database', 'api' => 'code', 'dashboards' => 'layout-dashboard'],
+        'configure' => ['setup' => 'plug', 'settings' => 'settings'],
+    ];
+
+    public function __construct($sModule, $mixedPageName, $sPage = "")
+    {
+        $this->_sModule = 'bx_analytics';
+        $this->_oModule = BxDolModule::getInstance($this->_sModule);
+
+        parent::__construct($sModule, $mixedPageName, $sPage);
+
+        $aSettings = $this->aMenuItems[BX_DOL_STUDIO_MOD_TYPE_SETTINGS];
+
+        $this->aMenuItems = [];
+        foreach($this->_aSections as $aPages)
+            foreach($aPages as $sPage => $sIcon)
+                $this->aMenuItems[$sPage] = $sPage == BX_DOL_STUDIO_MOD_TYPE_SETTINGS ? $aSettings : [
+                    'name' => $sPage,
+                    'icon' => '',
+                    'lucide' => $sIcon,
+                    'title' => '_bx_analytics_lmi_cpt_' . $sPage,
+                ];
+    }
+
+    /**
+     * The side menu in sections, each drawn by Studio's own menu object.
+     */
+    protected function getPageMenuObject($aMenu = [], $aMarkers = [])
+    {
+        $this->_oModule->_oTemplate->addStudioCss(['studio.css']);
+
+        bx_import('StudioMenu', $this->_oModule->_aModule);
+        bx_import('StudioMenuItems', $this->_oModule->_aModule);
+
+        $aByName = [];
+        foreach($aMenu as $aItem)
+            $aByName[$aItem['name']] = $aItem;
+
+        $aSections = [];
+        foreach($this->_aSections as $sSection => $aPages) {
+            $aItems = [];
+            foreach(array_keys($aPages) as $sPage)
+                if(isset($aByName[$sPage]))
+                    $aItems[] = $aByName[$sPage];
+
+            if(empty($aItems))
+                continue;
+
+            $oMenu = new BxAnalyticsStudioMenuItems(['template' => 'menu_side.html', 'menu_items' => $aItems]);
+            if(!empty($aMarkers))
+                $oMenu->addMarkers($aMarkers);
+            $oMenu->setInlineIcons($this->bPageMenuIconsInline);
+
+            $aSections[] = [
+                'name' => $sSection !== '' ? $sSection : 'main',
+                'title' => $sSection !== '' ? _t('_bx_analytics_section_' . $sSection) : '',
+                'menu' => $oMenu,
+            ];
+        }
+
+        return new BxAnalyticsStudioMenu($this->_oModule->_oTemplate, $aSections);
+    }
+
+    /**
+     * Report sections: the page name is the section the module renders.
+     */
+    public function getPageCode($sPage = '', $bWrap = true)
+    {
+        if(empty($sPage))
+            $sPage = $this->sPage;
+
+        if($sPage != BX_DOL_STUDIO_MOD_TYPE_SETTINGS && isset($this->aMenuItems[$sPage]) && !method_exists($this, 'get' . bx_gen_method_name($sPage))) {
+            $sResult = parent::getPageCode('', $bWrap);
+            if($sResult === false)
+                return false;
+
+            $this->_oModule->_oTemplate->setStudio(true);
+
+            return $this->getBlockCode(['content' => $this->_oModule->getStudioSection($sPage)]) . $sResult;
+        }
+
+        return parent::getPageCode($sPage, $bWrap);
+    }
+
+    protected function getContent()
+    {
+        $this->_oModule->_oTemplate->setStudio(true);
+
+        return $this->_oModule->serviceGetCanvas();
+    }
+}
+
+/** @} */

+ 1991 - 0
modules/boonex/analytics/updates/15.0.1_15.0.2/source/classes/BxAnalyticsTemplate.php

@@ -0,0 +1,1991 @@
+<?php defined('BX_DOL') or die('hack attempt');
+/**
+ * Copyright (c) UNA, Inc - https://una.io
+ * MIT License - https://opensource.org/licenses/MIT
+ *
+ * @defgroup    Analytics Analytics
+ * @ingroup     UnaModules
+ *
+ * @{
+ */
+
+bx_import ('BxDolModuleTemplate');
+
+class BxAnalyticsTemplate extends BxBaseModGeneralTemplate
+{
+    /**
+     * Blocks are drawn inside Studio: assets go to the Studio template.
+     */
+    protected $_bStudio = false;
+
+    protected $_aRanges = ['today', 'yesterday', '24h', '7d', '30d', '90d', '12m'];
+
+    function __construct(&$oConfig, &$oDb)
+    {
+        $this->MODULE = 'bx_analytics';
+        parent::__construct($oConfig, $oDb);
+    }
+
+    public function setStudio($bStudio)
+    {
+        $this->_bStudio = (bool)$bStudio;
+    }
+
+    public function isStudio()
+    {
+        return $this->_bStudio;
+    }
+
+    function getCanvas()
+    {
+        $aTmp = $this->getModule()->getSelectedModules();
+        $aModules = $aTmp[0];
+        $aModulesList = $aTmp[1];
+        $aModulesList2 = [];
+        foreach($aModules as $sModule)
+            $aModulesList2[] = ['value' => $sModule, 'title' => $aModulesList[$sModule]];
+
+        $iDaysBefore = getParam('bx_analytics_default_interval_day');
+        $sDate = date('d/m/Y', time() - $iDaysBefore * 86400) . ' - ' . date('d/m/Y');
+        $aJs = ['chart.umd.min.js', 'chartjs-adapter-moment.min.js', 'analytics.js', BX_DIRECTORY_PATH_MODULES . 'boonex/analytics/plugins/daterangepicker/|daterangepicker.js', BX_DIRECTORY_PATH_MODULES . 'boonex/analytics/plugins/datatables/|datatables.min.js'];
+        $aCss = ['main.css', BX_DIRECTORY_PATH_MODULES . 'boonex/analytics/plugins/daterangepicker/|daterangepicker.css', BX_DIRECTORY_PATH_MODULES . 'boonex/analytics/plugins/datatables/|datatables.min.css'];
+        $this->_addAssets($aJs, $aCss);
+
+        return $this->getJsCode('analytics') . $this->parseHtmlByName('canvas.html', [
+            'bx_repeat:items' => $aModulesList2,
+            'interval' => $sDate,
+            'export_to_csv_title' => _t('_bx_analytics_txt_export_to_csv_title')
+        ]);
+    }
+
+    /**
+     * The classic UI tracker: a small deferred script configured by data attributes.
+     */
+    public function getTrackerCode()
+    {
+        return $this->parseHtmlByName('tracker.html', [
+            'src' => $this->_getTrackerUrl(),
+            'endpoint' => BX_DOL_URL_ROOT . $this->_oConfig->getBaseUri() . 'collect',
+            'platform' => BX_ANALYTICS_PLATFORM_CORE_WEB,
+            'identity' => $this->_oConfig->getIdentityMode(),
+            'dnt' => $this->_oConfig->isRespectDnt() ? 1 : 0,
+            'errors' => $this->_oConfig->isTrackErrors() ? 1 : 0,
+            // percent of page loads that measure vitals, 0 for none
+            'vitals' => $this->_oConfig->isTrackVitals() ? $this->_oConfig->getVitalsSample() : 0,
+        ]);
+    }
+
+    /**
+     * The tracker's URL. Every classic page loads it, so when the site minifies its scripts
+     * it is minified too, once per revision, into the public cache next to the site's
+     * scripts (clearing the JS cache removes it); the source file otherwise, or when the
+     * cache can't be written.
+     */
+    protected function _getTrackerUrl()
+    {
+        $iRevision = (int)getParam('sys_revision');
+        $sSource = 'boonex/analytics/js/tracker.js';
+        $sUrl = BX_DOL_URL_MODULES . $sSource . '?v=' . $iRevision;
+        if(bx_is_dbg() || getParam('sys_template_cache_js_enable') != 'on' || getParam('sys_template_cache_minify_js_enable') != 'on')
+            return $sUrl;
+
+        $sName = $this->getCacheFilePrefix('js') . 'bx_analytics_tracker_' . bx_site_hash($sSource . $iRevision) . '.js';
+        $sPath = BX_DIRECTORY_PATH_CACHE_PUBLIC . $sName;
+        if(!file_exists($sPath)) {
+            $sCode = @file_get_contents(BX_DIRECTORY_PATH_MODULES . $sSource);
+            $sMinified = $sCode !== false ? BxDolMinify::getInstance()->minifyJs($sCode) : '';
+            // written aside and moved in, so a page never loads a half-written file
+            $sTemp = $sPath . '.' . uniqid() . '.tmp';
+            if($sMinified === '' || @file_put_contents($sTemp, $sMinified) === false || !@rename($sTemp, $sPath)) {
+                @unlink($sTemp);
+                return $sUrl;
+            }
+            @chmod($sPath, BX_DOL_FILE_RIGHTS);
+        }
+
+        return BX_DOL_URL_CACHE_PUBLIC . $sName;
+    }
+
+    /**
+     * A report block on a page: the dashboard script, once per page, and the report.
+     *
+     * @param string $sReport report name, see BxAnalyticsModule::getReportData
+     * @param array $aData report data
+     * @param object $oFilter the filter the data was built with
+     * @param array $aParams block parameters (group, table...) the script sends back on reload
+     */
+    public function getBlock($sReport, $aData, $oFilter, $aParams = [])
+    {
+        $this->_addDashboardAssets($sReport);
+
+        return $this->_getJsInit() . $this->getReportHtml($sReport, $aData, $oFilter, $aParams);
+    }
+
+    /**
+     * Block HTML for a report, on first render and when the dashboard script reloads it.
+     */
+    public function getReportHtml($sReport, $aData, $oFilter, $aParams = [])
+    {
+        $sMethod = '_get' . bx_gen_method_name($sReport) . 'Html';
+        if(!method_exists($this, $sMethod))
+            return '';
+
+        $sContent = $this->$sMethod($aData, $oFilter, $aParams);
+        if($sContent === '')
+            return '';
+
+        return $this->parseHtmlByName('block.html', [
+            'report' => $sReport,
+            'params' => $this->_esc(json_encode((object)$aParams)),
+            'class' => str_replace('_', '-', $sReport),
+            'content' => $sContent,
+        ]);
+    }
+
+    /**
+     * A Studio section: blocks in a grid, the wide ones across all columns.
+     *
+     * @param array $aBlocks list of ['title' => heading or '', 'content' => HTML, 'wide' => bool]
+     */
+    public function getStudioSection($aBlocks)
+    {
+        $aCards = [];
+        foreach($aBlocks as $aBlock) {
+            if($aBlock['content'] === '' || $aBlock['content'] === [])
+                continue;
+
+            $aCards[] = [
+                'class' => !empty($aBlock['wide']) ? 'bx-analytics-studio-wide' : '',
+                'bx_if:title' => [
+                    'condition' => !empty($aBlock['title']),
+                    'content' => ['title' => !empty($aBlock['title']) ? $aBlock['title'] : ''],
+                ],
+                'content' => $aBlock['content'],
+            ];
+        }
+
+        return $this->parseHtmlByName('studio_section.html', [
+            'bx_repeat:cards' => $aCards,
+        ]);
+    }
+
+    /**
+     * Traffic block of the Studio dashboard.
+     */
+    public function getStudioDashboardTraffic($aOverview, $aRealtime, $aPlatforms)
+    {
+        $this->addStudioCss(['dashboard.css']);
+
+        $aValues = array_map(function($a) { return $a['future'] ? null : $a['visitors']; }, $aOverview['series']);
+        $iMax = max(array_merge([1], array_map('intval', $aValues)));
+        $iCount = count($aValues);
+        $aPoints = [];
+        foreach($aValues as $i => $mixedValue)
+            if($mixedValue !== null)
+                $aPoints[] = round($iCount > 1 ? 100 * $i / ($iCount - 1) : 50, 2) . ',' . round(38 - 36 * $mixedValue / $iMax, 2);
+
+        $aKpis = [];
+        foreach(['visitors', 'pageviews'] as $sMetric) {
+            $aKpi = $aOverview['kpis'][$sMetric];
+            $aKpis[] = [
+                'title' => _t('_bx_analytics_metric_' . $sMetric),
+                'value' => $this->formatValue($sMetric, $aKpi['value']),
+                'change' => $this->_formatChange($aKpi['change']),
+                'trend' => $this->_trend($sMetric, $aKpi['change']),
+            ];
+        }
+
+        $aRows = [];
+        foreach($aPlatforms['items'] as $aItem)
+            $aRows[] = [
+                'label' => $this->_esc($aItem['label']),
+                'share' => $aItem['share'],
+                'bar' => $aItem['bar'],
+            ];
+
+        return $this->parseHtmlByName('studio_traffic.html', [
+            'online' => $this->formatValue('number', $aRealtime['online']),
+            'online_title' => _t('_bx_analytics_txt_online_now', $aRealtime['window']),
+            'bx_repeat:kpis' => $aKpis,
+            'points' => implode(' ', $aPoints),
+            'chart_label' => _t('_bx_analytics_txt_chart_visitors_7d'),
+            'bx_if:platforms' => [
+                'condition' => !empty($aRows),
+                'content' => ['bx_repeat:platforms' => $aRows],
+            ],
+            'bx_if:empty' => [
+                'condition' => empty($aRows),
+                'content' => ['empty' => _t('_bx_analytics_txt_no_data_yet')],
+            ],
+            'link' => BX_DOL_URL_STUDIO . 'module.php?name=' . $this->MODULE . '&page=overview',
+            'link_title' => _t('_bx_analytics_txt_open_analytics'),
+        ]);
+    }
+
+    /**
+     * A number for display by its kind: number (950, 9.5K), percent, duration (seconds),
+     * ms, pages_per_session, or a metric name.
+     */
+    public function formatValue($sType, $mixedValue)
+    {
+        if($mixedValue === null || $mixedValue === '')
+            return '';
+
+        switch($sType) {
+            case 'bounce_rate':
+            case 'percent':
+            case 'rate':
+                return (abs($mixedValue) < 10 && round($mixedValue) != $mixedValue ? number_format((float)$mixedValue, 1) : round($mixedValue)) . '%';
+
+            // shares close to 100%, such as crash-free visits: 99.95%, not 100%
+            case 'percent_precise':
+                return rtrim(rtrim(number_format((float)$mixedValue, 2), '0'), '.') . '%';
+
+            case 'pages_per_session':
+            case 'decimal':
+                return number_format((float)$mixedValue, 1);
+
+            // good, needs_improvement or poor, as a coloured label
+            case 'rating':
+                return $this->parseHtmlByName('rating.html', [
+                    'rating' => str_replace('_', '-', $this->_esc($mixedValue)),
+                    'title' => _t('_bx_analytics_rating_' . preg_replace('/[^a-z_]/', '', (string)$mixedValue)),
+                ]);
+
+            // a small line of values over time
+            case 'spark':
+                return is_array($mixedValue) ? $this->_getSparkHtml($mixedValue) : '';
+
+            case 'duration':
+            case 'engaged':
+                $iSeconds = (int)round($mixedValue);
+                if($iSeconds < 60)
+                    return $iSeconds . _t('_bx_analytics_txt_duration_s');
+                if($iSeconds < 3600)
+                    return intdiv($iSeconds, 60) . _t('_bx_analytics_txt_duration_m') . ' ' . ($iSeconds % 60) . _t('_bx_analytics_txt_duration_s');
+                return intdiv($iSeconds, 3600) . _t('_bx_analytics_txt_duration_h') . ' ' . intdiv($iSeconds % 3600, 60) . _t('_bx_analytics_txt_duration_m');
+
+            case 'ms':
+                return $mixedValue >= 1000 ? number_format($mixedValue / 1000, 2) . _t('_bx_analytics_txt_duration_s') : round($mixedValue) . _t('_bx_analytics_txt_ms');
+
+            case 'cls':
+                return number_format((float)$mixedValue, 3);
+
+            case 'money':
+                // the site's default currency, the way the payment module shows it
+                return $this->_esc(_t_format_currency((float)$mixedValue));
+
+            case 'credits':
+                return rtrim(rtrim(number_format((float)$mixedValue, 2), '0'), '.');
+
+            case 'datetime':
+                return bx_time_js((int)$mixedValue, BX_FORMAT_DATE_TIME, true);
+
+            case 'date':
+                return bx_time_js((int)$mixedValue, BX_FORMAT_DATE, true);
+
+            case 'text':
+                return $this->_esc($mixedValue);
+        }
+
+        return $this->_formatNumber($mixedValue);
+    }
+
+    protected function _getFiltersHtml($aData, $oFilter)
+    {
+        $aFilter = $oFilter->get();
+
+        $aRanges = [];
+        foreach($this->_aRanges as $sRange)
+            $aRanges[] = [
+                'value' => $sRange,
+                'title' => _t('_bx_analytics_range_' . $sRange),
+                'selected' => $aFilter['range'] == $sRange ? 'selected="selected"' : '',
+            ];
+        if($aFilter['range'] == 'custom')
+            $aRanges[] = [
+                'value' => 'custom',
+                'title' => $this->_esc($aFilter['from_date'] . ' - ' . $aFilter['to_date']),
+                'selected' => 'selected="selected"',
+            ];
+
+        $aPlatforms = [['value' => '', 'title' => _t('_bx_analytics_txt_all_platforms'), 'selected' => '']];
+        foreach($this->_oConfig->getPlatforms() as $sCode => $sTitle)
+            $aPlatforms[] = [
+                'value' => $sCode,
+                'title' => _t($sTitle),
+                'selected' => $aFilter['platform'] == $sCode ? 'selected="selected"' : '',
+            ];
+
+        $aChips = [];
+        foreach($aData['chips'] as $aChip)
+            $aChips[] = [
+                'name' => $aChip['name'],
+                'title' => $aChip['title'],
+                'value' => $this->_esc($aChip['label']),
+                'remove' => $this->_esc(_t('_bx_analytics_txt_remove_filter', $aChip['title'])),
+            ];
+
+        return $this->parseHtmlByName('filters.html', [
+            'range_label' => _t('_bx_analytics_txt_range'),
+            'bx_repeat:ranges' => $aRanges,
+            'platform_label' => _t('_bx_analytics_txt_platform'),
+            'bx_repeat:platforms' => $aPlatforms,
+            'bx_if:chips' => [
+                'condition' => !empty($aChips),
+                'content' => ['bx_repeat:chips' => $aChips],
+            ],
+            'online' => $this->formatValue('number', $aData['online']),
+            'online_title' => _t('_bx_analytics_txt_online_now', $aData['window']),
+        ]);
+    }
+
+    protected function _getOverviewHtml($aOverview, $oFilter)
+    {
+        $sMetric = bx_get('metric');
+        $aMetrics = $this->getModule()->getReports()->getMetrics();
+        if(!in_array($sMetric, $aMetrics, true))
+            $sMetric = 'visitors';
+
+        $aKpis = [];
+        foreach($aOverview['kpis'] as $sName => $aKpi)
+            $aKpis[] = [
+                'metric' => $sName,
+                'pressed' => $sName == $sMetric ? 'true' : 'false',
+                'title' => _t('_bx_analytics_metric_' . $sName),
+                'value' => $this->formatValue($sName, $aKpi['value']),
+                'change' => $this->_formatChange($aKpi['change']),
+                'change_title' => $this->_esc(_t('_bx_analytics_txt_previous_period', $this->formatValue($sName, $aKpi['previous']))),
+                'trend' => $this->_trend($sName, $aKpi['change']),
+            ];
+
+        $aTitles = [];
+        foreach($aMetrics as $sName)
+            $aTitles[$sName] = _t('_bx_analytics_metric_' . $sName);
+
+        return $this->parseHtmlByName('overview.html', [
+            'kpis_label' => _t('_bx_analytics_txt_key_figures'),
+            'bx_repeat:kpis' => $aKpis,
+            'chart_label' => $this->_esc(_t('_bx_analytics_txt_chart_label')),
+            'chart_json' => $this->_json([
+                'metric' => $sMetric,
+                'bucket' => $aOverview['bucket'],
+                'tz' => $aOverview['tz'],
+                'series' => $aOverview['series'],
+                'titles' => $aTitles,
+            ]),
+        ]);
+    }
+
+    protected function _getTopHtml($aTop, $oFilter)
+    {
+        $aItems = [];
+        foreach($aTop['items'] as $aItem)
+            $aItems[] = [
+                'dimension' => $aTop['dimension'],
+                'value' => $this->_esc($aItem['value']),
+                'label' => $this->_esc($aItem['label']),
+                'visitors' => $this->formatValue('number', $aItem['visitors']),
+                'share' => $aItem['share'],
+                'bar' => $aItem['bar'],
+                'filter_title' => $this->_esc(_t('_bx_analytics_txt_filter_by', $aItem['label'])),
+            ];
+
+        return $this->parseHtmlByName('top.html', [
+            'tabs' => $this->_getTabsHtml($aTop['group'], $aTop['tabs']),
+            'group' => $aTop['group'],
+            'column' => $aTop['column'],
+            'visitors' => _t('_bx_analytics_metric_visitors'),
+            'bx_if:items' => [
+                'condition' => !empty($aItems),
+                'content' => ['bx_repeat:items' => $aItems],
+            ],
+            'bx_if:empty' => [
+                'condition' => empty($aItems),
+                'content' => ['empty' => _t('_bx_analytics_txt_no_data')],
+            ],
+        ]);
+    }
+
+    protected function _getRealtimeHtml($aData, $oFilter)
+    {
+        $iMax = 1;
+        foreach($aData['minutes'] as $aMinute)
+            $iMax = max($iMax, $aMinute['pageviews']);
+
+        $aMinutes = [];
+        foreach($aData['minutes'] as $aMinute)
+            $aMinutes[] = [
+                'height' => $aMinute['pageviews'] ? max(4, round(100 * $aMinute['pageviews'] / $iMax)) : 0,
+                'title' => $this->_esc(_t('_bx_analytics_txt_pageviews_at', $aMinute['pageviews'], gmdate('H:i', $aMinute['t']) . ' UTC')),
+            ];
+
+        $aPlatforms = [];
+        foreach($aData['platforms'] as $aPlatform)
+            $aPlatforms[] = [
+                'label' => $this->_esc($aPlatform['label']),
+                'visitors' => $this->formatValue('number', $aPlatform['visitors']),
+                'share' => $aPlatform['share'],
+            ];
+
+        $aRecent = [];
+        foreach($aData['recent'] as $aItem) {
+            $aMeta = array_filter([
+                $aItem['platform_label'],
+                $aItem['city'] !== '' ? $aItem['city'] . ', ' . $aItem['country_label'] : ($aItem['country'] !== '' ? $aItem['country_label'] : ''),
+                $aItem['device'],
+                $aItem['member'] ? _t('_bx_analytics_txt_member') : '',
+            ]);
+            $aRecent[] = [
+                'time' => bx_time_js($aItem['ts'], BX_FORMAT_DATE_TIME),
+                'title' => $this->_esc($aItem['title'] !== '' ? $aItem['title'] : $aItem['path']),
+                'path' => $this->_esc($aItem['path']),
+                'meta' => $this->_esc(implode(' · ', $aMeta)),
+            ];
+        }
+
+        return $this->parseHtmlByName('realtime.html', [
+            'online' => $this->formatValue('number', $aData['online']),
+            'online_title' => _t('_bx_analytics_txt_online_now', $aData['window']),
+            'bx_if:platforms' => [
+                'condition' => !empty($aPlatforms),
+                'content' => ['bx_repeat:platforms' => $aPlatforms],
+            ],
+            'minutes_label' => $this->_esc(_t('_bx_analytics_txt_pageviews_30m')),
+            'bx_repeat:minutes' => $aMinutes,
+            'axis_start' => _t('_bx_analytics_txt_minutes_ago', 30),
+            'axis_end' => _t('_bx_analytics_txt_now'),
+            'recent_title' => _t('_bx_analytics_txt_latest_pageviews'),
+            'bx_if:recent' => [
+                'condition' => !empty($aRecent),
+                'content' => ['bx_repeat:recent' => $aRecent],
+            ],
+            'bx_if:empty' => [
+                'condition' => empty($aRecent),
+                'content' => ['empty' => _t('_bx_analytics_txt_no_recent')],
+            ],
+        ]);
+    }
+
+    protected function _getMapHtml($aData, $oFilter)
+    {
+        $aCountries = [];
+        foreach(array_slice($aData['countries'], 0, 12) as $aCountry)
+            $aCountries[] = [
+                'value' => $this->_esc($aCountry['country']),
+                'label' => $this->_esc($aCountry['label']),
+                'visitors' => $this->formatValue('number', $aCountry['visitors']),
+                'share' => $aCountry['share'],
+                'bar' => $aCountry['bar'],
+                'filter_title' => $this->_esc(_t('_bx_analytics_txt_filter_by', $aCountry['label'])),
+            ];
+
+        return $this->parseHtmlByName('map.html', [
+            'tabs' => $this->_getTabsHtml('map', $aData['tabs']),
+            'refresh' => $aData['tab'] == 'live' ? 'data-bx-analytics-refresh="30"' : '',
+            'map_label' => $this->_esc(_t('_bx_analytics_txt_map_label')),
+            'map_json' => $this->_json([
+                'countries' => $aData['countries'],
+                'points' => $aData['points'],
+                'provider' => $aData['provider'],
+                'visitors' => _t('_bx_analytics_metric_visitors'),
+            ]),
+            'column' => _t('_bx_analytics_tab_countries'),
+            'visitors' => _t('_bx_analytics_metric_visitors'),
+            'bx_if:countries' => [
+                'condition' => !empty($aCountries),
+                'content' => ['bx_repeat:countries' => $aCountries],
+            ],
+            'bx_if:empty' => [
+                'condition' => empty($aCountries),
+                'content' => ['empty' => _t($aData['tab'] == 'live' ? '_bx_analytics_txt_no_recent' : '_bx_analytics_txt_no_data')],
+            ],
+            'bx_if:unknown' => [
+                'condition' => $aData['unknown'] > 0,
+                'content' => ['unknown' => _t('_bx_analytics_txt_map_unknown', $this->formatValue('number', $aData['unknown']))],
+            ],
+        ]);
+    }
+
+    protected function _getBotsHtml($aData, $oFilter)
+    {
+        return $this->parseHtmlByName('bots.html', [
+            'tabs' => $this->_getTabsHtml('bots', $aData['tabs']),
+            'tiles' => $this->_getTilesHtml($aData['tiles']),
+            'chart_label' => $this->_esc(_t('_bx_analytics_txt_chart_bots')),
+            'chart_json' => $this->_json($aData['chart']),
+            'bots' => $this->_getTableHtml($aData['bots'], $oFilter),
+            'pages_title' => $aData['pages']['caption'],
+            'pages' => $this->_getTableHtml($aData['pages'], $oFilter),
+        ]);
+    }
+
+    protected function _getEventsHtml($aData, $oFilter)
+    {
+        if($aData['view'] == 'list')
+            return $this->_getTableHtml($aData, $oFilter);
+
+        $aProps = [];
+        foreach($aData['props'] as $aTable)
+            $aProps[] = [
+                'title' => $this->_esc($aTable['caption']),
+                'table' => $this->_getTableHtml($aTable, $oFilter),
+            ];
+
+        return $this->parseHtmlByName('event.html', [
+            'back' => _t('_bx_analytics_txt_back_events'),
+            'title' => $this->_esc($aData['title']),
+            'tiles' => $this->_getTilesHtml($aData['tiles']),
+            'chart_label' => $this->_esc(_t('_bx_analytics_txt_chart_event', $aData['title'])),
+            'chart_json' => $this->_json($aData['chart']),
+            'platforms_title' => _t('_bx_analytics_tab_platforms'),
+            'platforms' => $this->_getBarsHtml($aData['platforms'], 'count'),
+            'bx_repeat:props' => $aProps,
+            'bx_if:no_props' => [
+                'condition' => empty($aProps),
+                'content' => [
+                    'props_title' => _t('_bx_analytics_txt_properties'),
+                    'no_props' => _t('_bx_analytics_txt_no_properties'),
+                ],
+            ],
+        ]);
+    }
+
+    protected function _getSessionsHtml($aData, $oFilter)
+    {
+        if($aData['view'] == 'list')
+            return $this->_getTableHtml($aData, $oFilter);
+
+        return $this->_getSessionHtml($aData);
+    }
+
+    protected function _getUsersHtml($aData, $oFilter)
+    {
+        if($aData['view'] == 'list')
+            return $this->_getTableHtml($aData, $oFilter);
+
+        if($aData['view'] == 'detail')
+            return $this->_getSessionHtml($aData);
+
+        return $this->parseHtmlByName('user.html', [
+            'back' => _t('_bx_analytics_txt_back_members'),
+            'member' => $this->_getMemberHtml($aData['member']),
+            'tiles' => $this->_getTilesHtml($aData['tiles']),
+            'sessions_title' => _t('_bx_analytics_lmi_cpt_sessions'),
+            'sessions' => $this->_getTableHtml($aData['sessions'], $oFilter),
+            'platforms_title' => _t('_bx_analytics_tab_platforms'),
+            'platforms' => $this->_getBarsHtml($aData['platforms'], 'count'),
+            'actions_title' => $aData['actions']['caption'],
+            'actions' => $this->_getTableHtml($aData['actions'], $oFilter),
+        ]);
+    }
+
+    protected function _getSessionHtml($aData)
+    {
+        $aFacts = [];
+        foreach($aData['facts'] as $aFact)
+            $aFacts[] = [
+                'title' => $aFact['title'],
+                'value' => $aFact['value'] === '' ? '&mdash;' : $this->formatValue($aFact['type'], $aFact['value']),
+            ];
+
+        $aKinds = [
+            BX_ANALYTICS_HIT_PAGEVIEW => _t('_bx_analytics_event_type_pageview'),
+            BX_ANALYTICS_HIT_EVENT => _t('_bx_analytics_event_type_event'),
+            BX_ANALYTICS_HIT_UNA => _t('_bx_analytics_event_type_una'),
+        ];
+
+        $aTimeline = [];
+        foreach($aData['timeline'] as $aItem)
+            $aTimeline[] = [
+                'type' => $aItem['type'],
+                'offset' => sprintf('%d:%02d', intdiv($aItem['offset'], 60), $aItem['offset'] % 60),
+                'kind' => isset($aKinds[$aItem['type']]) ? $aKinds[$aItem['type']] : $this->_esc($aItem['type']),
+                'label' => $this->_esc($aItem['label']),
+                'bx_if:path' => [
+                    'condition' => $aItem['path'] !== '' && $aItem['path'] !== $aItem['label'],
+                    'content' => ['path' => $this->_esc($aItem['path'])],
+                ],
+                'engaged' => $aItem['engaged'] ? $this->formatValue('duration', $aItem['engaged']) : '',
+            ];
+
+        return $this->parseHtmlByName('session.html', [
+            'back' => _t('_bx_analytics_txt_back_sessions'),
+            'member' => $this->_getMemberHtml($aData['member']),
+            'bx_repeat:facts' => $aFacts,
+            'timeline_title' => _t('_bx_analytics_txt_timeline'),
+            'bx_if:timeline' => [
+                'condition' => !empty($aTimeline),
+                'content' => ['bx_repeat:timeline' => $aTimeline],
+            ],
+            'bx_if:empty' => [
+                'condition' => empty($aTimeline),
+                'content' => ['empty' => _t('_bx_analytics_txt_no_data')],
+            ],
+        ]);
+    }
+
+    protected function _getMemberHtml($aMember)
+    {
+        $bMember = !empty($aMember);
+
+        return $this->parseHtmlByName('member.html', [
+            'bx_if:thumb' => [
+                'condition' => $bMember && !empty($aMember['thumb']),
+                'content' => ['thumb' => $bMember ? $this->_esc($aMember['thumb']) : ''],
+            ],
+            'bx_if:no_thumb' => [
+                'condition' => !$bMember || empty($aMember['thumb']),
+                'content' => [],
+            ],
+            'bx_if:link' => [
+                'condition' => $bMember,
+                'content' => [
+                    'url' => $bMember ? $this->_esc($aMember['url']) : '',
+                    'name' => $bMember ? $this->_esc($aMember['name']) : '',
+                ],
+            ],
+            'bx_if:text' => [
+                'condition' => !$bMember,
+                'content' => ['name' => _t('_bx_analytics_txt_guest')],
+            ],
+        ]);
+    }
+
+    /**
+     * Shares as bars: items with label, a count under `$sKey` and share in percent.
+     */
+    protected function _getBarsHtml($aItems, $sKey)
+    {
+        $iMax = 1;
+        foreach($aItems as $aItem)
+            $iMax = max($iMax, (int)$aItem[$sKey]);
+
+        $aRows = [];
+        foreach($aItems as $aItem)
+            $aRows[] = [
+                'label' => $this->_esc($aItem['label']),
+                'value' => $this->formatValue('number', $aItem[$sKey]),
+                'share' => $aItem['share'],
+                'bar' => round(100 * $aItem[$sKey] / $iMax, 1),
+            ];
+
+        return $this->parseHtmlByName('bars.html', [
+            'bx_if:items' => [
+                'condition' => !empty($aRows),
+                'content' => ['bx_repeat:items' => $aRows],
+            ],
+            'bx_if:empty' => [
+                'condition' => empty($aRows),
+                'content' => ['empty' => _t('_bx_analytics_txt_no_data')],
+            ],
+        ]);
+    }
+
+    protected function _getJourneysHtml($aData, $oFilter)
+    {
+        $aEntries = [];
+        foreach($aData['entries'] as $aEntry)
+            $aEntries[] = [
+                'page' => $this->_esc($aEntry['page']),
+                'label' => $this->_esc($aEntry['page']),
+                'sessions' => $this->formatValue('number', $aEntry['sessions']),
+                'active' => $aEntry['active'] ? 'bx-analytics-row-btn-active' : '',
+                'pressed' => $aEntry['active'] ? 'true' : 'false',
+            ];
+
+        $fNodes = function($aItems, $bOpen) use ($aData) {
+            $aResult = [];
+            foreach($aItems as $aItem) {
+                $bExit = !empty($aItem['exit']);
+                $bActive = !$bExit && $bOpen && $aItem['page'] === $aData['via'];
+                $aResult[] = [
+                    'label' => $bExit ? _t('_bx_analytics_txt_left_site') : $this->_esc($aItem['page']),
+                    'share' => $aItem['share'],
+                    'class' => ($bExit ? 'bx-analytics-flow-exit' : '') . ($bActive ? ' bx-analytics-flow-node-active' : ''),
+                    'attrs' => $bOpen && !$bExit ? 'data-bx-analytics-open="via" data-bx-analytics-value="' . $this->_esc($aItem['page']) . '" aria-pressed="' . ($bActive ? 'true' : 'false') . '"' : 'disabled',
+                ];
+            }
+            return $aResult;
+        };
+
+        $iMax = 1;
+        foreach($aData['paths'] as $aPath)
+            $iMax = max($iMax, $aPath['sessions']);
+
+        $aPathRows = [];
+        foreach($aData['paths'] as $aPath)
+            $aPathRows[] = [
+                'label' => implode(' → ', $aPath['pages']),
+                'bar' => round(100 * $aPath['sessions'] / $iMax, 1),
+                'values' => ['sessions' => $aPath['sessions'], 'share' => $aPath['share']],
+            ];
+
+        return $this->parseHtmlByName('journeys.html', [
+            'entries_title' => _t('_bx_analytics_txt_start_page'),
+            'bx_if:entries' => [
+                'condition' => !empty($aEntries),
+                'content' => ['bx_repeat:entries' => $aEntries],
+            ],
+            'bx_if:no_entries' => [
+                'condition' => empty($aEntries),
+                'content' => ['empty' => _t('_bx_analytics_txt_no_data')],
+            ],
+            'bx_if:flow' => [
+                'condition' => $aData['jfrom'] !== '',
+                'content' => [
+                    'flow_label' => $this->_esc(_t('_bx_analytics_txt_journey_from', $aData['jfrom'])),
+                    'step1' => _t('_bx_analytics_txt_step', 1),
+                    'step2' => _t('_bx_analytics_txt_step', 2),
+                    'step3' => _t('_bx_analytics_txt_step', 3),
+                    'from' => $this->_esc($aData['jfrom']),
+                    'from_sessions' => $this->formatValue('number', $aData['from_sessions']),
+                    'bx_repeat:next' => $fNodes($aData['next'], true),
+                    'bx_repeat:after' => $fNodes($aData['after'], false),
+                    'bx_if:no_after' => [
+                        'condition' => empty($aData['after']),
+                        'content' => ['no_after' => _t('_bx_analytics_txt_pick_next')],
+                    ],
+                ],
+            ],
+            'paths_title' => _t('_bx_analytics_txt_top_paths'),
+            'paths' => $this->_getTableHtml([
+                'name' => 'paths',
+                'caption' => _t('_bx_analytics_txt_top_paths'),
+                'columns' => [
+                    ['key' => 'label', 'type' => 'label', 'title' => _t('_bx_analytics_txt_path')],
+                    ['key' => 'sessions', 'type' => 'number', 'title' => _t('_bx_analytics_metric_sessions')],
+                    ['key' => 'share', 'type' => 'percent', 'title' => _t('_bx_analytics_txt_share')],
+                ],
+                'rows' => $aPathRows,
+            ], $oFilter),
+            'bx_if:truncated' => [
+                'condition' => $aData['truncated'],
+                'content' => ['truncated' => _t('_bx_analytics_txt_journeys_truncated')],
+            ],
+        ]);
+    }
+
+    protected function _getRetentionHtml($aData, $oFilter)
+    {
+        $aWeeks = [];
+        for($i = 0; $i < $aData['weeks']; $i++)
+            $aWeeks[] = ['week' => _t('_bx_analytics_txt_week_n', $i)];
+
+        $oTz = new DateTimeZone($aData['tz']);
+        $aRows = [];
+        foreach($aData['rows'] as $aRow) {
+            $aCells = [];
+            foreach($aRow['weeks'] as $mixedPercent)
+                $aCells[] = [
+                    'heat' => $mixedPercent !== null ? round($mixedPercent / 100, 3) : 0,
+                    'strong' => $mixedPercent !== null && $mixedPercent >= 45 ? 'bx-analytics-cohort-strong' : '',
+                    'percent' => $mixedPercent !== null && $aRow['size'] ? $this->formatValue('percent', $mixedPercent) : '',
+                ];
+
+            $aRows[] = [
+                'start' => (new DateTime('@' . $aRow['start']))->setTimezone($oTz)->format('Y-m-d'),
+                'size' => $this->formatValue('number', $aRow['size']),
+                'bx_repeat:cells' => $aCells,
+            ];
+        }
+
+        return $this->parseHtmlByName('retention.html', [
+            'tabs' => $this->_getTabsHtml('retention', $aData['tabs']),
+            'bx_if:cookieless' => [
+                'condition' => $aData['cookieless'],
+                'content' => ['cookieless' => _t('_bx_analytics_txt_retention_cookieless')],
+            ],
+            'caption' => $this->_esc(_t('_bx_analytics_lmi_cpt_retention')),
+            'cohort_title' => _t('_bx_analytics_txt_cohort'),
+            'size_title' => _t('_bx_analytics_metric_visitors'),
+            'bx_repeat:weeks' => $aWeeks,
+            'bx_repeat:rows' => $aRows,
+        ]);
+    }
+
+    protected function _getGoalsHtml($aData, $oFilter)
+    {
+        $sCsrf = $aData['manage'] ? (string)BxDolForm::getCsrfToken() : '';
+        $aTypes = ['page' => _t('_bx_analytics_txt_goal_type_page'), 'event' => _t('_bx_analytics_txt_goal_type_event')];
+
+        $fMax = 0;
+        foreach($aData['goals'] as $aGoal)
+            $fMax = max($fMax, $aGoal['rate']);
+
+        $aGoals = [];
+        $aEdit = null;
+        foreach($aData['goals'] as $aGoal) {
+            if($aData['edit'] !== '' && $aGoal['id'] === (int)$aData['edit'])
+                $aEdit = $aGoal;
+
+            $aGoals[] = [
+                'id' => $aGoal['id'],
+                'name' => $this->_esc($aGoal['name']),
+                'type' => _t('_bx_analytics_txt_goal_badge_' . $aGoal['type']),
+                'pattern' => $this->_esc($aGoal['pattern']),
+                'rate' => $this->formatValue('percent', $aGoal['rate']),
+                'change' => $this->_formatChange($aGoal['change']),
+                'trend' => $this->_trend('', $aGoal['change']),
+                'conversions' => _t('_bx_analytics_txt_conversions', $this->formatValue('number', $aGoal['conversions']), $this->formatValue('number', $aGoal['visitors'])),
+                'bar' => $fMax ? round(100 * $aGoal['rate'] / $fMax, 1) : 0,
+                'bx_if:actions' => [
+                    'condition' => $aData['manage'],
+                    'content' => [
+                        'id' => $aGoal['id'],
+                        'edit' => _t('_bx_analytics_txt_edit'),
+                        'delete' => _t('_bx_analytics_txt_delete'),
+                        'confirm' => $this->_esc(_t('_bx_analytics_txt_goal_delete_confirm', $aGoal['name'])),
+                        'csrf' => $this->_esc($sCsrf),
+                    ],
+                ],
+            ];
+        }
+
+        $bForm = $aData['manage'] && ($aEdit || $aData['edit'] === 'new');
+
+        return $this->parseHtmlByName('goals.html', [
+            'bx_if:goals' => [
+                'condition' => !empty($aGoals),
+                'content' => ['bx_repeat:goals' => $aGoals],
+            ],
+            'bx_if:empty' => [
+                'condition' => empty($aGoals),
+                'content' => ['empty' => _t('_bx_analytics_txt_no_goals')],
+            ],
+            'bx_if:add' => [
+                'condition' => $aData['manage'] && !$bForm,
+                'content' => ['add' => _t('_bx_analytics_txt_add_goal')],
+            ],
+            'bx_if:form' => [
+                'condition' => $bForm,
+                'content' => [
+                    'form_title' => _t($aEdit ? '_bx_analytics_txt_edit_goal' : '_bx_analytics_txt_add_goal'),
+                    'form_id' => $aEdit ? $aEdit['id'] : 0,
+                    'form_csrf' => $this->_esc($sCsrf),
+                    'form_name' => $aEdit ? $this->_esc($aEdit['name']) : '',
+                    'form_pattern' => $aEdit ? $this->_esc($aEdit['pattern']) : '',
+                    'page_selected' => !$aEdit || $aEdit['type'] == 'page' ? 'selected' : '',
+                    'event_selected' => $aEdit && $aEdit['type'] == 'event' ? 'selected' : '',
+                    'label_name' => _t('_bx_analytics_txt_name'),
+                    'label_type' => _t('_bx_analytics_txt_goal_type'),
+                    'label_pattern' => _t('_bx_analytics_txt_goal_match'),
+                    'type_page' => $aTypes['page'],
+                    'type_event' => $aTypes['event'],
+                    'hint' => _t('_bx_analytics_txt_match_hint'),
+                    'save' => _t('_bx_analytics_txt_save'),
+                    'cancel' => _t('_bx_analytics_txt_cancel'),
+                ],
+            ],
+        ]);
+    }
+
+    protected function _getFunnelsHtml($aData, $oFilter)
+    {
+        $sCsrf = $aData['manage'] ? (string)BxDolForm::getCsrfToken() : '';
+        $aTypes = ['page' => _t('_bx_analytics_txt_goal_type_page'), 'event' => _t('_bx_analytics_txt_goal_type_event')];
+
+        $aList = [];
+        foreach($aData['funnels'] as $aFunnel)
+            $aList[] = [
+                'id' => $aFunnel['id'],
+                'name' => $this->_esc($aFunnel['name']),
+                'active' => $aFunnel['active'] ? 'bx-analytics-chip-active' : '',
+                'pressed' => $aFunnel['active'] ? 'true' : 'false',
+            ];
+
+        $aSteps = [];
+        foreach($aData['steps'] as $i => $aStep)
+            $aSteps[] = [
+                'number' => $i + 1,
+                'pattern' => $this->_esc($aStep['pattern']),
+                'type' => _t('_bx_analytics_txt_goal_badge_' . $aStep['type']),
+                'rate_bar' => $aStep['rate'],
+                'sessions' => _t('_bx_analytics_txt_n_sessions', $this->formatValue('number', $aStep['sessions'])),
+                'rate' => $this->formatValue('percent', $aStep['rate']),
+                'bx_if:drop' => [
+                    'condition' => $aStep['drop'] !== null,
+                    'content' => ['drop' => _t('_bx_analytics_txt_dropped', $this->formatValue('percent', (float)$aStep['drop']))],
+                ],
+            ];
+
+        $sEdit = (string)$aData['edit'];
+        $bNew = $sEdit === 'new';
+        $bEdit = $aData['funnel'] && $sEdit !== '' && (int)$sEdit === $aData['funnel']['id'];
+        $bForm = $aData['manage'] && ($bNew || $bEdit);
+
+        $aRows = [];
+        $aDefinition = $bEdit ? $aData['definition'] : [];
+        for($i = 0; $i < $aData['max_steps']; $i++) {
+            $aStep = isset($aDefinition[$i]) ? $aDefinition[$i] : ['type' => 'page', 'pattern' => ''];
+            $aRows[] = [
+                'row_number' => $i + 1,
+                'row_type_label' => _t('_bx_analytics_txt_step_type', $i + 1),
+                'row_pattern_label' => _t('_bx_analytics_txt_step_match', $i + 1),
+                'row_page' => $aStep['type'] == 'page' ? 'selected' : '',
+                'row_type_page' => $aTypes['page'],
+                'row_type_event' => $aTypes['event'],
+                'row_event' => $aStep['type'] == 'event' ? 'selected' : '',
+                'row_pattern' => $this->_esc($aStep['pattern']),
+            ];
+        }
+
+        return $this->parseHtmlByName('funnels.html', [
+            'bx_if:list' => [
+                'condition' => count($aList) > 1,
+                'content' => [
+                    'list_label' => $this->_esc(_t('_bx_analytics_lmi_cpt_funnels')),
+                    'bx_repeat:list' => $aList,
+                ],
+            ],
+            'bx_if:manage' => [
+                'condition' => $aData['manage'] && !$bForm,
+                'content' => [
+                    'add' => _t('_bx_analytics_txt_add_funnel'),
+                    'bx_if:current' => [
+                        'condition' => (bool)$aData['funnel'],
+                        'content' => [
+                            'current_id' => $aData['funnel'] ? $aData['funnel']['id'] : 0,
+                            'edit' => _t('_bx_analytics_txt_edit'),
+                            'delete' => _t('_bx_analytics_txt_delete'),
+                            'confirm' => $this->_esc(_t('_bx_analytics_txt_funnel_delete_confirm', $aData['funnel'] ? $aData['funnel']['name'] : '')),
+                            'csrf' => $this->_esc($sCsrf),
+                        ],
+                    ],
+                ],
+            ],
+            'bx_if:steps' => [
+                'condition' => !empty($aSteps) && !$bForm,
+                'content' => ['bx_repeat:steps' => $aSteps],
+            ],
+            'bx_if:empty' => [
+                'condition' => empty($aSteps) && !$bForm,
+                'content' => ['empty' => _t('_bx_analytics_txt_no_funnels')],
+            ],
+            'bx_if:form' => [
+                'condition' => $bForm,
+                'content' => [
+                    'form_title' => _t($bEdit ? '_bx_analytics_txt_edit_funnel' : '_bx_analytics_txt_add_funnel'),
+                    'form_id' => $bEdit ? $aData['funnel']['id'] : 0,
+                    'form_csrf' => $this->_esc($sCsrf),
+                    'form_name' => $bEdit ? $this->_esc($aData['funnel']['name']) : '',
+                    'label_name' => _t('_bx_analytics_txt_name'),
+                    'bx_repeat:rows' => $aRows,
+                    'hint' => _t('_bx_analytics_txt_funnel_hint'),
+                    'save' => _t('_bx_analytics_txt_save'),
+                    'cancel' => _t('_bx_analytics_txt_cancel'),
+                ],
+            ],
+        ]);
+    }
+
+    protected function _getRevenueHtml($aData, $oFilter)
+    {
+        return $this->parseHtmlByName('revenue.html', [
+            'tiles' => $this->_getTilesHtml($aData['tiles']),
+            'chart_label' => $this->_esc(_t('_bx_analytics_txt_chart_revenue')),
+            'chart_json' => $this->_json($aData['chart']),
+            'split' => $this->_getTableHtml($aData['split'], $oFilter),
+            'notes' => $this->_getNotesHtml($aData['notes']),
+            'import' => $this->_getImportHtml($aData['import']),
+        ]);
+    }
+
+    protected function _getSubscriptionsHtml($aData, $oFilter)
+    {
+        return $this->parseHtmlByName('subscriptions.html', [
+            'tiles' => $this->_getTilesHtml($aData['tiles']),
+            'chart_label' => $this->_esc(_t('_bx_analytics_txt_chart_mrr')),
+            'chart_json' => $this->_json($aData['chart']),
+            'movement_title' => _t('_bx_analytics_txt_sbs_movement'),
+            'movement_label' => $this->_esc(_t('_bx_analytics_txt_sbs_movement')),
+            'movement_json' => $this->_json($aData['movement']),
+            'plans_title' => $this->_esc($aData['plans']['caption']),
+            'plans' => $this->_getTableHtml($aData['plans'], $oFilter),
+            'reasons_title' => $this->_esc($aData['reasons']['caption']),
+            'reasons' => $this->_getTableHtml($aData['reasons'], $oFilter),
+            'notes' => $this->_getNotesHtml($aData['notes']),
+        ]);
+    }
+
+    protected function _getSalesHtml($aData, $oFilter)
+    {
+        return $this->parseHtmlByName('sales.html', [
+            'tiles' => $this->_getTilesHtml($aData['tiles']),
+            'items_title' => $this->_esc($aData['items']['caption']),
+            'items' => $this->_getTableHtml($aData['items'], $oFilter),
+            'sellers_title' => $this->_esc($aData['sellers']['caption']),
+            'sellers' => $this->_getTableHtml($aData['sellers'], $oFilter),
+            'bx_if:invoices' => [
+                'condition' => !empty($aData['invoices']),
+                'content' => [
+                    'invoices_title' => _t('_bx_analytics_txt_sales_invoices'),
+                    'invoices' => !empty($aData['invoices']) ? $this->_getTilesHtml($aData['invoices']) : '',
+                ],
+            ],
+            'notes' => $this->_getNotesHtml($aData['notes']),
+        ]);
+    }
+
+    protected function _getCreditsHtml($aData, $oFilter)
+    {
+        if(empty($aData['available']))
+            return $this->parseHtmlByName('credits.html', [
+                'bx_if:credits' => ['condition' => false, 'content' => []],
+                'bx_if:none' => [
+                    'condition' => true,
+                    'content' => ['empty' => _t('_bx_analytics_txt_credits_none')],
+                ],
+                'notes' => '',
+            ]);
+
+        return $this->parseHtmlByName('credits.html', [
+            'bx_if:credits' => [
+                'condition' => true,
+                'content' => [
+                    'tiles' => $this->_getTilesHtml($aData['tiles']),
+                    'chart_label' => $this->_esc(_t('_bx_analytics_txt_chart_credits')),
+                    'chart_json' => $this->_json($aData['chart']),
+                    'spenders_title' => $this->_esc($aData['spenders']['caption']),
+                    'spenders' => $this->_getTableHtml($aData['spenders'], $oFilter),
+                    'spent_title' => $this->_esc($aData['spent']['caption']),
+                    'spent' => $this->_getTableHtml($aData['spent'], $oFilter),
+                ],
+            ],
+            'bx_if:none' => ['condition' => false, 'content' => []],
+            'notes' => $this->_getNotesHtml($aData['notes']),
+        ]);
+    }
+
+    /**
+     * Errors: the list with its key figures, or one error group. Messages, sources and
+     * stacks come from visitors' browsers and apps: every one is escaped.
+     */
+    protected function _getErrorsHtml($aData, $oFilter)
+    {
+        if($aData['view'] == 'list')
+            return $this->parseHtmlByName('errors.html', [
+                'tiles' => $this->_getTilesHtml($aData['tiles']),
+                'table' => $this->_getTableHtml($aData, $oFilter),
+            ]);
+
+        if(!empty($aData['missing']))
+            return $this->parseHtmlByName('error_missing.html', [
+                'back' => _t('_bx_analytics_txt_back_errors'),
+                'text' => $this->_esc($aData['title']),
+            ]);
+
+        $sCsrf = $aData['manage'] ? (string)BxDolForm::getCsrfToken() : '';
+        $aActions = [];
+        foreach($aData['actions'] as $aAction)
+            $aActions[] = [
+                'action_op' => $aAction['op'],
+                'action_id' => (int)$aData['id'],
+                'action_csrf' => $this->_esc($sCsrf),
+                'action_title' => $aAction['title'],
+            ];
+
+        $aFacts = [];
+        foreach($aData['facts'] as $aFact)
+            $aFacts[] = [
+                'fact_title' => $aFact['title'],
+                'fact_value' => $this->formatValue($aFact['type'], $aFact['value']),
+            ];
+
+        $aBreakdowns = [];
+        foreach($aData['breakdowns'] as $aTable)
+            $aBreakdowns[] = [
+                'breakdown_title' => $this->_esc($aTable['caption']),
+                'breakdown' => $this->_getTableHtml($aTable, $oFilter),
+            ];
+
+        $bStack = $aData['stack'] !== '';
+
+        return $this->parseHtmlByName('error.html', [
+            'back' => _t('_bx_analytics_txt_back_errors'),
+            'title' => $this->_esc($aData['title']),
+            'bx_if:actions' => [
+                'condition' => !empty($aActions),
+                'content' => [
+                    'actions_label' => $this->_esc(_t('_bx_analytics_txt_error_actions')),
+                    'bx_repeat:actions' => $aActions,
+                ],
+            ],
+            'bx_repeat:facts' => $aFacts,
+            'tiles' => $this->_getTilesHtml($aData['tiles']),
+            'chart_label' => $this->_esc(_t('_bx_analytics_txt_chart_error', $aData['title'])),
+            'chart_json' => $this->_json($aData['chart']),
+            'stack_title' => _t('_bx_analytics_txt_error_stack'),
+            'bx_if:no_stack' => [
+                'condition' => !$bStack,
+                'content' => ['no_stack' => _t($aData['stack_hidden'] ? '_bx_analytics_txt_error_stack_hidden' : '_bx_analytics_txt_error_no_stack')],
+            ],
+            'bx_if:breakdowns' => [
+                'condition' => !empty($aBreakdowns),
+                'content' => ['bx_repeat:breakdowns' => $aBreakdowns],
+            ],
+            'latest_title' => $this->_esc($aData['occurrences']['caption']),
+            'latest' => $this->_getTableHtml($aData['occurrences'], $oFilter),
+            // last: visitor text goes in after every placeholder is filled
+            'bx_if:stack' => [
+                'condition' => $bStack,
+                'content' => [
+                    'stack_label' => $this->_esc(_t('_bx_analytics_txt_error_stack')),
+                    'stack' => $this->_esc($aData['stack']),
+                ],
+            ],
+        ]);
+    }
+
+    /**
+     * Performance: a card per metric (75th percentile, rating, distribution) that picks the
+     * metric for the chart and the tables.
+     */
+    protected function _getPerformanceHtml($aData, $oFilter)
+    {
+        $aDevices = [];
+        foreach($aData['devices'] as $aDevice)
+            $aDevices[] = [
+                'device_value' => $aDevice['value'],
+                'device_title' => $aDevice['title'],
+                'device_selected' => $aDevice['selected'] ? 'selected="selected"' : '',
+            ];
+
+        $aCards = [];
+        foreach($aData['cards'] as $aCard)
+            $aCards[] = [
+                'card_metric' => $aCard['metric'],
+                'card_title' => $aCard['title'],
+                'card_value' => $aCard['value'] !== null ? $this->formatValue($aCard['unit'], $aCard['value']) : _t('_bx_analytics_txt_not_enough_data'),
+                'card_rating' => $aCard['rating'] !== '' ? str_replace('_', '-', $aCard['rating']) : 'none',
+                'card_rating_title' => $aCard['rating'] !== '' ? _t('_bx_analytics_rating_' . $aCard['rating']) : '',
+                'card_pressed' => $aCard['active'] ? 'true' : 'false',
+                'card_good' => $aCard['good'],
+                'card_ni' => $aCard['needs_improvement'],
+                'card_poor' => $aCard['poor'],
+                'card_dist' => $aCard['samples'] ? _t('_bx_analytics_txt_vital_distribution', $this->formatValue('percent', $aCard['good']), $this->formatValue('percent', $aCard['needs_improvement']), $this->formatValue('percent', $aCard['poor'])) : '',
+                'card_samples' => _t('_bx_analytics_txt_n_samples', $this->formatValue('number', $aCard['samples'])),
+                'card_thresholds' => _t('_bx_analytics_txt_vital_thresholds', $this->formatValue($aCard['unit'], $aCard['good_max']), $this->formatValue($aCard['unit'], $aCard['poor_min'])),
+            ];
+
+        return $this->parseHtmlByName('performance.html', [
+            'device_label' => _t('_bx_analytics_txt_device'),
+            'bx_repeat:devices' => $aDevices,
+            'cards_label' => $this->_esc(_t('_bx_analytics_txt_vitals_metrics')),
+            'bx_repeat:cards' => $aCards,
+            'chart_title' => $this->_esc(_t('_bx_analytics_txt_chart_vital', $aData['metric_title'])),
+            'chart_label' => $this->_esc(_t('_bx_analytics_txt_chart_vital', $aData['metric_title'])),
+            'chart_json' => $this->_json($aData['chart']),
+            'platforms_title' => $this->_esc($aData['platforms']['caption']),
+            'platforms' => $this->_getTableHtml($aData['platforms'], $oFilter),
+            'pages_title' => $this->_esc($aData['pages']['caption']),
+            'pages' => $this->_getTableHtml($aData['pages'], $oFilter),
+        ]);
+    }
+
+    /**
+     * A sparkline of values over time, scaled to its own maximum.
+     */
+    protected function _getSparkHtml($aValues)
+    {
+        $aValues = array_values($aValues);
+        $iCount = count($aValues);
+        if(!$iCount)
+            return '';
+
+        $fMax = max(1, max($aValues));
+        $aPoints = [];
+        foreach($aValues as $i => $mixedValue)
+            $aPoints[] = round($iCount > 1 ? 100 * $i / ($iCount - 1) : 50, 2) . ',' . round(22 - 20 * (float)$mixedValue / $fMax, 2);
+        if($iCount == 1)
+            $aPoints[] = '100,' . round(22 - 20 * (float)$aValues[0] / $fMax, 2);
+
+        return $this->parseHtmlByName('spark.html', [
+            'points' => implode(' ', $aPoints),
+        ]);
+    }
+
+    /**
+     * Short notes under a report: how amounts are counted, what is missing.
+     */
+    protected function _getNotesHtml($aNotes)
+    {
+        if(empty($aNotes))
+            return '';
+
+        $aItems = [];
+        foreach($aNotes as $sNote)
+            $aItems[] = ['note' => $this->_esc($sNote)];
+
+        return $this->parseHtmlByName('notes.html', [
+            'bx_repeat:notes' => $aItems,
+        ]);
+    }
+
+    /**
+     * The import of past payments, with a button to run a step now, for administrators.
+     */
+    protected function _getImportHtml($aImport)
+    {
+        if(empty($aImport) || empty($aImport['state']))
+            return '';
+
+        $bRunning = $aImport['state'] == 'running';
+
+        return $this->parseHtmlByName('import.html', [
+            'title' => _t('_bx_analytics_txt_import_title'),
+            'text' => $this->_esc(_t($bRunning ? '_bx_analytics_txt_import_running' : '_bx_analytics_txt_import_done', $this->formatValue('number', $aImport['imported']))),
+            'hint' => _t('_bx_analytics_txt_import_hint'),
+            'button' => _t($bRunning ? '_bx_analytics_txt_import_continue' : '_bx_analytics_txt_import_again'),
+            'op' => $bRunning ? 'import' : 'restart',
+            'confirm' => $bRunning ? '' : $this->_esc(_t('_bx_analytics_txt_import_confirm')),
+            'csrf' => $this->_esc((string)BxDolForm::getCsrfToken()),
+        ]);
+    }
+
+    /**
+     * Key figures that are not chart switches: title, value and an optional note or change.
+     */
+    protected function _getTilesHtml($aTiles)
+    {
+        $aItems = [];
+        foreach($aTiles as $aTile)
+            $aItems[] = [
+                'title' => $aTile['title'],
+                // nothing to measure (a rate without a base): say so instead of an empty tile
+                'value' => $aTile['value'] === null ? _t('_bx_analytics_txt_not_available') : $this->formatValue(isset($aTile['type']) ? $aTile['type'] : 'number', $aTile['value']),
+                'bx_if:sub' => [
+                    'condition' => !empty($aTile['sub']),
+                    'content' => ['sub' => !empty($aTile['sub']) ? $this->_esc($aTile['sub']) : ''],
+                ],
+                'bx_if:change' => [
+                    'condition' => isset($aTile['change']) && $aTile['change'] !== null,
+                    'content' => [
+                        'change' => isset($aTile['change']) ? $this->_formatChange($aTile['change']) : '',
+                        'trend' => isset($aTile['change']) ? $this->_trend(isset($aTile['metric']) ? $aTile['metric'] : '', $aTile['change']) : 'flat',
+                    ],
+                ],
+            ];
+
+        return $this->parseHtmlByName('tiles.html', [
+            'bx_repeat:tiles' => $aItems,
+        ]);
+    }
+
+    /**
+     * Query: what to break down and sort by, the first values over time, the table, and the
+     * CSV download of every value.
+     */
+    protected function _getQueryHtml($aData, $oFilter)
+    {
+        $aDimensions = ['event' => [], 'session' => []];
+        foreach($aData['dimensions'] as $aDimension)
+            $aDimensions[$aDimension['kind']][] = [
+                'dim_value' => $aDimension['name'],
+                'dim_title' => $aDimension['title'],
+                'dim_selected' => $aDimension['name'] == $aData['dimension'] ? 'selected="selected"' : '',
+            ];
+
+        $aMetrics = [];
+        foreach($aData['metrics'] as $aMetric)
+            $aMetrics[] = [
+                'metric_value' => $aMetric['name'],
+                'metric_title' => $aMetric['title'],
+                'metric_selected' => $aMetric['name'] == $aData['metric'] ? 'selected="selected"' : '',
+            ];
+
+        $aDirs = [];
+        foreach(['desc', 'asc'] as $sDir)
+            $aDirs[] = [
+                'dir_value' => $sDir,
+                'dir_title' => _t('_bx_analytics_txt_sort_' . $sDir),
+                'dir_selected' => $sDir == $aData['dir'] ? 'selected="selected"' : '',
+            ];
+
+        $bRows = !empty($aData['rows']);
+        $sExportUrl = bx_append_url_params(BX_DOL_URL_ROOT . $this->_oConfig->getBaseUri() . 'export/query/', http_build_query(array_merge($oFilter->getParams(), [
+            'q_dim' => $aData['dimension'],
+            'q_metric' => $aData['metric'],
+            'q_dir' => $aData['dir'],
+        ]), '', '&', PHP_QUERY_RFC3986));
+
+        return $this->parseHtmlByName('query.html', [
+            'dimension_label' => _t('_bx_analytics_txt_dimension'),
+            'event_dims_label' => _t('_bx_analytics_txt_dims_event'),
+            'bx_repeat:event_dims' => $aDimensions['event'],
+            'session_dims_label' => _t('_bx_analytics_txt_dims_session'),
+            'bx_repeat:session_dims' => $aDimensions['session'],
+            'metric_label' => _t('_bx_analytics_txt_metric'),
+            'bx_repeat:metrics' => $aMetrics,
+            'dir_label' => _t('_bx_analytics_txt_sort'),
+            'bx_repeat:dirs' => $aDirs,
+            'bx_if:download' => [
+                'condition' => $bRows,
+                'content' => [
+                    'export_url' => $this->_esc($sExportUrl),
+                    'download' => _t('_bx_analytics_txt_download_csv'),
+                ],
+            ],
+            'bx_if:chart' => [
+                'condition' => $bRows,
+                'content' => [
+                    'chart_label' => $this->_esc(_t('_bx_analytics_txt_chart_query', _t('_bx_analytics_metric_' . $aData['metric']), count($aData['chart']['series']))),
+                    'chart_json' => $this->_json($aData['chart']),
+                ],
+            ],
+            'table' => $this->_getTableHtml($aData, $oFilter),
+            'bx_if:truncated' => [
+                'condition' => $aData['total'] > $aData['export_max'],
+                'content' => [
+                    'truncated' => _t('_bx_analytics_txt_csv_truncated', $this->formatValue('number', $aData['export_max']), $this->formatValue('number', $aData['total'])),
+                ],
+            ],
+        ]);
+    }
+
+    /**
+     * A table report: columns with types, rows with a label cell that filters, links or is plain.
+     */
+    protected function _getTableHtml($aTable, $oFilter)
+    {
+        $aColumns = [];
+        foreach($aTable['columns'] as $aColumn)
+            $aColumns[] = [
+                'type' => $aColumn['type'],
+                'title' => $aColumn['title'],
+                // the column the rows are sorted by: ascending or descending
+                'sort' => isset($aColumn['sort']) && in_array($aColumn['sort'], ['ascending', 'descending'], true) ? 'aria-sort="' . $aColumn['sort'] . '"' : '',
+            ];
+
+        $aRows = [];
+        foreach($aTable['rows'] as $aRow) {
+            $aCells = [];
+            foreach($aTable['columns'] as $aColumn) {
+                $sKey = $aColumn['key'];
+                $bLabel = $aColumn['type'] == 'label';
+
+                $aCells[] = [
+                    'type' => $aColumn['type'],
+                    'bx_if:label' => [
+                        'condition' => $bLabel,
+                        'content' => $bLabel ? $this->_getLabelCell($aRow) : [],
+                    ],
+                    'bx_if:value' => [
+                        'condition' => !$bLabel,
+                        'content' => [
+                            'cell_value' => !$bLabel ? $this->formatValue($aColumn['type'], isset($aRow['values'][$sKey]) ? $aRow['values'][$sKey] : null) : '',
+                        ],
+                    ],
+                ];
+            }
+
+            $aRows[] = ['bx_repeat:cells' => $aCells];
+        }
+
+        $aPager = isset($aTable['pages']) && $aTable['pages'] > 1 ? [
+            'name' => $aTable['name'],
+            'page' => $aTable['page'],
+            'pages' => $aTable['pages'],
+            'prev' => max(1, $aTable['page'] - 1),
+            'next' => min($aTable['pages'], $aTable['page'] + 1),
+            'prev_disabled' => $aTable['page'] <= 1 ? 'disabled' : '',
+            'next_disabled' => $aTable['page'] >= $aTable['pages'] ? 'disabled' : '',
+            'txt_prev' => _t('_bx_analytics_txt_prev'),
+            'txt_next' => _t('_bx_analytics_txt_next'),
+            'txt_page' => _t('_bx_analytics_txt_page_of', $aTable['page'], $aTable['pages']),
+        ] : [];
+
+        return $this->parseHtmlByName('table.html', [
+            'tabs' => !empty($aTable['tabs']) ? $this->_getTabsHtml($aTable['name'], $aTable['tabs']) : '',
+            'panel_attrs' => !empty($aTable['tabs']) ? 'role="tabpanel" id="bx-analytics-panel-' . $aTable['name'] . '"' : '',
+            'name' => $aTable['name'],
+            'caption' => $this->_esc($aTable['caption']),
+            'bx_if:rows' => [
+                'condition' => !empty($aRows),
+                'content' => [
+                    'caption' => $this->_esc($aTable['caption']),
+                    'bx_repeat:columns' => $aColumns,
+                    'bx_repeat:rows' => $aRows,
+                ],
+            ],
+            'bx_if:empty' => [
+                'condition' => empty($aRows),
+                'content' => ['empty' => !empty($aTable['empty']) ? $aTable['empty'] : _t('_bx_analytics_txt_no_data')],
+            ],
+            'bx_if:pager' => [
+                'condition' => !empty($aPager),
+                'content' => $aPager,
+            ],
+        ]);
+    }
+
+    protected function _getLabelCell($aRow)
+    {
+        $sLabel = $this->_esc($aRow['label']);
+        $sSub = isset($aRow['sub']) ? $this->_esc($aRow['sub']) : '';
+        $bFilter = !empty($aRow['filter']);
+        $bLink = !$bFilter && !empty($aRow['link']);
+        $bOpen = !$bFilter && !$bLink && !empty($aRow['open']);
+
+        return [
+            'bar' => isset($aRow['bar']) ? $aRow['bar'] : 0,
+            // placeholder names are unique across the row: the parser fills every one with
+            // the same name from any part of the row
+            'bx_if:filter' => [
+                'condition' => $bFilter,
+                'content' => [
+                    'filter_dim' => $bFilter ? $aRow['filter']['dim'] : '',
+                    'filter_value' => $bFilter ? $this->_esc($aRow['filter']['value']) : '',
+                    'filter_title' => $this->_esc(_t('_bx_analytics_txt_filter_by', $aRow['label'])),
+                    'label' => $sLabel,
+                ],
+            ],
+            'bx_if:link' => [
+                'condition' => $bLink,
+                'content' => [
+                    'link_href' => $bLink ? $this->_esc($aRow['link']) : '',
+                    'label' => $sLabel,
+                ],
+            ],
+            'bx_if:open' => [
+                'condition' => $bOpen,
+                'content' => [
+                    'open_param' => $bOpen ? $aRow['open']['param'] : '',
+                    'open_value' => $bOpen ? $this->_esc($aRow['open']['value']) : '',
+                    'label' => $sLabel,
+                ],
+            ],
+            'bx_if:text' => [
+                'condition' => !$bFilter && !$bLink && !$bOpen,
+                'content' => ['label' => $sLabel],
+            ],
+            'bx_if:sub' => [
+                'condition' => $sSub !== '',
+                'content' => ['sub' => $sSub],
+            ],
+        ];
+    }
+
+    protected function _getTabsHtml($sGroup, $aTabs)
+    {
+        if(count($aTabs) < 2)
+            return '';
+
+        $aItems = [];
+        foreach($aTabs as $aTab)
+            $aItems[] = [
+                'group' => $sGroup,
+                'name' => $aTab['name'],
+                'title' => $aTab['title'],
+                'selected' => $aTab['active'] ? 'true' : 'false',
+                'tabindex' => $aTab['active'] ? '0' : '-1',
+            ];
+
+        return $this->parseHtmlByName('tabs.html', [
+            'group' => $sGroup,
+            'label' => $this->_esc(_t('_bx_analytics_group_' . $sGroup)),
+            'bx_repeat:tabs' => $aItems,
+        ]);
+    }
+
+    /**
+     * Studio > API: pick a report, edit its parameters, see the call as bx_srv, as an
+     * api.php URL and as NEO's request, and run it.
+     *
+     * @param array $aData catalog (see BxAnalyticsModule::getApiCatalog), api_enabled, api_access
+     */
+    public function getStudioApi($aData)
+    {
+        $aCatalog = $aData['catalog'];
+
+        $aNotices = [];
+        if(!$aData['api_enabled'])
+            $aNotices[] = ['notice' => $this->_esc(_t('_bx_analytics_txt_api_disabled'))];
+        elseif(!$aData['api_access'])
+            $aNotices[] = ['notice' => $this->_esc(_t('_bx_analytics_txt_api_no_access'))];
+
+        // a top list first: it shows what parameters do
+        $sSelected = isset($aCatalog['reports']['top']) ? 'top' : (string)key($aCatalog['reports']);
+
+        $aReports = [];
+        $aDocs = [];
+        foreach($aCatalog['reports'] as $sReport => $aParams) {
+            $aReports[] = [
+                'report_value' => $sReport,
+                'report_title' => $this->_esc(_t('_bx_analytics_report_' . $sReport)),
+                'report_selected' => $sReport == $sSelected ? 'selected="selected"' : '',
+            ];
+
+            $aDocs[] = [
+                'doc_report' => $sReport,
+                'doc_hidden' => $sReport == $sSelected ? '' : 'hidden',
+                'doc_table' => $this->_getApiParamsHtml($aParams, _t('_bx_analytics_txt_api_report_params', _t('_bx_analytics_report_' . $sReport))),
+            ];
+        }
+
+        $this->_addToolsAssets();
+
+        return $this->_getToolsJsInit() . $this->parseHtmlByName('studio_api.html', [
+            'bx_if:notices' => [
+                'condition' => !empty($aNotices),
+                'content' => ['bx_repeat:notices' => $aNotices],
+            ],
+            'intro' => _t('_bx_analytics_txt_api_intro'),
+            'csrf' => $this->_esc((string)BxDolForm::getCsrfToken()),
+            'report_label' => _t('_bx_analytics_txt_api_report'),
+            'bx_repeat:reports' => $aReports,
+            'params_label' => _t('_bx_analytics_txt_api_params'),
+            'params_hint' => _t('_bx_analytics_txt_api_params_hint'),
+            'docs_label' => _t('_bx_analytics_txt_api_docs'),
+            'params_default' => $this->_esc($sSelected == 'top' ? '{"group": "pages", "range": "7d"}' : '{"range": "7d"}'),
+            'common_table' => $this->_getApiParamsHtml($aCatalog['common'], _t('_bx_analytics_txt_api_common_params')),
+            'bx_repeat:docs' => $aDocs,
+            'code_srv' => $this->_getCodeHtml('bx-analytics-api-srv', _t('_bx_analytics_txt_api_srv'), ''),
+            'code_url' => $this->_getCodeHtml('bx-analytics-api-url', _t('_bx_analytics_txt_api_url'), ''),
+            'code_neo' => $this->_getCodeHtml('bx-analytics-api-neo', _t('_bx_analytics_txt_api_neo'), ''),
+            'run' => _t('_bx_analytics_txt_api_run'),
+            'response_label' => _t('_bx_analytics_txt_api_response'),
+            'copy' => _t('_bx_analytics_txt_copy'),
+            'copied' => $this->_esc(_t('_bx_analytics_txt_copied')),
+            'catalog_json' => $this->_json([
+                'root' => BX_DOL_URL_ROOT,
+                'module' => $this->MODULE,
+                'reports' => array_keys($aCatalog['reports']),
+            ]),
+        ]);
+    }
+
+    /**
+     * A table of parameters: name and what it accepts.
+     */
+    protected function _getApiParamsHtml($aParams, $sCaption)
+    {
+        $aRows = [];
+        foreach($aParams as $sName => $aParam) {
+            switch($aParam['type']) {
+                case 'enum':
+                    $sAccepts = _t('_bx_analytics_txt_api_type_enum', implode(', ', $aParam['values']));
+                    break;
+                case 'enum_by':
+                    $aLists = [];
+                    foreach($aParam['values'] as $sBy => $aValues)
+                        $aLists[] = $sBy . ': ' . implode(', ', $aValues);
+                    $sAccepts = _t('_bx_analytics_txt_api_type_enum_by', $aParam['by'], implode('; ', $aLists));
+                    break;
+                case 'int':
+                    $sAccepts = isset($aParam['max']) ? _t('_bx_analytics_txt_api_type_int_range', $aParam['min'], $aParam['max']) : _t('_bx_analytics_txt_api_type_int', $aParam['min']);
+                    break;
+                case 'date':
+                    $sAccepts = _t('_bx_analytics_txt_api_type_date');
+                    break;
+                case 'dims':
+                    $sAccepts = _t('_bx_analytics_txt_api_type_dims', implode(', ', $aParam['values']));
+                    break;
+                default:
+                    $sAccepts = _t('_bx_analytics_txt_api_type_string');
+            }
+            if(isset($aParam['default']))
+                $sAccepts .= ' ' . _t('_bx_analytics_txt_api_default', $aParam['default']);
+
+            $aRows[] = [
+                'param_name' => $this->_esc($sName),
+                'param_accepts' => $this->_esc($sAccepts),
+            ];
+        }
+
+        return $this->parseHtmlByName('api_params.html', [
+            'caption' => $this->_esc($sCaption),
+            'bx_if:params' => [
+                'condition' => !empty($aRows),
+                'content' => [
+                    'caption' => $this->_esc($sCaption),
+                    'col_param' => _t('_bx_analytics_txt_api_col_param'),
+                    'col_accepts' => _t('_bx_analytics_txt_api_col_accepts'),
+                    'bx_repeat:params' => $aRows,
+                ],
+            ],
+            'bx_if:none' => [
+                'condition' => empty($aRows),
+                'content' => ['none' => _t('_bx_analytics_txt_api_no_params')],
+            ],
+        ]);
+    }
+
+    /**
+     * Studio > Dashboards: site pages with analytics blocks, and a form for a new one.
+     *
+     * @param array $aData pages (see BxAnalyticsModule::getDashboards)
+     */
+    public function getStudioDashboards($aData)
+    {
+        $aPages = [];
+        foreach($aData['pages'] as $aPage)
+            $aPages[] = [
+                'view_url' => $this->_esc($aPage['view_url']),
+                'page_title' => $this->_esc($aPage['title']),
+                'blocks' => $this->formatValue('number', $aPage['blocks']),
+                'api_blocks' => $this->formatValue('number', $aPage['api_blocks']),
+                'edit_url' => $this->_esc($aPage['edit_url']),
+                'edit' => _t('_bx_analytics_txt_dash_edit'),
+                'edit_label' => $this->_esc(_t('_bx_analytics_txt_dash_edit_label', $aPage['title'])),
+            ];
+
+        $this->_addToolsAssets();
+
+        return $this->_getToolsJsInit() . $this->parseHtmlByName('studio_dashboards.html', [
+            'intro' => _t('_bx_analytics_txt_dash_intro'),
+            'bx_if:pages' => [
+                'condition' => !empty($aPages),
+                'content' => [
+                    'caption' => $this->_esc(_t('_bx_analytics_txt_dash_pages')),
+                    'col_page' => _t('_bx_analytics_txt_dash_col_page'),
+                    'col_blocks' => _t('_bx_analytics_txt_dash_blocks'),
+                    'col_neo' => _t('_bx_analytics_txt_dash_neo_blocks'),
+                    'col_actions' => _t('_bx_analytics_txt_dash_col_actions'),
+                    'bx_repeat:pages' => $aPages,
+                ],
+            ],
+            'bx_if:empty' => [
+                'condition' => empty($aPages),
+                'content' => ['empty' => _t('_bx_analytics_txt_dash_empty')],
+            ],
+            'neo_note' => _t('_bx_analytics_txt_dash_neo_note'),
+            'create_title' => _t('_bx_analytics_txt_dash_create'),
+            'csrf' => $this->_esc((string)BxDolForm::getCsrfToken()),
+            'label_title' => _t('_bx_analytics_txt_dash_title'),
+            'create_hint' => _t('_bx_analytics_txt_dash_create_hint'),
+            'disable_warning' => _t('_bx_analytics_txt_dash_disable_warning'),
+            'create' => _t('_bx_analytics_txt_dash_create_btn'),
+        ]);
+    }
+
+    /**
+     * Studio > Setup: tracker status per platform, checks of the admin's own browser, what
+     * each client needs, and custom events.
+     *
+     * @param array $aData status (see BxAnalyticsModule::getSetupStatus), tracking, injection,
+     *      track_actions, alerts, api_enabled, api_key, api_origin
+     */
+    public function getStudioSetup($aData)
+    {
+        $sRoot = BX_DOL_URL_ROOT;
+        $sCsrf = $this->_esc((string)BxDolForm::getCsrfToken());
+
+        // what NEO and the apps need from the site
+        $aNeoChecks = [
+            ['on' => $aData['api_enabled'], 'text' => _t('_bx_analytics_txt_setup_neo_api')],
+            ['on' => $aData['api_key'] || $aData['api_origin'], 'text' => _t('_bx_analytics_txt_setup_neo_access')],
+        ];
+        foreach([BX_ANALYTICS_PLATFORM_NEXT_WEB, BX_ANALYTICS_PLATFORM_RN_IOS, BX_ANALYTICS_PLATFORM_RN_ANDROID] as $sPlatform) {
+            $aStatus = $aData['status'][$sPlatform];
+            $aNeoChecks[] = [
+                'on' => $aStatus['enabled'],
+                'text' => _t($aStatus['enabled'] ? '_bx_analytics_txt_setup_platform_on' : '_bx_analytics_txt_setup_platform_off', _t($this->_oConfig->getPlatforms()[$sPlatform])),
+            ];
+        }
+
+        $aChecks = [];
+        foreach($aNeoChecks as $aCheck)
+            $aChecks[] = [
+                'check_state' => $aCheck['on'] ? 'yes' : 'no',
+                'check_icon' => $this->_getIconHtml($aCheck['on'] ? 'check' : 'x'),
+                'check_text' => $this->_esc($aCheck['text']),
+            ];
+
+        $aAlerts = [];
+        foreach($aData['alerts'] as $sAlert)
+            $aAlerts[] = ['alert' => $this->_esc($sAlert)];
+
+        $sCurl = "curl -X POST '" . $sRoot . "api.php?r=" . $this->MODULE . "/collect' \\\n"
+            . "  -H 'Authorization: Bearer YOUR_API_KEY' \\\n"
+            . "  -H 'Content-Type: application/json' \\\n"
+            . "  -d '{\"platform\": \"api\", \"app\": \"my-client\", \"app_version\": \"1.0.0\", \"hits\": [{\"type\": \"pageview\", \"url\": \"https://example.com/pricing\", \"title\": \"Pricing\"}, {\"type\": \"event\", \"name\": \"signup_click\", \"url\": \"https://example.com/pricing\", \"props\": {\"plan\": \"pro\"}}]}'";
+
+        $this->_addToolsAssets();
+
+        return $this->_getToolsJsInit() . $this->parseHtmlByName('studio_setup.html', [
+            'bx_if:tracking_off' => [
+                'condition' => !$aData['tracking'],
+                'content' => [
+                    'tracking_off' => _t('_bx_analytics_txt_setup_tracking_off'),
+                    'settings_url' => $this->_esc(BX_DOL_URL_STUDIO . 'module.php?name=' . $this->MODULE . '&page=settings'),
+                    'settings' => _t('_bx_analytics_txt_setup_open_settings'),
+                ],
+            ],
+            'status_title' => _t('_bx_analytics_txt_setup_status'),
+            'status' => $this->getSetupStatusHtml($aData['status']),
+            'check_title' => _t('_bx_analytics_txt_setup_check_title'),
+            'check_hint' => _t('_bx_analytics_txt_setup_check_hint'),
+            'csrf' => $sCsrf,
+            'check' => _t('_bx_analytics_txt_setup_check'),
+            'test' => _t('_bx_analytics_txt_setup_test'),
+            'test_warning' => _t('_bx_analytics_txt_setup_test_warning'),
+            'install_title' => _t('_bx_analytics_txt_setup_install'),
+            'core_web_title' => _t('_bx_analytics_platform_core_web'),
+            'core_web_text' => _t('_bx_analytics_txt_setup_core_web'),
+            'injection_state' => $aData['injection'] ? 'yes' : 'no',
+            'injection_icon' => $this->_getIconHtml($aData['injection'] ? 'check' : 'x'),
+            'injection' => _t($aData['injection'] ? '_bx_analytics_txt_setup_injection_on' : '_bx_analytics_txt_setup_injection_off'),
+            'core_web_exclusions' => _t('_bx_analytics_txt_setup_core_web_exclusions'),
+            'neo_title' => _t('_bx_analytics_txt_setup_neo_title'),
+            'neo_text' => _t('_bx_analytics_txt_setup_neo'),
+            'bx_repeat:neo_checks' => $aChecks,
+            'api_title' => _t('_bx_analytics_platform_api'),
+            'api_text' => _t('_bx_analytics_txt_setup_api'),
+            'code_curl' => $this->_getCodeHtml('bx-analytics-setup-curl', _t('_bx_analytics_txt_setup_curl'), $sCurl),
+            'core_title' => _t('_bx_analytics_platform_core'),
+            'core_text' => _t('_bx_analytics_txt_setup_core'),
+            'actions_state' => $aData['track_actions'] ? 'yes' : 'no',
+            'actions_icon' => $this->_getIconHtml($aData['track_actions'] ? 'check' : 'x'),
+            'actions' => _t($aData['track_actions'] ? '_bx_analytics_txt_setup_actions_on' : '_bx_analytics_txt_setup_actions_off'),
+            'alerts_label' => _t('_bx_analytics_txt_setup_alerts'),
+            'bx_repeat:alerts' => $aAlerts,
+            'events_title' => _t('_bx_analytics_txt_setup_events'),
+            'events_hint' => _t('_bx_analytics_txt_setup_events_hint'),
+            'code_classic' => $this->_getCodeHtml('bx-analytics-setup-js', _t('_bx_analytics_txt_setup_events_classic'), "window.bx_analytics && bx_analytics.track('signup_click', {plan: 'pro'});"),
+            'code_neo' => $this->_getCodeHtml('bx-analytics-setup-neo', _t('_bx_analytics_txt_setup_events_neo'), "import { trackEvent } from 'app/lib/analytics';\n\ntrackEvent('signup_click', { plan: 'pro' });"),
+            'code_server' => $this->_getCodeHtml('bx-analytics-setup-srv', _t('_bx_analytics_txt_setup_events_server'), "bx_srv('" . $this->MODULE . "', 'track', ['export_done', ['rows' => 120]]);"),
+            'events_warning' => _t('_bx_analytics_txt_setup_events_warning'),
+        ]);
+    }
+
+    /**
+     * The tracker status table, on the page and after a test event.
+     */
+    public function getSetupStatusHtml($aStatus)
+    {
+        $aPlatforms = $this->_oConfig->getPlatforms();
+
+        $aRows = [];
+        foreach($aStatus as $sPlatform => $aRow) {
+            $bApp = $this->_oConfig->isAppPlatform($sPlatform);
+            $aRows[] = [
+                'platform_title' => _t($aPlatforms[$sPlatform]),
+                'status' => $aRow['status'],
+                'status_icon' => $this->_getIconHtml($aRow['status']),
+                'status_title' => _t('_bx_analytics_txt_setup_status_' . $aRow['status']),
+                'last' => $aRow['last'] ? $this->formatValue('datetime', $aRow['last']) : _t('_bx_analytics_txt_setup_never'),
+                'hits' => $this->formatValue('number', $aRow['hits']),
+                // the server's own hits keep no visits
+                'visitors' => $sPlatform == BX_ANALYTICS_PLATFORM_CORE ? _t('_bx_analytics_txt_not_available') : $this->formatValue('number', $aRow['visitors']),
+                'sessions' => $sPlatform == BX_ANALYTICS_PLATFORM_CORE ? _t('_bx_analytics_txt_not_available') : $this->formatValue('number', $aRow['sessions']),
+                'version' => $bApp && $aRow['app_version'] !== '' ? $this->_esc($aRow['app_version']) : ($bApp ? _t('_bx_analytics_txt_setup_none') : _t('_bx_analytics_txt_not_available')),
+            ];
+        }
+
+        return $this->parseHtmlByName('setup_status.html', [
+            'caption' => $this->_esc(_t('_bx_analytics_txt_setup_status')),
+            'col_platform' => _t('_bx_analytics_txt_platform'),
+            'col_status' => _t('_bx_analytics_txt_setup_col_status'),
+            'col_last' => _t('_bx_analytics_txt_setup_last_hit'),
+            'col_hits' => _t('_bx_analytics_txt_setup_hits_24h'),
+            'col_visitors' => _t('_bx_analytics_txt_setup_visitors_24h'),
+            'col_sessions' => _t('_bx_analytics_txt_setup_sessions_7d'),
+            'col_version' => _t('_bx_analytics_txt_setup_app_version'),
+            'bx_repeat:rows' => $aRows,
+        ]);
+    }
+
+    /**
+     * The result of "Check this browser": tracked or not and why, and what the server sees.
+     *
+     * @param array $aCheck see BxAnalyticsCollector::check
+     */
+    public function getSetupCheckHtml($aCheck)
+    {
+        $bTracked = $aCheck['reasons'] == ['ok'];
+
+        $aReasons = [];
+        foreach($aCheck['reasons'] as $sReason)
+            if($sReason != 'ok')
+                $aReasons[] = [
+                    'reason_kind' => 'no',
+                    'reason_icon' => $this->_getIconHtml('x'),
+                    'reason' => _t('_bx_analytics_txt_check_' . $sReason),
+                ];
+        foreach($aCheck['notes'] as $sNote)
+            $aReasons[] = [
+                'reason_kind' => 'note',
+                'reason_icon' => $this->_getIconHtml('info'),
+                'reason' => _t('_bx_analytics_txt_check_' . $sNote),
+            ];
+
+        $sGeo = _t('_bx_analytics_txt_check_geo_none');
+        if($aCheck['geo_source'] == BX_ANALYTICS_GEO_NONE)
+            $sGeo = _t('_bx_analytics_txt_check_geo_off');
+        elseif($aCheck['country_header'] !== '')
+            $sGeo = _t('_bx_analytics_txt_check_geo_header', $this->_esc(str_replace('_', '-', preg_replace('/^HTTP_/', '', $aCheck['country_header']))), $this->_esc($aCheck['country']));
+
+        return $this->parseHtmlByName('setup_check.html', [
+            'verdict_state' => $bTracked ? 'yes' : 'no',
+            'verdict_icon' => $this->_getIconHtml($bTracked ? 'check' : 'x'),
+            'verdict' => _t($bTracked ? '_bx_analytics_txt_check_tracked' : '_bx_analytics_txt_check_not_tracked'),
+            'bx_if:reasons' => [
+                'condition' => !empty($aReasons),
+                'content' => ['bx_repeat:reasons' => $aReasons],
+            ],
+            'ip_label' => _t('_bx_analytics_txt_check_ip'),
+            'ip' => $aCheck['ip'] !== '' ? $this->_esc($aCheck['ip']) : _t('_bx_analytics_txt_not_available'),
+            'remote_label' => _t('_bx_analytics_txt_check_remote_addr'),
+            'remote' => $aCheck['remote_addr'] !== '' ? $this->_esc($aCheck['remote_addr']) : _t('_bx_analytics_txt_not_available'),
+            'geo_label' => _t('_bx_analytics_txt_check_geo'),
+            'geo' => $sGeo,
+            'identity_label' => _t('_bx_analytics_adm_stg_cpt_identity'),
+            'identity' => _t('_bx_analytics_identity_' . $aCheck['identity']),
+        ]);
+    }
+
+    /**
+     * A code snippet with a copy button. The code is text: it is escaped here.
+     */
+    protected function _getCodeHtml($sId, $sLabel, $sCode)
+    {
+        return $this->parseHtmlByName('code.html', [
+            'code_id' => $sId,
+            'code_label' => $this->_esc($sLabel),
+            'code' => $this->_esc($sCode),
+            'copy' => _t('_bx_analytics_txt_copy'),
+            'copied' => $this->_esc(_t('_bx_analytics_txt_copied')),
+        ]);
+    }
+
+    /**
+     * A small Lucide icon next to a status text, hidden from screen readers.
+     *
+     * @param string $sName receiving, quiet, nodata, off, check, x, info
+     */
+    protected function _getIconHtml($sName)
+    {
+        $aIcons = ['receiving' => 'check', 'quiet' => 'clock', 'nodata' => 'minus', 'off' => 'power', 'check' => 'check', 'x' => 'x', 'info' => 'info'];
+        $sIcon = isset($aIcons[$sName]) ? $aIcons[$sName] : 'info';
+
+        $aVars = [];
+        foreach(array_unique(array_values($aIcons)) as $sGlyph)
+            $aVars['bx_if:' . $sGlyph] = ['condition' => $sGlyph == $sIcon, 'content' => []];
+
+        return $this->parseHtmlByName('icon.html', $aVars);
+    }
+
+    protected function _addToolsAssets()
+    {
+        $this->_addAssets(['studio_tools.js'], ['dashboard.css']);
+    }
+
+    protected function _getToolsJsInit()
+    {
+        return $this->getJsCode('tools', [
+            'aStrings' => [
+                'error' => _t('_bx_analytics_txt_error_loading'),
+                'running' => _t('_bx_analytics_txt_api_running'),
+                'meta' => _t('_bx_analytics_txt_api_meta', '{status}', '{ms}', '{size}'),
+                'truncated' => _t('_bx_analytics_txt_api_truncated'),
+                'bad_json' => _t('_bx_analytics_txt_api_bad_params'),
+            ],
+        ], [
+            'mask' => "if(typeof window.{object} == 'undefined') window.{object} = new {class}({params});",
+            'wrap' => true,
+        ]);
+    }
+
+    /**
+     * Creates the dashboard script object once per page, whatever block comes first.
+     */
+    protected function _getJsInit()
+    {
+        return $this->getJsCode('dashboard', [
+            'sActionUrl' => BX_DOL_URL_ROOT . $this->_oConfig->getBaseUri(),
+            'aStrings' => [
+                'duration_h' => _t('_bx_analytics_txt_duration_h'),
+                'duration_m' => _t('_bx_analytics_txt_duration_m'),
+                'duration_s' => _t('_bx_analytics_txt_duration_s'),
+                'ms' => _t('_bx_analytics_txt_ms'),
+                'error' => _t('_bx_analytics_txt_error_loading'),
+            ],
+        ], [
+            'mask' => "if(typeof window.{object} == 'undefined') window.{object} = new {class}({params});",
+            'wrap' => true,
+        ]);
+    }
+
+    protected function _addDashboardAssets($sReport = '')
+    {
+        $aJs = ['chart.umd.min.js', 'dashboard.js'];
+        $aCss = ['dashboard.css'];
+
+        // the map uses the Leaflet copy and tile provider the site's location maps use
+        if($sReport == 'map') {
+            $aJs = array_merge(['leaflet/leaflet.js', 'leaflet-providers/leaflet-providers.js'], $aJs);
+            $aCss[] = BX_DIRECTORY_PATH_PLUGINS_PUBLIC . 'leaflet/|leaflet.css';
+        }
+
+        $this->_addAssets($aJs, $aCss);
+    }
+
+    protected function _addAssets($aJs, $aCss)
+    {
+        if($this->_bStudio) {
+            $this->addStudioJs($aJs);
+            $this->addStudioCss($aCss);
+            return;
+        }
+
+        $this->addJs($aJs);
+        $this->addCss($aCss);
+    }
+
+    /**
+     * 950, 9.5K, 95K, 1.2M
+     */
+    protected function _formatNumber($iValue)
+    {
+        $iValue = (int)$iValue;
+        foreach([1000000000 => 'B', 1000000 => 'M', 1000 => 'K'] as $iSize => $sSuffix)
+            if($iValue >= $iSize) {
+                $fValue = $iValue / $iSize;
+                return ($fValue < 10 ? rtrim(rtrim(number_format($fValue, 1), '0'), '.') : number_format(floor($fValue))) . $sSuffix;
+            }
+
+        return (string)$iValue;
+    }
+
+    protected function _formatChange($mixedChange)
+    {
+        if($mixedChange === null)
+            return '';
+
+        return ($mixedChange > 0 ? '+' : '') . number_format(abs($mixedChange) < 10 ? $mixedChange : round($mixedChange), abs($mixedChange) < 10 ? 1 : 0) . '%';
+    }
+
+    /**
+     * good, bad or flat, where down is good for bounce rate.
+     */
+    protected function _trend($sMetric, $mixedChange)
+    {
+        if($mixedChange === null || $mixedChange == 0)
+            return 'flat';
+
+        $bUp = $mixedChange > 0;
+        if(in_array($sMetric, ['bounce_rate', 'errors', 'refunds', 'cancelled', 'churn'], true))
+            return $bUp ? 'bad' : 'good';
+
+        return $bUp ? 'good' : 'bad';
+    }
+
+    /**
+     * Visitor-supplied text (paths, titles, referrers) for HTML text and attributes.
+     */
+    protected function _esc($s)
+    {
+        return htmlspecialchars((string)$s, ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML5, 'UTF-8');
+    }
+
+    /**
+     * JSON safe to put inside a script element.
+     */
+    protected function _json($a)
+    {
+        return json_encode($a, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_UNESCAPED_UNICODE);
+    }
+}
+
+/** @} */

+ 311 - 0
modules/boonex/analytics/updates/15.0.1_15.0.2/source/classes/BxAnalyticsUserAgent.php

@@ -0,0 +1,311 @@
+<?php defined('BX_DOL') or die('hack attempt');
+/**
+ * Copyright (c) UNA, Inc - https://una.io
+ * MIT License - https://opensource.org/licenses/MIT
+ *
+ * @defgroup    Analytics Analytics
+ * @ingroup     UnaModules
+ *
+ * @{
+ */
+
+/**
+ * User agent parser for web analytics: browser, operating system, device type and bots.
+ *
+ * Covers the browsers, systems and devices that make up nearly all real traffic. Rules are
+ * checked in order, so a token shared by several browsers (Chrome, Safari) comes last.
+ * Native app hits carry their own system and device fields and don't depend on this class.
+ */
+class BxAnalyticsUserAgent
+{
+    const DEVICE_DESKTOP = 'desktop';
+    const DEVICE_MOBILE = 'mobile';
+    const DEVICE_TABLET = 'tablet';
+    const DEVICE_TV = 'tv';
+    const DEVICE_CONSOLE = 'console';
+    const DEVICE_BOT = 'bot';
+
+    /**
+     * Crawlers, link previews, monitoring and HTTP libraries. A browser never sends these tokens.
+     */
+    protected static $_sBotsPattern = '/(?<!cu)bot\b|(?<!cu)bot\/|crawl|spider|slurp|mediapartners|adsbot|facebookexternalhit|facebookcatalog|meta-externalagent|embedly|quora link preview|outbrain|pinterest\/|vkshare|w3c_validator|whatsapp|telegrambot|discordbot|slackbot|skypeuripreview|bitlybot|headlesschrome|phantomjs|puppeteer|playwright|selenium|lighthouse|pagespeed|gtmetrix|pingdom|uptimerobot|statuscake|site24x7|datadog|newrelic|python-requests|python-urllib|aiohttp|httpx|curl\/|wget\/|libwww|httpclient|go-http-client|java\/|axios\/|node-fetch|undici|guzzlehttp|okhttp\/[0-9.]+$|scrapy|feedfetcher|rss|preview|archive\.org|ia_archiver|gptbot|chatgpt-user|oai-searchbot|claudebot|claude-web|anthropic-ai|perplexitybot|ccbot|bytespider|amazonbot|applebot|petalbot|semrush|ahrefs|mj12bot|dotbot|yandex(bot|images|metrika)|baiduspider|sogou|exabot|seznambot|bingpreview|google-inspectiontool|googleother|storebot/i';
+
+    const BOT_SEARCH = 'search';
+    const BOT_AI_CRAWLER = 'ai_crawler';
+    const BOT_AI_AGENT = 'ai_agent';
+    const BOT_SEO = 'seo';
+    const BOT_SOCIAL = 'social';
+    const BOT_MONITOR = 'monitor';
+    const BOT_FEED = 'feed';
+    const BOT_SCRIPT = 'script';
+    const BOT_OTHER = 'other';
+
+    /**
+     * Known bots as [pattern, name, category]. AI agents fetch a page because a person asked
+     * an assistant about it; AI crawlers collect pages for training or search indexes.
+     */
+    protected static $_aBots = [
+        ['/ChatGPT-User/i', 'ChatGPT', self::BOT_AI_AGENT],
+        ['/Claude-User/i', 'Claude', self::BOT_AI_AGENT],
+        ['/Perplexity-User/i', 'Perplexity', self::BOT_AI_AGENT],
+        ['/MistralAI-User/i', 'Mistral', self::BOT_AI_AGENT],
+        ['/DuckAssistBot/i', 'DuckAssist', self::BOT_AI_AGENT],
+        ['/Google-NotebookLM/i', 'NotebookLM', self::BOT_AI_AGENT],
+        ['/OAI-SearchBot/i', 'OAI-SearchBot', self::BOT_AI_CRAWLER],
+        ['/GPTBot/i', 'GPTBot', self::BOT_AI_CRAWLER],
+        ['/Claude-SearchBot/i', 'Claude-SearchBot', self::BOT_AI_CRAWLER],
+        ['/ClaudeBot|anthropic-ai|Claude-Web/i', 'ClaudeBot', self::BOT_AI_CRAWLER],
+        ['/PerplexityBot/i', 'PerplexityBot', self::BOT_AI_CRAWLER],
+        ['/CCBot/i', 'CCBot', self::BOT_AI_CRAWLER],
+        ['/Bytespider/i', 'Bytespider', self::BOT_AI_CRAWLER],
+        ['/meta-externalagent|meta-externalfetcher/i', 'Meta AI', self::BOT_AI_CRAWLER],
+        ['/Amazonbot/i', 'Amazonbot', self::BOT_AI_CRAWLER],
+        ['/cohere-ai|cohere-training/i', 'Cohere', self::BOT_AI_CRAWLER],
+        ['/Diffbot/i', 'Diffbot', self::BOT_AI_CRAWLER],
+        ['/YouBot/i', 'YouBot', self::BOT_AI_CRAWLER],
+        ['/AI2Bot/i', 'AI2Bot', self::BOT_AI_CRAWLER],
+        ['/Timpibot|ImagesiftBot|Omgilibot|PetalBot.*AI/i', 'Other AI crawler', self::BOT_AI_CRAWLER],
+        ['/Googlebot|Google-InspectionTool|GoogleOther|Storebot-Google|AdsBot-Google|Mediapartners-Google/i', 'Googlebot', self::BOT_SEARCH],
+        ['/bingbot|BingPreview|msnbot|adidxbot/i', 'Bingbot', self::BOT_SEARCH],
+        ['/YandexBot|YandexImages|YandexMetrika|YandexMobileBot/i', 'YandexBot', self::BOT_SEARCH],
+        ['/Baiduspider/i', 'Baiduspider', self::BOT_SEARCH],
+        ['/DuckDuckBot/i', 'DuckDuckBot', self::BOT_SEARCH],
+        ['/Applebot/i', 'Applebot', self::BOT_SEARCH],
+        ['/Sogou/i', 'Sogou', self::BOT_SEARCH],
+        ['/SeznamBot/i', 'SeznamBot', self::BOT_SEARCH],
+        ['/PetalBot/i', 'PetalBot', self::BOT_SEARCH],
+        ['/Yeti\//i', 'Naver', self::BOT_SEARCH],
+        ['/Qwantbot|Qwantify/i', 'Qwant', self::BOT_SEARCH],
+        ['/MojeekBot/i', 'Mojeek', self::BOT_SEARCH],
+        ['/coccocbot/i', 'Coc Coc', self::BOT_SEARCH],
+        ['/Exabot/i', 'Exabot', self::BOT_SEARCH],
+        ['/AhrefsBot|AhrefsSiteAudit/i', 'Ahrefs', self::BOT_SEO],
+        ['/SemrushBot|SiteAuditBot/i', 'Semrush', self::BOT_SEO],
+        ['/MJ12bot/i', 'Majestic', self::BOT_SEO],
+        ['/DotBot/i', 'Moz', self::BOT_SEO],
+        ['/rogerbot/i', 'Moz', self::BOT_SEO],
+        ['/Screaming Frog/i', 'Screaming Frog', self::BOT_SEO],
+        ['/BLEXBot/i', 'BLEXBot', self::BOT_SEO],
+        ['/DataForSeoBot/i', 'DataForSEO', self::BOT_SEO],
+        ['/serpstatbot/i', 'Serpstat', self::BOT_SEO],
+        ['/facebookexternalhit|facebookcatalog|Facebot/i', 'Facebook', self::BOT_SOCIAL],
+        ['/Twitterbot/i', 'X (Twitter)', self::BOT_SOCIAL],
+        ['/LinkedInBot/i', 'LinkedIn', self::BOT_SOCIAL],
+        ['/Slackbot|Slack-ImgProxy/i', 'Slack', self::BOT_SOCIAL],
+        ['/Discordbot/i', 'Discord', self::BOT_SOCIAL],
+        ['/TelegramBot/i', 'Telegram', self::BOT_SOCIAL],
+        ['/WhatsApp/i', 'WhatsApp', self::BOT_SOCIAL],
+        ['/Pinterest/i', 'Pinterest', self::BOT_SOCIAL],
+        ['/redditbot/i', 'Reddit', self::BOT_SOCIAL],
+        ['/vkShare/i', 'VK', self::BOT_SOCIAL],
+        ['/SkypeUriPreview/i', 'Skype', self::BOT_SOCIAL],
+        ['/Embedly|Iframely/i', 'Embed service', self::BOT_SOCIAL],
+        ['/Mastodon|Bluesky|Cardyb/i', 'Fediverse', self::BOT_SOCIAL],
+        ['/UptimeRobot/i', 'UptimeRobot', self::BOT_MONITOR],
+        ['/Pingdom/i', 'Pingdom', self::BOT_MONITOR],
+        ['/StatusCake/i', 'StatusCake', self::BOT_MONITOR],
+        ['/Site24x7/i', 'Site24x7', self::BOT_MONITOR],
+        ['/Datadog/i', 'Datadog', self::BOT_MONITOR],
+        ['/NewRelic/i', 'New Relic', self::BOT_MONITOR],
+        ['/Better Uptime|BetterStack/i', 'Better Stack', self::BOT_MONITOR],
+        ['/Chrome-Lighthouse|PageSpeed|GTmetrix/i', 'Speed test', self::BOT_MONITOR],
+        ['/Feedly|Inoreader|NewsBlur|Feedfetcher|Feedbin|FreshRSS|\brss/i', 'Feed reader', self::BOT_FEED],
+        ['/HeadlessChrome|PhantomJS|puppeteer|playwright|selenium/i', 'Headless browser', self::BOT_SCRIPT],
+        ['/curl\//i', 'curl', self::BOT_SCRIPT],
+        ['/Wget\//i', 'Wget', self::BOT_SCRIPT],
+        ['/python-requests|python-urllib|aiohttp|httpx|Scrapy/i', 'Python', self::BOT_SCRIPT],
+        ['/Go-http-client/i', 'Go', self::BOT_SCRIPT],
+        ['/axios|node-fetch|undici|Node\.js/i', 'Node.js', self::BOT_SCRIPT],
+        ['/GuzzleHttp/i', 'PHP', self::BOT_SCRIPT],
+        ['/Java\/|Apache-HttpClient|okhttp/i', 'Java', self::BOT_SCRIPT],
+        ['/libwww-perl/i', 'Perl', self::BOT_SCRIPT],
+    ];
+
+    /**
+     * Browsers as [name, pattern with the version in the first group]. Order matters.
+     */
+    protected static $_aBrowsers = [
+        ['Edge', '/\bEdg(?:e|A|iOS)?\/([\d.]+)/'],
+        ['Opera', '/\b(?:OPR|OPiOS|OPT)\/([\d.]+)/'],
+        ['Opera', '/\bOpera(?:.*Version)?[\/ ]([\d.]+)/'],
+        ['Samsung Internet', '/\bSamsungBrowser\/([\d.]+)/'],
+        ['Yandex Browser', '/\bYaBrowser\/([\d.]+)/'],
+        ['UC Browser', '/\bUC ?Browser\/([\d.]+)/'],
+        ['Vivaldi', '/\bVivaldi\/([\d.]+)/'],
+        ['DuckDuckGo', '/\b(?:DuckDuckGo|Ddg)\/([\d.]+)/'],
+        ['Huawei Browser', '/\bHuaweiBrowser\/([\d.]+)/'],
+        ['MIUI Browser', '/\bMiuiBrowser\/([\d.]+)/'],
+        ['Facebook', '/\bFBAV\/([\d.]+)/'],
+        ['Instagram', '/\bInstagram ([\d.]+)/'],
+        ['TikTok', '/\b(?:musical_ly|BytedanceWebview|TikTok)[_\/ ]([\d.]+)/'],
+        ['Firefox', '/\b(?:Firefox|FxiOS)\/([\d.]+)/'],
+        ['Chrome', '/\bCriOS\/([\d.]+)/'],
+        ['Android WebView', '/; wv\).*Chrome\/([\d.]+)/'],
+        ['Chrome', '/\b(?:Chrome|Chromium)\/([\d.]+)/'],
+        ['Safari', '/\bVersion\/([\d.]+).*Safari\//'],
+        ['iOS WebView', '/\b(?:iPhone|iPad|iPod).*AppleWebKit\/[\d.]+()/'],
+        ['Internet Explorer', '/\b(?:MSIE |Trident\/.*rv:)([\d.]+)/'],
+    ];
+
+    /**
+     * Operating systems as [name, pattern with an optional version in the first group]. Order matters.
+     */
+    protected static $_aSystems = [
+        ['iPadOS', '/\biPad.*?OS ([\d_]+)/'],
+        ['iOS', '/\b(?:iPhone|iPod).*?OS ([\d_]+)/'],
+        ['iOS', '/\b(?:iPhone|iPad|iPod)\b()/'],
+        ['HarmonyOS', '/\bHarmonyOS ?([\d.]*)/'],
+        ['Android', '/\bAndroid[ \/]?([\d.]*)/'],
+        ['Windows Phone', '/\bWindows Phone(?: OS)? ([\d.]+)/'],
+        ['Windows', '/\bWindows NT ([\d.]+)/'],
+        ['Windows', '/\bWindows\b()/'],
+        ['Chrome OS', '/\bCrOS \S+ ([\d.]+)/'],
+        ['macOS', '/\bMac OS X ([\d_.]+)/'],
+        ['macOS', '/\bMacintosh\b()/'],
+        ['KaiOS', '/\bKAIOS\/([\d.]+)/i'],
+        ['Tizen', '/\bTizen ?([\d.]*)/'],
+        ['webOS', '/\b(?:web0s|webos)\b()/i'],
+        ['PlayStation', '/\bPlayStation ?([\d.]*)/'],
+        ['Xbox', '/\bXbox\b()/'],
+        ['Ubuntu', '/\bUbuntu\b()/'],
+        ['Linux', '/\bLinux\b()/'],
+        ['FreeBSD', '/\bFreeBSD\b()/'],
+    ];
+
+    /**
+     * Windows NT kernel versions; 10.0 covers both Windows 10 and 11.
+     */
+    protected static $_aWindowsVersions = [
+        '10.0' => '10',
+        '6.3' => '8.1',
+        '6.2' => '8',
+        '6.1' => '7',
+        '6.0' => 'Vista',
+        '5.1' => 'XP',
+    ];
+
+    /**
+     * Parse a user agent string.
+     *
+     * @param string $sUserAgent the User-Agent header
+     * @return array with keys browser, browser_version, os, os_version, device and bot
+     */
+    public static function parse($sUserAgent)
+    {
+        $sUserAgent = trim((string)$sUserAgent);
+
+        $aResult = [
+            'browser' => '',
+            'browser_version' => '',
+            'os' => '',
+            'os_version' => '',
+            'device' => self::DEVICE_DESKTOP,
+            'bot' => false,
+        ];
+
+        if($sUserAgent === '' || self::isBot($sUserAgent)) {
+            $aResult['device'] = self::DEVICE_BOT;
+            $aResult['bot'] = true;
+            return $aResult;
+        }
+
+        foreach(self::$_aBrowsers as list($sName, $sPattern))
+            if(preg_match($sPattern, $sUserAgent, $aMatch)) {
+                $aResult['browser'] = $sName;
+                $aResult['browser_version'] = self::_majorMinor($aMatch[1]);
+                break;
+            }
+
+        foreach(self::$_aSystems as list($sName, $sPattern))
+            if(preg_match($sPattern, $sUserAgent, $aMatch)) {
+                $aResult['os'] = $sName;
+                $aResult['os_version'] = self::_systemVersion($sName, isset($aMatch[1]) ? $aMatch[1] : '');
+                break;
+            }
+
+        $aResult['device'] = self::_device($sUserAgent, $aResult['os']);
+
+        return $aResult;
+    }
+
+    /**
+     * Whether a user agent belongs to a crawler, a link preview, a monitor or an HTTP library.
+     */
+    public static function isBot($sUserAgent)
+    {
+        $sUserAgent = trim((string)$sUserAgent);
+        if($sUserAgent === '')
+            return true;
+
+        return (bool)preg_match(self::$_sBotsPattern, $sUserAgent);
+    }
+
+    /**
+     * Name and category of a bot's user agent, or null for a browser.
+     *
+     * @return array|null ['name' => ..., 'category' => ...]
+     */
+    public static function getBot($sUserAgent)
+    {
+        $sUserAgent = trim((string)$sUserAgent);
+        if($sUserAgent === '')
+            return ['name' => 'Unknown', 'category' => self::BOT_SCRIPT];
+
+        foreach(self::$_aBots as list($sPattern, $sName, $sCategory))
+            if(preg_match($sPattern, $sUserAgent))
+                return ['name' => $sName, 'category' => $sCategory];
+
+        if(!self::isBot($sUserAgent))
+            return null;
+
+        // an unlisted bot: its own name when the user agent carries one ("ExampleBot/1.0")
+        if(preg_match('/([A-Za-z][A-Za-z0-9._-]{1,40}(?:bot|crawler|spider))\b/i', $sUserAgent, $aMatch))
+            return ['name' => get_mb_substr($aMatch[1], 0, 64), 'category' => self::BOT_OTHER];
+
+        return ['name' => 'Other', 'category' => self::BOT_OTHER];
+    }
+
+    protected static function _device($sUserAgent, $sOs)
+    {
+        if(preg_match('/\b(?:SmartTV|SMART-TV|Smart TV|AppleTV|Apple TV|GoogleTV|Android TV|BRAVIA|HbbTV|NetCast|Web0S|webOS TV|Tizen.*TV|CrKey|AFT[A-Z]+|Roku)\b/i', $sUserAgent))
+            return self::DEVICE_TV;
+
+        if(preg_match('/\b(?:PlayStation|Xbox|Nintendo)\b/i', $sUserAgent))
+            return self::DEVICE_CONSOLE;
+
+        if(preg_match('/\b(?:iPad|Tablet|Kindle|Silk|PlayBook)\b/i', $sUserAgent) || $sOs === 'iPadOS')
+            return self::DEVICE_TABLET;
+
+        if($sOs === 'Android' && stripos($sUserAgent, 'Mobile') === false)
+            return self::DEVICE_TABLET;
+
+        if(preg_match('/Mobi|iPhone|iPod|Windows Phone|Opera Mini|BlackBerry|BB10|KAIOS/i', $sUserAgent) || in_array($sOs, ['iOS', 'Android', 'HarmonyOS'], true))
+            return self::DEVICE_MOBILE;
+
+        return self::DEVICE_DESKTOP;
+    }
+
+    protected static function _systemVersion($sOs, $sVersion)
+    {
+        $sVersion = str_replace('_', '.', $sVersion);
+        if($sOs === 'Windows')
+            return isset(self::$_aWindowsVersions[$sVersion]) ? self::$_aWindowsVersions[$sVersion] : '';
+
+        // Safari has reported macOS as 10.15.7 since Big Sur, so the minor version means nothing.
+        if($sOs === 'macOS' && bx_mb_strpos($sVersion, '10.15') === 0)
+            return '';
+
+        return self::_majorMinor($sVersion);
+    }
+
+    protected static function _majorMinor($sVersion)
+    {
+        $aParts = explode('.', (string)$sVersion);
+        if($aParts[0] === '')
+            return '';
+
+        return isset($aParts[1]) && $aParts[1] !== '' && $aParts[1] !== '0' ? $aParts[0] . '.' . $aParts[1] : $aParts[0];
+    }
+}
+
+/** @} */

+ 176 - 0
modules/boonex/analytics/updates/15.0.1_15.0.2/source/classes/BxAnalyticsVitals.php

@@ -0,0 +1,176 @@
+<?php defined('BX_DOL') or die('hack attempt');
+/**
+ * Copyright (c) UNA, Inc - https://una.io
+ * MIT License - https://opensource.org/licenses/MIT
+ *
+ * @defgroup    Analytics Analytics
+ * @ingroup     UnaModules
+ *
+ * @{
+ */
+
+/**
+ * Web vitals and app timings: which metrics there are, what a client may send, and the 75th
+ * percentile from a log-scale histogram the database builds with GROUP BY (no window
+ * functions, so it runs on every MySQL and MariaDB UNA supports).
+ *
+ * Timings are milliseconds. CLS is a unitless score; it is stored in thousandths.
+ */
+class BxAnalyticsVitals
+{
+    /**
+     * Every metric, in the order reports show them. LCP, INP, CLS, FCP and TTFB are web vitals
+     * of a page load; screen_load is a navigation inside a running app or a soft navigation
+     * on the web; app_start is a native app's cold start.
+     */
+    const METRICS = ['lcp', 'inp', 'cls', 'fcp', 'ttfb', 'screen_load', 'app_start'];
+
+    const WEB_METRICS = ['lcp', 'inp', 'cls', 'fcp', 'ttfb', 'screen_load'];
+    const APP_METRICS = ['screen_load', 'app_start'];
+
+    /**
+     * How the page was reached: the Navigation Timing type, `soft` for a navigation inside a
+     * single-page app or an htmx swap, `restore` for the back-forward cache.
+     */
+    const NAVS = ['navigate', 'reload', 'back_forward', 'prerender', 'soft', 'restore'];
+
+    const MAX_MS = 120000;
+    const MAX_CLS = 10;
+
+    /**
+     * Histogram buckets per e-fold: 20 makes a bucket 5% wide, so the interpolated
+     * percentile is within a bucket of the exact one, and usually within 2.5%.
+     */
+    const BUCKETS = 20;
+
+    /**
+     * A sent value as stored, or null when it is not a number in range: whole milliseconds
+     * up to 2 minutes, CLS from 0 to 10 in thousandths.
+     */
+    public static function getValue($sMetric, $mixedValue)
+    {
+        if(!in_array($sMetric, self::METRICS, true) || is_bool($mixedValue) || !is_numeric($mixedValue))
+            return null;
+
+        $fValue = (float)$mixedValue;
+        if(!is_finite($fValue) || $fValue < 0)
+            return null;
+
+        if($sMetric == 'cls')
+            return $fValue <= self::MAX_CLS ? (int)round($fValue * 1000) : null;
+
+        return $fValue <= self::MAX_MS ? (int)round($fValue) : null;
+    }
+
+    /**
+     * A stored value in the metric's own unit: CLS back from thousandths.
+     */
+    public static function toDisplay($sMetric, $mixedValue)
+    {
+        if($mixedValue === null)
+            return null;
+
+        return $sMetric == 'cls' ? round($mixedValue / 1000, 3) : (int)round($mixedValue);
+    }
+
+    /**
+     * The format a metric's values are shown in: ms or cls.
+     */
+    public static function getUnit($sMetric)
+    {
+        return $sMetric == 'cls' ? 'cls' : 'ms';
+    }
+
+    /**
+     * The histogram bucket of a stored value: -1 for exactly 0, else floor(ln(v) * 20). The
+     * SQL in BxAnalyticsDb::_getVitalsBucketSql is the same expression.
+     */
+    public static function getBucket($iValue)
+    {
+        return $iValue <= 0 ? -1 : (int)floor(log($iValue) * self::BUCKETS);
+    }
+
+    /**
+     * A percentile from histogram counts, interpolated geometrically inside the bucket it
+     * falls in (at the middle of the ranked sample's share of the bucket); null with fewer
+     * samples than `$iMinSamples`.
+     *
+     * A bucket can't tell where its values sit, so with the exact counts on each side of the
+     * thresholds the estimate is kept on the side the exact percentile is on: a page whose
+     * values are all 2500 ms has an LCP p75 of 2500, which is good, not 2534.
+     *
+     * @param array $aHistogram bucket => count
+     * @param float $fPercentile 0.75 for p75
+     * @param array $aSides counts of values up to the good threshold, between the two, and
+     *      above the poor threshold, or [] when unknown
+     * @param array $aThresholds [good up to, poor above], with `$aSides`
+     */
+    public static function getPercentile($aHistogram, $fPercentile = 0.75, $iMinSamples = 20, $aSides = [], $aThresholds = [])
+    {
+        $mixedValue = self::_getPercentile($aHistogram, $fPercentile, $iMinSamples);
+        if($mixedValue === null || count($aSides) != 3 || count($aThresholds) != 2)
+            return $mixedValue;
+
+        $fRank = $fPercentile * array_sum($aSides);
+        if($aSides[0] >= $fRank)
+            return min($mixedValue, (float)$aThresholds[0]);
+
+        if($aSides[0] + $aSides[1] >= $fRank)
+            return min(max($mixedValue, (float)$aThresholds[0] + 1), (float)$aThresholds[1]);
+
+        return max($mixedValue, (float)$aThresholds[1] + 1);
+    }
+
+    protected static function _getPercentile($aHistogram, $fPercentile, $iMinSamples)
+    {
+        $iTotal = 0;
+        foreach($aHistogram as $iCount)
+            $iTotal += (int)$iCount;
+        if(!$iTotal || $iTotal < $iMinSamples)
+            return null;
+
+        ksort($aHistogram, SORT_NUMERIC);
+
+        $fRank = $fPercentile * $iTotal;
+        $iBefore = 0;
+        foreach($aHistogram as $iBucket => $iCount) {
+            $iCount = (int)$iCount;
+            if(!$iCount)
+                continue;
+
+            if($iBefore + $iCount >= $fRank) {
+                if((int)$iBucket < 0)
+                    return 0.0;
+
+                $fShare = min(1, max(0, ($fRank - $iBefore - 0.5) / $iCount));
+                return exp(((int)$iBucket + $fShare) / self::BUCKETS);
+            }
+
+            $iBefore += $iCount;
+        }
+
+        // rounding left the rank past the last bucket
+        end($aHistogram);
+        $iLast = (int)key($aHistogram);
+
+        return $iLast < 0 ? 0.0 : exp(($iLast + 1) / self::BUCKETS);
+    }
+
+    /**
+     * good, needs_improvement or poor for a stored value.
+     *
+     * @param array $aThresholds [good up to, poor above]
+     */
+    public static function getRating($mixedValue, $aThresholds)
+    {
+        if($mixedValue === null)
+            return '';
+
+        if($mixedValue <= $aThresholds[0])
+            return 'good';
+
+        return $mixedValue > $aThresholds[1] ? 'poor' : 'needs_improvement';
+    }
+}
+
+/** @} */

+ 77 - 0
modules/boonex/analytics/updates/15.0.1_15.0.2/source/install/config.php

@@ -0,0 +1,77 @@
+<?php
+/**
+ * Copyright (c) UNA, Inc - https://una.io
+ * MIT License - https://opensource.org/licenses/MIT
+ *
+ * @defgroup    Analytics Analytics
+ * @ingroup     UnaModules
+ *
+ * @{
+ */
+
+$aConfig = array(
+
+    /**
+     * Main Section.
+     */
+    'type' => BX_DOL_MODULE_TYPE_MODULE,
+    'name' => 'bx_analytics',
+    'title' => 'Analytics',
+    'note' => 'Analytics, statistics, site metrics',
+    'version' => '15.0.2',
+    'vendor' => 'UNA INC',
+    'help_url' => 'http://feed.una.io/?section={module_name}',
+
+    'compatible_with' => array(
+        '15.0.0-RC3'
+    ),
+
+    /**
+     * 'home_dir' and 'home_uri' - should be unique. Don't use spaces in 'home_uri' and the other special chars.
+     */
+    'home_dir' => 'boonex/analytics/',
+    'home_uri' => 'analytics',
+
+    'db_prefix' => 'bx_analytics_',
+    'class_prefix' => 'BxAnalytics',
+
+    /**
+     * Category for language keys.
+     */
+    'language_category' => 'Analytics',
+
+    /**
+     * Installation/Uninstallation Section.
+     */
+    'install' => array(
+        'execute_sql' => 1,
+        'update_languages' => 1,
+        'clear_db_cache' => 1,
+    ),
+    'uninstall' => array (
+        'execute_sql' => 1,
+        'update_languages' => 1,
+        'clear_db_cache' => 1,
+    ),
+    'enable' => array(
+        'execute_sql' => 1,
+        'clear_db_cache' => 1,
+    ),
+    'enable_success' => array(
+        'clear_db_cache' => 1,
+    ),
+    'disable' => array (
+        'execute_sql' => 1,
+        'clear_db_cache' => 1,
+    ),
+    'disable_failed' => array (
+        'clear_db_cache' => 1,
+    ),
+
+    /**
+     * Dependencies Section
+     */
+    'dependencies' => array(),
+);
+
+/** @} */

+ 33 - 0
modules/boonex/analytics/updates/15.0.1_15.0.2/source/install/installer.php

@@ -0,0 +1,33 @@
+<?php defined('BX_DOL') or die('hack attempt');
+/**
+ * Copyright (c) UNA, Inc - https://una.io
+ * MIT License - https://opensource.org/licenses/MIT
+ *
+ * @defgroup    Analytics Analytics
+ * @ingroup     UnaModules
+ *
+ * @{
+ */
+
+class BxAnalyticsInstaller extends BxBaseModTextInstaller
+{
+    function __construct($aConfig)
+    {
+        parent::__construct($aConfig);
+    }
+
+    /**
+     * Subscribe to the actions of the modules installed now; later ones are added when they
+     * are enabled (the system/enable alert).
+     */
+    protected function _onEnableAfter()
+    {
+        $iResult = parent::_onEnableAfter();
+        if($iResult == BX_DOL_STUDIO_INSTALLER_SUCCESS)
+            BxDolService::call($this->_aConfig['name'], 'sync_actions');
+
+        return $iResult;
+    }
+}
+
+/** @} */

+ 725 - 0
modules/boonex/analytics/updates/15.0.1_15.0.2/source/install/langs/en.xml

@@ -0,0 +1,725 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources name="en" flag="gb" title="English">
+	<string name="_bx_analytics"><![CDATA[Analytics]]></string>
+	<string name="_bx_analytics_adm_stg_cpt_type"><![CDATA[Analytics]]></string>
+	<string name="_bx_analytics_adm_stg_cpt_vitals_retention_days"><![CDATA[Keep performance data, days]]></string>
+	<string name="_bx_analytics_adm_stg_cpt_vitals_sample"><![CDATA[Page loads measured, %]]></string>
+	<string name="_bx_analytics_adm_stg_cpt_category_content"><![CDATA[Content reports]]></string>
+	<string name="_bx_analytics_adm_stg_cpt_category_general"><![CDATA[Analytics]]></string>
+	<string name="_bx_analytics_adm_stg_cpt_category_health"><![CDATA[Health]]></string>
+	<string name="_bx_analytics_adm_stg_cpt_category_privacy"><![CDATA[Privacy and data]]></string>
+	<string name="_bx_analytics_adm_stg_cpt_category_system"><![CDATA[System]]></string>
+	<string name="_bx_analytics_adm_stg_cpt_category_tracking"><![CDATA[Tracking]]></string>
+	<string name="_bx_analytics_adm_stg_inf_errors_ignore"><![CDATA[Error messages not to record, one per line. * stands for any characters, and case does not matter.]]></string>
+	<string name="_bx_analytics_adm_stg_inf_exclude_ips"><![CDATA[Addresses or CIDR ranges, separated by commas or spaces.]]></string>
+	<string name="_bx_analytics_adm_stg_inf_exclude_levels"><![CDATA[For example, administrators, so your own visits don't count.]]></string>
+	<string name="_bx_analytics_adm_stg_inf_exclude_paths"><![CDATA[Path prefixes, or patterns with * (for example /page/*-admin), separated by commas or spaces.]]></string>
+	<string name="_bx_analytics_adm_stg_inf_geo_source"><![CDATA[The country, region and city that Cloudflare, Vercel or CloudFront add to each request. A CDN sets its own headers and passes the others through, so a visitor could send another CDN's: choose the CDN your site and NEO run behind.]]></string>
+	<string name="_bx_analytics_adm_stg_inf_identity"><![CDATA[Cookieless: a hash of the address and browser that changes daily, with nothing stored on the device. A guest who comes back another day counts as a new visitor. Persistent: a random id kept in the browser or app, which allows guest retention but may need consent.]]></string>
+	<string name="_bx_analytics_adm_stg_inf_ignore_params"><![CDATA[Left out of stored page paths, so one page is not counted as many. Separate with commas; * matches any characters. Campaign tags and ad click ids are always left out.]]></string>
+	<string name="_bx_analytics_adm_stg_inf_limit_hits"><![CDATA[How many pageviews, events, errors and timings the trackers may send from one address in a minute (an IPv6 address counts by its /64 network). More are dropped until the next minute, and the tracker keeps sending. Raise it when many visitors share one address, as in an office or a school. 0: no limit. Site actions and payments are never limited.]]></string>
+	<string name="_bx_analytics_adm_stg_inf_limit_visits"><![CDATA[How many visits may start from one address in an hour (an IPv6 address counts by its /64 network). Hits that would start another visit are dropped, so a script cannot fill the reports with visits. Visits already running go on. 0: no limit.]]></string>
+	<string name="_bx_analytics_adm_stg_inf_link_members"><![CDATA[Record the profile of signed-in members, for member sessions and retention. Deleting a profile unlinks its visits.]]></string>
+	<string name="_bx_analytics_adm_stg_inf_online_window"><![CDATA[Visitors active within this time count as online.]]></string>
+	<string name="_bx_analytics_adm_stg_inf_platforms"><![CDATA[Clients whose visits are recorded.]]></string>
+	<string name="_bx_analytics_adm_stg_inf_respect_dnt"><![CDATA[Skip browsers that send Do Not Track or Global Privacy Control.]]></string>
+	<string name="_bx_analytics_adm_stg_inf_retention_days"><![CDATA[Older visits and events are deleted every hour.]]></string>
+	<string name="_bx_analytics_adm_stg_inf_session_timeout"><![CDATA[A visit ends after this long without activity.]]></string>
+	<string name="_bx_analytics_adm_stg_inf_track_actions"><![CDATA[Sign-ups, logins, new profiles and content, comments, reactions and connections become events, with the client they came from.]]></string>
+	<string name="_bx_analytics_adm_stg_inf_track_bots"><![CDATA[Search engines, AI crawlers, AI assistants fetching pages, link previews, monitors and scripts. They are counted per day and page, apart from visitors.]]></string>
+	<string name="_bx_analytics_adm_stg_inf_track_errors"><![CDATA[JavaScript errors, unhandled promise rejections, failed requests and app crashes from every client. Query strings, tokens and emails are removed before anything is stored.]]></string>
+	<string name="_bx_analytics_adm_stg_inf_track_vitals"><![CDATA[Web vitals of page loads (LCP, INP, CLS, FCP, TTFB), and screen loads and starts in the apps.]]></string>
+	<string name="_bx_analytics_adm_stg_inf_tracking"><![CDATA[Off stops collecting. Reports keep the data already collected.]]></string>
+	<string name="_bx_analytics_adm_stg_inf_trusted_proxies"><![CDATA[Addresses or CIDR ranges of proxies and load balancers whose X-Forwarded-For header holds the visitor's address. Private network addresses and the NEO server are always trusted.]]></string>
+	<string name="_bx_analytics_adm_stg_inf_vitals_retention_days"><![CDATA[Measurements older than this are deleted. It can be shorter than the retention of other data, never longer.]]></string>
+	<string name="_bx_analytics_adm_stg_inf_vitals_sample"><![CDATA[From 0 to 100. Every measured page view adds a row, so a busy site can measure a part of them.]]></string>
+	<string name="_bx_analytics_bot_ai_agent"><![CDATA[AI assistant]]></string>
+	<string name="_bx_analytics_bot_ai_crawler"><![CDATA[AI crawler]]></string>
+	<string name="_bx_analytics_bot_feed"><![CDATA[Feed reader]]></string>
+	<string name="_bx_analytics_bot_monitor"><![CDATA[Monitoring]]></string>
+	<string name="_bx_analytics_bot_other"><![CDATA[Other bot]]></string>
+	<string name="_bx_analytics_bot_script"><![CDATA[Script]]></string>
+	<string name="_bx_analytics_bot_search"><![CDATA[Search engine]]></string>
+	<string name="_bx_analytics_bot_seo"><![CDATA[SEO tool]]></string>
+	<string name="_bx_analytics_bot_social"><![CDATA[Link preview]]></string>
+	<string name="_bx_analytics_channel_affiliate"><![CDATA[Affiliates]]></string>
+	<string name="_bx_analytics_channel_ai"><![CDATA[AI assistants]]></string>
+	<string name="_bx_analytics_channel_campaign"><![CDATA[Other campaigns]]></string>
+	<string name="_bx_analytics_channel_direct"><![CDATA[Direct]]></string>
+	<string name="_bx_analytics_channel_display"><![CDATA[Display ads]]></string>
+	<string name="_bx_analytics_channel_email"><![CDATA[Email]]></string>
+	<string name="_bx_analytics_channel_internal"><![CDATA[Internal]]></string>
+	<string name="_bx_analytics_channel_paid_search"><![CDATA[Paid search]]></string>
+	<string name="_bx_analytics_channel_paid_social"><![CDATA[Paid social]]></string>
+	<string name="_bx_analytics_channel_referral"><![CDATA[Referral]]></string>
+	<string name="_bx_analytics_channel_search"><![CDATA[Organic search]]></string>
+	<string name="_bx_analytics_channel_social"><![CDATA[Organic social]]></string>
+	<string name="_bx_analytics_device_bot"><![CDATA[Bot]]></string>
+	<string name="_bx_analytics_device_console"><![CDATA[Game console]]></string>
+	<string name="_bx_analytics_device_desktop"><![CDATA[Desktop]]></string>
+	<string name="_bx_analytics_device_mobile"><![CDATA[Mobile]]></string>
+	<string name="_bx_analytics_device_tablet"><![CDATA[Tablet]]></string>
+	<string name="_bx_analytics_device_tv"><![CDATA[TV]]></string>
+	<string name="_bx_analytics_dim_app"><![CDATA[App]]></string>
+	<string name="_bx_analytics_dim_app_version"><![CDATA[App version]]></string>
+	<string name="_bx_analytics_dim_browser"><![CDATA[Browser]]></string>
+	<string name="_bx_analytics_dim_channel"><![CDATA[Channel]]></string>
+	<string name="_bx_analytics_dim_city"><![CDATA[City]]></string>
+	<string name="_bx_analytics_dim_country"><![CDATA[Country]]></string>
+	<string name="_bx_analytics_dim_device"><![CDATA[Device]]></string>
+	<string name="_bx_analytics_dim_entry_path"><![CDATA[Entry page]]></string>
+	<string name="_bx_analytics_dim_exit_path"><![CDATA[Exit page]]></string>
+	<string name="_bx_analytics_dim_hostname"><![CDATA[Hostname]]></string>
+	<string name="_bx_analytics_dim_language"><![CDATA[Language]]></string>
+	<string name="_bx_analytics_dim_os"><![CDATA[Operating system]]></string>
+	<string name="_bx_analytics_dim_path"><![CDATA[Page]]></string>
+	<string name="_bx_analytics_dim_platform"><![CDATA[Platform]]></string>
+	<string name="_bx_analytics_dim_referrer"><![CDATA[Referrer URL]]></string>
+	<string name="_bx_analytics_dim_referrer_host"><![CDATA[Referrer]]></string>
+	<string name="_bx_analytics_dim_region"><![CDATA[Region]]></string>
+	<string name="_bx_analytics_dim_screen"><![CDATA[Screen size]]></string>
+	<string name="_bx_analytics_dim_title"><![CDATA[Page title]]></string>
+	<string name="_bx_analytics_dim_utm_campaign"><![CDATA[UTM campaign]]></string>
+	<string name="_bx_analytics_dim_utm_content"><![CDATA[UTM content]]></string>
+	<string name="_bx_analytics_dim_utm_medium"><![CDATA[UTM medium]]></string>
+	<string name="_bx_analytics_dim_utm_source"><![CDATA[UTM source]]></string>
+	<string name="_bx_analytics_dim_utm_term"><![CDATA[UTM term]]></string>
+	<string name="_bx_analytics_error_kind_http"><![CDATA[Server error]]></string>
+	<string name="_bx_analytics_error_kind_js"><![CDATA[JavaScript error]]></string>
+	<string name="_bx_analytics_error_kind_native"><![CDATA[App error]]></string>
+	<string name="_bx_analytics_error_kind_overflow"><![CDATA[Other]]></string>
+	<string name="_bx_analytics_error_kind_promise"><![CDATA[Unhandled rejection]]></string>
+	<string name="_bx_analytics_error_kind_react"><![CDATA[Render error]]></string>
+	<string name="_bx_analytics_error_kind_resource"><![CDATA[Failed to load]]></string>
+	<string name="_bx_analytics_error_status_ignored"><![CDATA[Ignored]]></string>
+	<string name="_bx_analytics_error_status_open"><![CDATA[Open]]></string>
+	<string name="_bx_analytics_error_status_resolved"><![CDATA[Resolved]]></string>
+	<string name="_bx_analytics_event_type_event"><![CDATA[Custom event]]></string>
+	<string name="_bx_analytics_event_type_pageview"><![CDATA[Pageview]]></string>
+	<string name="_bx_analytics_event_type_una"><![CDATA[Site action]]></string>
+	<string name="_bx_analytics_geo_cloudflare"><![CDATA[Cloudflare headers only]]></string>
+	<string name="_bx_analytics_geo_cloudfront"><![CDATA[CloudFront headers only]]></string>
+	<string name="_bx_analytics_geo_headers"><![CDATA[Any CDN's headers, the first one found]]></string>
+	<string name="_bx_analytics_geo_none"><![CDATA[Off]]></string>
+	<string name="_bx_analytics_geo_vercel"><![CDATA[Vercel headers only]]></string>
+	<string name="_bx_analytics_group_bots"><![CDATA[Bot categories]]></string>
+	<string name="_bx_analytics_group_devices"><![CDATA[Devices]]></string>
+	<string name="_bx_analytics_group_errors"><![CDATA[Errors by status]]></string>
+	<string name="_bx_analytics_group_events"><![CDATA[Event types]]></string>
+	<string name="_bx_analytics_group_locations"><![CDATA[Locations]]></string>
+	<string name="_bx_analytics_group_map"><![CDATA[Map]]></string>
+	<string name="_bx_analytics_group_pages"><![CDATA[Pages]]></string>
+	<string name="_bx_analytics_group_performance"><![CDATA[Group pages by]]></string>
+	<string name="_bx_analytics_group_platforms"><![CDATA[Platforms]]></string>
+	<string name="_bx_analytics_group_retention"><![CDATA[Who to count]]></string>
+	<string name="_bx_analytics_group_revenue"><![CDATA[Revenue by]]></string>
+	<string name="_bx_analytics_group_sources"><![CDATA[Sources]]></string>
+	<string name="_bx_analytics_identity_cookieless"><![CDATA[Cookieless (recommended)]]></string>
+	<string name="_bx_analytics_identity_persistent"><![CDATA[Persistent id on the device]]></string>
+	<string name="_bx_analytics_page_block_title_bots"><![CDATA[Bots and agents]]></string>
+	<string name="_bx_analytics_page_block_title_canvas"><![CDATA[Analytics]]></string>
+	<string name="_bx_analytics_page_block_title_credits"><![CDATA[Credits]]></string>
+	<string name="_bx_analytics_page_block_title_errors"><![CDATA[Errors]]></string>
+	<string name="_bx_analytics_page_block_title_events"><![CDATA[Events]]></string>
+	<string name="_bx_analytics_page_block_title_filters"><![CDATA[Filters]]></string>
+	<string name="_bx_analytics_page_block_title_funnels"><![CDATA[Funnels]]></string>
+	<string name="_bx_analytics_page_block_title_goals"><![CDATA[Goals]]></string>
+	<string name="_bx_analytics_page_block_title_journeys"><![CDATA[Journeys]]></string>
+	<string name="_bx_analytics_page_block_title_map"><![CDATA[Visitors by location]]></string>
+	<string name="_bx_analytics_page_block_title_overview"><![CDATA[Overview]]></string>
+	<string name="_bx_analytics_page_block_title_performance"><![CDATA[Performance]]></string>
+	<string name="_bx_analytics_page_block_title_query"><![CDATA[Query]]></string>
+	<string name="_bx_analytics_page_block_title_realtime"><![CDATA[Right now]]></string>
+	<string name="_bx_analytics_page_block_title_retention"><![CDATA[Retention]]></string>
+	<string name="_bx_analytics_page_block_title_revenue"><![CDATA[Revenue]]></string>
+	<string name="_bx_analytics_page_block_title_sales"><![CDATA[Sales and sellers]]></string>
+	<string name="_bx_analytics_page_block_title_sessions"><![CDATA[Sessions]]></string>
+	<string name="_bx_analytics_page_block_title_std_dash_traffic"><![CDATA[Traffic]]></string>
+	<string name="_bx_analytics_page_block_title_subscriptions"><![CDATA[Subscriptions]]></string>
+	<string name="_bx_analytics_page_block_title_system_bots"><![CDATA[Analytics: bots and agents]]></string>
+	<string name="_bx_analytics_page_block_title_system_canvas"><![CDATA[Analytics]]></string>
+	<string name="_bx_analytics_page_block_title_system_credits"><![CDATA[Analytics: credits]]></string>
+	<string name="_bx_analytics_page_block_title_system_errors"><![CDATA[Analytics: errors]]></string>
+	<string name="_bx_analytics_page_block_title_system_events"><![CDATA[Analytics: events]]></string>
+	<string name="_bx_analytics_page_block_title_system_filters"><![CDATA[Analytics: filters]]></string>
+	<string name="_bx_analytics_page_block_title_system_funnels"><![CDATA[Analytics: funnels]]></string>
+	<string name="_bx_analytics_page_block_title_system_goals"><![CDATA[Analytics: goals]]></string>
+	<string name="_bx_analytics_page_block_title_system_journeys"><![CDATA[Analytics: journeys]]></string>
+	<string name="_bx_analytics_page_block_title_system_map"><![CDATA[Analytics: map]]></string>
+	<string name="_bx_analytics_page_block_title_system_overview"><![CDATA[Analytics: overview]]></string>
+	<string name="_bx_analytics_page_block_title_system_performance"><![CDATA[Analytics: performance]]></string>
+	<string name="_bx_analytics_page_block_title_system_query"><![CDATA[Analytics: query]]></string>
+	<string name="_bx_analytics_page_block_title_system_realtime"><![CDATA[Analytics: realtime]]></string>
+	<string name="_bx_analytics_page_block_title_system_retention"><![CDATA[Analytics: retention]]></string>
+	<string name="_bx_analytics_page_block_title_system_revenue"><![CDATA[Analytics: revenue]]></string>
+	<string name="_bx_analytics_page_block_title_system_sales"><![CDATA[Analytics: sales and sellers]]></string>
+	<string name="_bx_analytics_page_block_title_system_sessions"><![CDATA[Analytics: sessions]]></string>
+	<string name="_bx_analytics_page_block_title_system_subscriptions"><![CDATA[Analytics: subscriptions]]></string>
+	<string name="_bx_analytics_page_block_title_system_table_pages"><![CDATA[Analytics: pages table]]></string>
+	<string name="_bx_analytics_page_block_title_system_table_sources"><![CDATA[Analytics: sources table]]></string>
+	<string name="_bx_analytics_page_block_title_system_top_devices"><![CDATA[Analytics: devices]]></string>
+	<string name="_bx_analytics_page_block_title_system_top_locations"><![CDATA[Analytics: locations]]></string>
+	<string name="_bx_analytics_page_block_title_system_top_pages"><![CDATA[Analytics: pages]]></string>
+	<string name="_bx_analytics_page_block_title_system_top_platforms"><![CDATA[Analytics: platforms]]></string>
+	<string name="_bx_analytics_page_block_title_system_top_sources"><![CDATA[Analytics: sources]]></string>
+	<string name="_bx_analytics_page_block_title_system_users"><![CDATA[Analytics: users]]></string>
+	<string name="_bx_analytics_page_block_title_table_pages"><![CDATA[Pages]]></string>
+	<string name="_bx_analytics_page_block_title_table_sources"><![CDATA[Sources]]></string>
+	<string name="_bx_analytics_page_block_title_top_devices"><![CDATA[Devices]]></string>
+	<string name="_bx_analytics_page_block_title_top_locations"><![CDATA[Locations]]></string>
+	<string name="_bx_analytics_page_block_title_top_pages"><![CDATA[Pages]]></string>
+	<string name="_bx_analytics_page_block_title_top_platforms"><![CDATA[Platforms]]></string>
+	<string name="_bx_analytics_page_block_title_top_sources"><![CDATA[Sources]]></string>
+	<string name="_bx_analytics_page_block_title_users"><![CDATA[Users]]></string>
+	<string name="_bx_analytics_adm_stg_cpt_modules_enabled"><![CDATA[Enabled for modules]]></string>
+	<string name="_bx_analytics_adm_stg_cpt_online_window"><![CDATA[Online window, minutes]]></string>
+	<string name="_bx_analytics_adm_stg_cpt_platforms"><![CDATA[Collect from]]></string>
+	<string name="_bx_analytics_adm_stg_cpt_respect_dnt"><![CDATA[Respect Do Not Track]]></string>
+	<string name="_bx_analytics_adm_stg_cpt_retention_days"><![CDATA[Keep data, days]]></string>
+	<string name="_bx_analytics_adm_stg_cpt_session_timeout"><![CDATA[Session timeout, minutes]]></string>
+	<string name="_bx_analytics_adm_stg_cpt_track_actions"><![CDATA[Record site actions]]></string>
+	<string name="_bx_analytics_adm_stg_cpt_track_bots"><![CDATA[Count bots and agents]]></string>
+	<string name="_bx_analytics_adm_stg_cpt_track_errors"><![CDATA[Record errors]]></string>
+	<string name="_bx_analytics_adm_stg_cpt_track_vitals"><![CDATA[Measure performance]]></string>
+	<string name="_bx_analytics_adm_stg_cpt_tracking"><![CDATA[Collect visits and events]]></string>
+	<string name="_bx_analytics_adm_stg_cpt_trusted_proxies"><![CDATA[Trusted proxies]]></string>
+	<string name="_bx_analytics_adm_stg_cpt_items_count"><![CDATA[Max number of items]]></string>
+	<string name="_bx_analytics_adm_stg_cpt_limit_hits"><![CDATA[Hits per minute from one address]]></string>
+	<string name="_bx_analytics_adm_stg_cpt_limit_visits"><![CDATA[New visits per hour from one address]]></string>
+	<string name="_bx_analytics_adm_stg_cpt_link_members"><![CDATA[Link visits to members]]></string>
+	<string name="_bx_analytics_adm_stg_cpt_default_interval_day"><![CDATA[Default interval in days]]></string>
+	<string name="_bx_analytics_adm_stg_cpt_errors_ignore"><![CDATA[Ignored errors]]></string>
+	<string name="_bx_analytics_adm_stg_cpt_exclude_ips"><![CDATA[Excluded IP addresses]]></string>
+	<string name="_bx_analytics_adm_stg_cpt_exclude_levels"><![CDATA[Don't track these levels]]></string>
+	<string name="_bx_analytics_adm_stg_cpt_exclude_paths"><![CDATA[Excluded paths]]></string>
+	<string name="_bx_analytics_adm_stg_cpt_geo_source"><![CDATA[Visitor location]]></string>
+	<string name="_bx_analytics_adm_stg_cpt_identity"><![CDATA[Guest visitor ids]]></string>
+	<string name="_bx_analytics_adm_stg_cpt_ignore_params"><![CDATA[Query parameters to ignore]]></string>
+	<string name="_bx_analytics_page_title_sys_page"><![CDATA[Analytics]]></string>
+	<string name="_bx_analytics_page_title_sys_revenue"><![CDATA[Analytics: revenue]]></string>
+	<string name="_bx_analytics_page_title_page"><![CDATA[Analytics]]></string>
+	<string name="_bx_analytics_page_title_revenue"><![CDATA[Revenue]]></string>
+	<string name="_bx_analytics_page_block_title_system_canvas"><![CDATA[Analytics]]></string>
+	<string name="_bx_analytics_page_block_title_canvas"><![CDATA[Analytics]]></string>
+	<string name="_bx_analytics_platform_api"><![CDATA[API]]></string>
+	<string name="_bx_analytics_platform_core"><![CDATA[Server]]></string>
+	<string name="_bx_analytics_platform_core_web"><![CDATA[Web (classic)]]></string>
+	<string name="_bx_analytics_platform_next_web"><![CDATA[Web (NEO)]]></string>
+	<string name="_bx_analytics_platform_rn_android"><![CDATA[Android app]]></string>
+	<string name="_bx_analytics_platform_rn_ios"><![CDATA[iOS app]]></string>
+	<string name="_bx_analytics_range_12m"><![CDATA[Last 12 months]]></string>
+	<string name="_bx_analytics_range_24h"><![CDATA[Last 24 hours]]></string>
+	<string name="_bx_analytics_range_30d"><![CDATA[Last 30 days]]></string>
+	<string name="_bx_analytics_range_7d"><![CDATA[Last 7 days]]></string>
+	<string name="_bx_analytics_range_90d"><![CDATA[Last 90 days]]></string>
+	<string name="_bx_analytics_range_today"><![CDATA[Today]]></string>
+	<string name="_bx_analytics_range_yesterday"><![CDATA[Yesterday]]></string>
+	<string name="_bx_analytics_rating_good"><![CDATA[Good]]></string>
+	<string name="_bx_analytics_rating_needs_improvement"><![CDATA[Needs improvement]]></string>
+	<string name="_bx_analytics_rating_poor"><![CDATA[Poor]]></string>
+	<string name="_bx_analytics_report_bots"><![CDATA[Bots and agents]]></string>
+	<string name="_bx_analytics_report_credits"><![CDATA[Credits]]></string>
+	<string name="_bx_analytics_report_errors"><![CDATA[Errors]]></string>
+	<string name="_bx_analytics_report_events"><![CDATA[Events]]></string>
+	<string name="_bx_analytics_report_filters"><![CDATA[Filter bar]]></string>
+	<string name="_bx_analytics_report_funnels"><![CDATA[Funnels]]></string>
+	<string name="_bx_analytics_report_goals"><![CDATA[Goals]]></string>
+	<string name="_bx_analytics_report_journeys"><![CDATA[Journeys]]></string>
+	<string name="_bx_analytics_report_map"><![CDATA[Visitors by location]]></string>
+	<string name="_bx_analytics_report_overview"><![CDATA[Overview]]></string>
+	<string name="_bx_analytics_report_performance"><![CDATA[Performance]]></string>
+	<string name="_bx_analytics_report_query"><![CDATA[Query]]></string>
+	<string name="_bx_analytics_report_realtime"><![CDATA[Right now]]></string>
+	<string name="_bx_analytics_report_retention"><![CDATA[Retention]]></string>
+	<string name="_bx_analytics_report_revenue"><![CDATA[Revenue]]></string>
+	<string name="_bx_analytics_report_sales"><![CDATA[Sales and sellers]]></string>
+	<string name="_bx_analytics_report_sessions"><![CDATA[Sessions]]></string>
+	<string name="_bx_analytics_report_subscriptions"><![CDATA[Subscriptions]]></string>
+	<string name="_bx_analytics_report_table"><![CDATA[Table]]></string>
+	<string name="_bx_analytics_report_top"><![CDATA[Top list]]></string>
+	<string name="_bx_analytics_report_users"><![CDATA[Users]]></string>
+	<string name="_bx_analytics_section_behavior"><![CDATA[Behavior]]></string>
+	<string name="_bx_analytics_section_community"><![CDATA[Community]]></string>
+	<string name="_bx_analytics_section_configure"><![CDATA[Configure]]></string>
+	<string name="_bx_analytics_section_conversion"><![CDATA[Conversion]]></string>
+	<string name="_bx_analytics_section_explore"><![CDATA[Explore]]></string>
+	<string name="_bx_analytics_section_health"><![CDATA[Health]]></string>
+	<string name="_bx_analytics_section_revenue"><![CDATA[Revenue]]></string>
+	<string name="_bx_analytics_section_traffic"><![CDATA[Traffic]]></string>
+	<string name="_bx_analytics_item_title_system_analytics"><![CDATA[Analytics]]></string>
+	<string name="_bx_analytics_item_title_system_revenue"><![CDATA[Revenue]]></string>
+	<string name="_bx_analytics_item_title_analytics"><![CDATA[Analytics]]></string>
+	<string name="_bx_analytics_item_title_revenue"><![CDATA[Revenue]]></string>
+	<string name="_bx_analytics_lmi_cpt_api"><![CDATA[API]]></string>
+	<string name="_bx_analytics_lmi_cpt_audience"><![CDATA[Audience]]></string>
+	<string name="_bx_analytics_lmi_cpt_bots"><![CDATA[Bots and agents]]></string>
+	<string name="_bx_analytics_lmi_cpt_content"><![CDATA[Content reports]]></string>
+	<string name="_bx_analytics_lmi_cpt_credits"><![CDATA[Credits]]></string>
+	<string name="_bx_analytics_lmi_cpt_dashboards"><![CDATA[Dashboards]]></string>
+	<string name="_bx_analytics_lmi_cpt_errors"><![CDATA[Errors]]></string>
+	<string name="_bx_analytics_lmi_cpt_events"><![CDATA[Events]]></string>
+	<string name="_bx_analytics_lmi_cpt_funnels"><![CDATA[Funnels]]></string>
+	<string name="_bx_analytics_lmi_cpt_goals"><![CDATA[Goals]]></string>
+	<string name="_bx_analytics_lmi_cpt_journeys"><![CDATA[Journeys]]></string>
+	<string name="_bx_analytics_lmi_cpt_map"><![CDATA[Map]]></string>
+	<string name="_bx_analytics_lmi_cpt_overview"><![CDATA[Overview]]></string>
+	<string name="_bx_analytics_lmi_cpt_pages"><![CDATA[Pages]]></string>
+	<string name="_bx_analytics_lmi_cpt_performance"><![CDATA[Performance]]></string>
+	<string name="_bx_analytics_lmi_cpt_query"><![CDATA[Query]]></string>
+	<string name="_bx_analytics_lmi_cpt_realtime"><![CDATA[Realtime]]></string>
+	<string name="_bx_analytics_lmi_cpt_retention"><![CDATA[Retention]]></string>
+	<string name="_bx_analytics_lmi_cpt_revenue"><![CDATA[Revenue]]></string>
+	<string name="_bx_analytics_lmi_cpt_sales"><![CDATA[Sales and sellers]]></string>
+	<string name="_bx_analytics_lmi_cpt_sessions"><![CDATA[Sessions]]></string>
+	<string name="_bx_analytics_lmi_cpt_setup"><![CDATA[Setup]]></string>
+	<string name="_bx_analytics_lmi_cpt_sources"><![CDATA[Sources]]></string>
+	<string name="_bx_analytics_lmi_cpt_subscriptions"><![CDATA[Subscriptions]]></string>
+	<string name="_bx_analytics_lmi_cpt_users"><![CDATA[Users]]></string>
+	<string name="_bx_analytics_metric_ai_hits"><![CDATA[AI requests]]></string>
+	<string name="_bx_analytics_metric_bot_hits"><![CDATA[Requests]]></string>
+	<string name="_bx_analytics_metric_bot_share"><![CDATA[Share of all requests]]></string>
+	<string name="_bx_analytics_metric_bots"><![CDATA[Bots]]></string>
+	<string name="_bx_analytics_metric_bounce_rate"><![CDATA[Bounce rate]]></string>
+	<string name="_bx_analytics_metric_count"><![CDATA[Count]]></string>
+	<string name="_bx_analytics_metric_crash_free"><![CDATA[Crash-free app visits]]></string>
+	<string name="_bx_analytics_metric_credits_bundles"><![CDATA[Bundles sold for money]]></string>
+	<string name="_bx_analytics_metric_duration"><![CDATA[Session duration]]></string>
+	<string name="_bx_analytics_metric_engaged"><![CDATA[Time on page]]></string>
+	<string name="_bx_analytics_metric_entries"><![CDATA[Entries]]></string>
+	<string name="_bx_analytics_metric_error_sessions"><![CDATA[Visits with errors]]></string>
+	<string name="_bx_analytics_metric_errors"><![CDATA[Errors]]></string>
+	<string name="_bx_analytics_metric_exits"><![CDATA[Exits]]></string>
+	<string name="_bx_analytics_metric_new_errors"><![CDATA[New errors]]></string>
+	<string name="_bx_analytics_metric_occurrences"><![CDATA[Occurrences]]></string>
+	<string name="_bx_analytics_metric_pages_per_session"><![CDATA[Pages per session]]></string>
+	<string name="_bx_analytics_metric_pageviews"><![CDATA[Pageviews]]></string>
+	<string name="_bx_analytics_metric_per_visitor"><![CDATA[Per visitor]]></string>
+	<string name="_bx_analytics_metric_revenue_arppu"><![CDATA[Revenue per customer]]></string>
+	<string name="_bx_analytics_metric_revenue_customers"><![CDATA[Paying customers]]></string>
+	<string name="_bx_analytics_metric_revenue_gross"><![CDATA[Gross revenue]]></string>
+	<string name="_bx_analytics_metric_revenue_net"><![CDATA[Net revenue]]></string>
+	<string name="_bx_analytics_metric_revenue_orders"><![CDATA[Orders]]></string>
+	<string name="_bx_analytics_metric_revenue_refunds"><![CDATA[Refunds]]></string>
+	<string name="_bx_analytics_metric_sales_commissions"><![CDATA[Commissions paid]]></string>
+	<string name="_bx_analytics_metric_sales_site"><![CDATA[Sold by the site]]></string>
+	<string name="_bx_analytics_metric_sales_units"><![CDATA[Units]]></string>
+	<string name="_bx_analytics_metric_sales_vendors"><![CDATA[Sold by vendors]]></string>
+	<string name="_bx_analytics_metric_sbs_active"><![CDATA[Paying subscriptions]]></string>
+	<string name="_bx_analytics_metric_sbs_cancelled"><![CDATA[Cancelled]]></string>
+	<string name="_bx_analytics_metric_sbs_churn"><![CDATA[Churn]]></string>
+	<string name="_bx_analytics_metric_sbs_mrr"><![CDATA[MRR]]></string>
+	<string name="_bx_analytics_metric_sbs_new"><![CDATA[New]]></string>
+	<string name="_bx_analytics_metric_sbs_renewed"><![CDATA[Renewed]]></string>
+	<string name="_bx_analytics_metric_sbs_trials"><![CDATA[Trials]]></string>
+	<string name="_bx_analytics_metric_sessions"><![CDATA[Sessions]]></string>
+	<string name="_bx_analytics_metric_visitors"><![CDATA[Unique visitors]]></string>
+	<string name="_bx_analytics_object_sys_profiles_friends"><![CDATA[Friends]]></string>
+	<string name="_bx_analytics_object_sys_profiles_relations"><![CDATA[Relations]]></string>
+	<string name="_bx_analytics_object_sys_profiles_subscriptions"><![CDATA[Follows]]></string>
+	<string name="_bx_analytics_type_top_contents_by_likes"><![CDATA[Top contents by likes]]></string>
+	<string name="_bx_analytics_type_content_growth"><![CDATA[Total numbers]]></string>
+	<string name="_bx_analytics_type_content_speed"><![CDATA[Growth speed]]></string>
+	<string name="_bx_analytics_type_accounts_growth"><![CDATA[Accounts - Total numbers]]></string>
+	<string name="_bx_analytics_type_accounts_speed"><![CDATA[Accounts - Growth speed]]></string>
+	<string name="_bx_analytics_type_top_by_views"><![CDATA[Top by views]]></string>
+	<string name="_bx_analytics_type_most_followed_profiles"><![CDATA[Most followed profiles]]></string>
+	<string name="_bx_analytics_type_most_active_profiles"><![CDATA[Most active profiles]]></string>
+	<string name="_bx_analytics_vital_app_start"><![CDATA[App start]]></string>
+	<string name="_bx_analytics_vital_cls"><![CDATA[Layout shift (CLS)]]></string>
+	<string name="_bx_analytics_vital_fcp"><![CDATA[First paint (FCP)]]></string>
+	<string name="_bx_analytics_vital_inp"><![CDATA[Response to input (INP)]]></string>
+	<string name="_bx_analytics_vital_lcp"><![CDATA[Largest paint (LCP)]]></string>
+	<string name="_bx_analytics_vital_screen_load"><![CDATA[Screen load]]></string>
+	<string name="_bx_analytics_vital_ttfb"><![CDATA[Server response (TTFB)]]></string>
+	<string name="_bx_analytics_system_text"><![CDATA[System]]></string>
+	<string name="_bx_analytics_tab_app_versions"><![CDATA[App versions]]></string>
+	<string name="_bx_analytics_tab_apps"><![CDATA[Apps]]></string>
+	<string name="_bx_analytics_tab_bots_ai"><![CDATA[AI]]></string>
+	<string name="_bx_analytics_tab_bots_all"><![CDATA[All]]></string>
+	<string name="_bx_analytics_tab_bots_monitor"><![CDATA[Monitoring]]></string>
+	<string name="_bx_analytics_tab_bots_script"><![CDATA[Scripts and other]]></string>
+	<string name="_bx_analytics_tab_bots_search"><![CDATA[Search engines]]></string>
+	<string name="_bx_analytics_tab_bots_seo"><![CDATA[SEO tools]]></string>
+	<string name="_bx_analytics_tab_bots_social"><![CDATA[Link previews]]></string>
+	<string name="_bx_analytics_tab_browsers"><![CDATA[Browsers]]></string>
+	<string name="_bx_analytics_tab_channels"><![CDATA[Channels]]></string>
+	<string name="_bx_analytics_tab_cities"><![CDATA[Cities]]></string>
+	<string name="_bx_analytics_tab_countries"><![CDATA[Countries]]></string>
+	<string name="_bx_analytics_tab_devices"><![CDATA[Devices]]></string>
+	<string name="_bx_analytics_tab_entry"><![CDATA[Entry pages]]></string>
+	<string name="_bx_analytics_tab_errors_all"><![CDATA[All]]></string>
+	<string name="_bx_analytics_tab_errors_ignored"><![CDATA[Ignored]]></string>
+	<string name="_bx_analytics_tab_errors_new"><![CDATA[New]]></string>
+	<string name="_bx_analytics_tab_errors_open"><![CDATA[Open]]></string>
+	<string name="_bx_analytics_tab_errors_resolved"><![CDATA[Resolved]]></string>
+	<string name="_bx_analytics_tab_events_all"><![CDATA[All]]></string>
+	<string name="_bx_analytics_tab_events_custom"><![CDATA[Custom events]]></string>
+	<string name="_bx_analytics_tab_events_site"><![CDATA[Site actions]]></string>
+	<string name="_bx_analytics_tab_exit"><![CDATA[Exit pages]]></string>
+	<string name="_bx_analytics_tab_hostnames"><![CDATA[Hostnames]]></string>
+	<string name="_bx_analytics_tab_languages"><![CDATA[Languages]]></string>
+	<string name="_bx_analytics_tab_map_live"><![CDATA[Live]]></string>
+	<string name="_bx_analytics_tab_map_period"><![CDATA[This period]]></string>
+	<string name="_bx_analytics_tab_os"><![CDATA[Operating systems]]></string>
+	<string name="_bx_analytics_tab_pages"><![CDATA[Pages]]></string>
+	<string name="_bx_analytics_tab_platforms"><![CDATA[Platforms]]></string>
+	<string name="_bx_analytics_tab_referrer_urls"><![CDATA[Referrer URLs]]></string>
+	<string name="_bx_analytics_tab_referrers"><![CDATA[Referrers]]></string>
+	<string name="_bx_analytics_tab_regions"><![CDATA[Regions]]></string>
+	<string name="_bx_analytics_tab_retention_members"><![CDATA[Members]]></string>
+	<string name="_bx_analytics_tab_retention_visitors"><![CDATA[All visitors]]></string>
+	<string name="_bx_analytics_tab_revenue_campaigns"><![CDATA[Campaign]]></string>
+	<string name="_bx_analytics_tab_revenue_first_touch"><![CDATA[First visit from]]></string>
+	<string name="_bx_analytics_tab_revenue_last_touch"><![CDATA[Last visit from]]></string>
+	<string name="_bx_analytics_tab_revenue_platforms"><![CDATA[Platform]]></string>
+	<string name="_bx_analytics_tab_revenue_providers"><![CDATA[Payment provider]]></string>
+	<string name="_bx_analytics_tab_revenue_sources"><![CDATA[Source]]></string>
+	<string name="_bx_analytics_tab_screens"><![CDATA[Screen sizes]]></string>
+	<string name="_bx_analytics_tab_titles"><![CDATA[Page titles]]></string>
+	<string name="_bx_analytics_tab_utm_campaign"><![CDATA[UTM campaign]]></string>
+	<string name="_bx_analytics_tab_utm_content"><![CDATA[UTM content]]></string>
+	<string name="_bx_analytics_tab_utm_medium"><![CDATA[UTM medium]]></string>
+	<string name="_bx_analytics_tab_utm_source"><![CDATA[UTM source]]></string>
+	<string name="_bx_analytics_tab_utm_term"><![CDATA[UTM term]]></string>
+	<string name="_bx_analytics_tab_vitals_path"><![CDATA[Paths]]></string>
+	<string name="_bx_analytics_tab_vitals_route"><![CDATA[Routes]]></string>
+	<string name="_bx_analytics_txt_empty_title_item"><![CDATA[Empty title]]></string>
+	<string name="_bx_analytics_txt_error"><![CDATA[Error]]></string>
+	<string name="_bx_analytics_txt_error_actions"><![CDATA[Change status]]></string>
+	<string name="_bx_analytics_txt_error_all_time"><![CDATA[All occurrences]]></string>
+	<string name="_bx_analytics_txt_error_http_status"><![CDATA[HTTP status]]></string>
+	<string name="_bx_analytics_txt_error_kind"><![CDATA[Kind]]></string>
+	<string name="_bx_analytics_txt_error_latest"><![CDATA[Latest occurrences]]></string>
+	<string name="_bx_analytics_txt_error_loading"><![CDATA[Couldn't load this report. Reload the page to try again.]]></string>
+	<string name="_bx_analytics_txt_error_mark_ignored"><![CDATA[Ignore]]></string>
+	<string name="_bx_analytics_txt_error_mark_open"><![CDATA[Reopen]]></string>
+	<string name="_bx_analytics_txt_error_mark_resolved"><![CDATA[Mark resolved]]></string>
+	<string name="_bx_analytics_txt_error_missing"><![CDATA[This error is no longer stored.]]></string>
+	<string name="_bx_analytics_txt_error_no_stack"><![CDATA[No stack trace came with this error.]]></string>
+	<string name="_bx_analytics_txt_error_overflow"><![CDATA[Other errors: more new kinds in an hour than the limit]]></string>
+	<string name="_bx_analytics_txt_error_report"><![CDATA[Couldn't load this report.]]></string>
+	<string name="_bx_analytics_txt_error_source"><![CDATA[Where]]></string>
+	<string name="_bx_analytics_txt_error_stack"><![CDATA[Stack trace]]></string>
+	<string name="_bx_analytics_txt_error_stack_hidden"><![CDATA[A stack trace can show what a visitor typed or opened, so only those who may see visitor details can read it.]]></string>
+	<string name="_bx_analytics_txt_error_status"><![CDATA[Status]]></string>
+	<string name="_bx_analytics_txt_event"><![CDATA[Event]]></string>
+	<string name="_bx_analytics_txt_item"><![CDATA[Item]]></string>
+	<string name="_bx_analytics_txt_item_n"><![CDATA[Item #{0}]]></string>
+	<string name="_bx_analytics_txt_journey_from"><![CDATA[Where visitors went from {0}]]></string>
+	<string name="_bx_analytics_txt_journeys_truncated"><![CDATA[This period has too many pageviews to follow them all; the latest visits are shown.]]></string>
+	<string name="_bx_analytics_txt_key_figures"><![CDATA[Key figures]]></string>
+	<string name="_bx_analytics_txt_last_seen"><![CDATA[Last seen]]></string>
+	<string name="_bx_analytics_txt_latest_pageviews"><![CDATA[Latest pageviews]]></string>
+	<string name="_bx_analytics_txt_left_site"><![CDATA[Left]]></string>
+	<string name="_bx_analytics_txt_lifetime_value"><![CDATA[Lifetime value]]></string>
+	<string name="_bx_analytics_txt_location"><![CDATA[Location]]></string>
+	<string name="_bx_analytics_txt_map_label"><![CDATA[Map of visitors by location]]></string>
+	<string name="_bx_analytics_txt_map_unknown"><![CDATA[{0} visitors from unknown locations are not on the map.]]></string>
+	<string name="_bx_analytics_txt_match_hint"><![CDATA[A page path starts with / or *, and * stands for any characters: *view-post* matches the post page on the site and in the apps. An action is an event name such as signup, or name@module for one module, for example content_add@bx_posts.]]></string>
+	<string name="_bx_analytics_txt_member"><![CDATA[member]]></string>
+	<string name="_bx_analytics_txt_member_name"><![CDATA[Member]]></string>
+	<string name="_bx_analytics_txt_metric"><![CDATA[Metric]]></string>
+	<string name="_bx_analytics_txt_minutes_ago"><![CDATA[{0} min ago]]></string>
+	<string name="_bx_analytics_txt_ms"><![CDATA[ms]]></string>
+	<string name="_bx_analytics_txt_n_samples"><![CDATA[{0} measurements]]></string>
+	<string name="_bx_analytics_txt_n_sessions"><![CDATA[{0} visits]]></string>
+	<string name="_bx_analytics_txt_name"><![CDATA[Name]]></string>
+	<string name="_bx_analytics_txt_next"><![CDATA[Next]]></string>
+	<string name="_bx_analytics_txt_no_bots"><![CDATA[No bots in this period]]></string>
+	<string name="_bx_analytics_txt_no_cancellations"><![CDATA[No cancellations in this period.]]></string>
+	<string name="_bx_analytics_txt_no_credit_orders"><![CDATA[No orders paid with credits in this period.]]></string>
+	<string name="_bx_analytics_txt_no_credits"><![CDATA[No credits spent in this period.]]></string>
+	<string name="_bx_analytics_txt_no_data"><![CDATA[No data for this period]]></string>
+	<string name="_bx_analytics_txt_no_data_yet"><![CDATA[No visits recorded yet]]></string>
+	<string name="_bx_analytics_txt_no_errors"><![CDATA[No errors in this period.]]></string>
+	<string name="_bx_analytics_txt_no_events"><![CDATA[No events in this period]]></string>
+	<string name="_bx_analytics_txt_no_funnels"><![CDATA[No funnels yet. A funnel shows how many visits go through a series of steps and where they drop off.]]></string>
+	<string name="_bx_analytics_txt_no_goals"><![CDATA[No goals yet. A goal counts the visits that reach a page or an action, such as a sign-up or a new post.]]></string>
+	<string name="_bx_analytics_txt_no_members"><![CDATA[No members visited in this period]]></string>
+	<string name="_bx_analytics_txt_no_properties"><![CDATA[This event has no properties]]></string>
+	<string name="_bx_analytics_txt_no_recent"><![CDATA[No pageviews in the last 30 minutes]]></string>
+	<string name="_bx_analytics_txt_no_revenue"><![CDATA[No revenue in this period.]]></string>
+	<string name="_bx_analytics_txt_no_subscriptions"><![CDATA[No paying subscriptions.]]></string>
+	<string name="_bx_analytics_txt_no_vitals"><![CDATA[No measurements in this period.]]></string>
+	<string name="_bx_analytics_txt_not_available"><![CDATA[n/a]]></string>
+	<string name="_bx_analytics_txt_not_enough_data"><![CDATA[Not enough data]]></string>
+	<string name="_bx_analytics_txt_now"><![CDATA[now]]></string>
+	<string name="_bx_analytics_txt_online_now"><![CDATA[online in the last {0} minutes]]></string>
+	<string name="_bx_analytics_txt_open_analytics"><![CDATA[Open analytics]]></string>
+	<string name="_bx_analytics_txt_p75"><![CDATA[75th percentile]]></string>
+	<string name="_bx_analytics_txt_page_of"><![CDATA[Page {0} of {1}]]></string>
+	<string name="_bx_analytics_txt_pageviews_30m"><![CDATA[Pageviews per minute over the last 30 minutes]]></string>
+	<string name="_bx_analytics_txt_pageviews_at"><![CDATA[{0} pageviews at {1}]]></string>
+	<string name="_bx_analytics_txt_path"><![CDATA[Path]]></string>
+	<string name="_bx_analytics_txt_pick_next"><![CDATA[Pick a page in step 2 to see where visitors went after it.]]></string>
+	<string name="_bx_analytics_txt_platform"><![CDATA[Platform]]></string>
+	<string name="_bx_analytics_txt_prev"><![CDATA[Previous]]></string>
+	<string name="_bx_analytics_txt_previous_period"><![CDATA[Previous period: {0}]]></string>
+	<string name="_bx_analytics_txt_properties"><![CDATA[Properties]]></string>
+	<string name="_bx_analytics_txt_range"><![CDATA[Period]]></string>
+	<string name="_bx_analytics_txt_rating"><![CDATA[Rating]]></string>
+	<string name="_bx_analytics_txt_raw_value"><![CDATA[Stored value]]></string>
+	<string name="_bx_analytics_txt_remove_filter"><![CDATA[Remove filter: {0}]]></string>
+	<string name="_bx_analytics_txt_retention_cookieless"><![CDATA[Guests get a new id every day in cookieless mode, so they all look like new visitors here. Switch guest ids to persistent in Settings to follow them.]]></string>
+	<string name="_bx_analytics_txt_revenue_as_recorded"><![CDATA[Amounts in {0} as recorded by the payment module, taxes included where the provider charged them. Orders paid with credits are counted under Credits.]]></string>
+	<string name="_bx_analytics_txt_revenue_commissions"><![CDATA[Commission invoices]]></string>
+	<string name="_bx_analytics_txt_revenue_credits_only"><![CDATA[The site takes payments in credits only, so revenue here is empty. See Credits for what credits paid for.]]></string>
+	<string name="_bx_analytics_txt_revenue_imported"><![CDATA[Imported (platform unknown)]]></string>
+	<string name="_bx_analytics_txt_revenue_no_campaign"><![CDATA[No campaign]]></string>
+	<string name="_bx_analytics_txt_revenue_no_payment"><![CDATA[No payment module is active, so no new payments are recorded.]]></string>
+	<string name="_bx_analytics_txt_revenue_no_rate"><![CDATA[Payments in another currency with no exchange rate, left out of the totals: {0}.]]></string>
+	<string name="_bx_analytics_txt_revenue_split"><![CDATA[Revenue split]]></string>
+	<string name="_bx_analytics_txt_revenue_untracked"><![CDATA[No tracked visit]]></string>
+	<string name="_bx_analytics_txt_route"><![CDATA[Route]]></string>
+	<string name="_bx_analytics_txt_sales_invoices"><![CDATA[Commission invoices issued in this period]]></string>
+	<string name="_bx_analytics_txt_sales_items"><![CDATA[Top items]]></string>
+	<string name="_bx_analytics_txt_sales_seller"><![CDATA[Seller]]></string>
+	<string name="_bx_analytics_txt_sales_sellers"><![CDATA[Top sellers]]></string>
+	<string name="_bx_analytics_txt_sales_vendor_count"><![CDATA[{0} vendors]]></string>
+	<string name="_bx_analytics_txt_samples"><![CDATA[Measurements]]></string>
+	<string name="_bx_analytics_txt_save"><![CDATA[Save]]></string>
+	<string name="_bx_analytics_txt_sbs_churn_no_base"><![CDATA[No paying subscriptions at the start of the period]]></string>
+	<string name="_bx_analytics_txt_sbs_movement"><![CDATA[New, renewed and cancelled]]></string>
+	<string name="_bx_analytics_txt_sbs_no_reason"><![CDATA[No reason given]]></string>
+	<string name="_bx_analytics_txt_sbs_plan"><![CDATA[Plan]]></string>
+	<string name="_bx_analytics_txt_sbs_plans"><![CDATA[Plans]]></string>
+	<string name="_bx_analytics_txt_sbs_reason"><![CDATA[Reason]]></string>
+	<string name="_bx_analytics_txt_sbs_reasons"><![CDATA[Why members cancel]]></string>
+	<string name="_bx_analytics_txt_sbs_trial_mrr"><![CDATA[{0} a month once paid]]></string>
+	<string name="_bx_analytics_txt_setup_actions_off"><![CDATA[Site actions are not recorded (Settings > Track site actions)]]></string>
+	<string name="_bx_analytics_txt_setup_actions_on"><![CDATA[Site actions are recorded (Settings > Track site actions)]]></string>
+	<string name="_bx_analytics_txt_setup_alerts"><![CDATA[Recorded from these alerts and from new content of every content module:]]></string>
+	<string name="_bx_analytics_txt_setup_api"><![CDATA[Any other client sends hits as JSON to the collect service with an API key (Studio > API). The payload is described in BxAnalyticsCollector.]]></string>
+	<string name="_bx_analytics_txt_setup_app_version"><![CDATA[Latest app version]]></string>
+	<string name="_bx_analytics_txt_setup_check"><![CDATA[Check this browser]]></string>
+	<string name="_bx_analytics_txt_setup_check_hint"><![CDATA[See whether your own visits to the site are recorded, and what the server sees of this browser. The check stores nothing.]]></string>
+	<string name="_bx_analytics_txt_setup_check_title"><![CDATA[Check this browser]]></string>
+	<string name="_bx_analytics_txt_setup_col_status"><![CDATA[Status]]></string>
+	<string name="_bx_analytics_txt_setup_core"><![CDATA[Site actions are recorded on the server, on the platform of the request that did them. Core is for cron and other jobs without a request.]]></string>
+	<string name="_bx_analytics_txt_setup_core_web"><![CDATA[Automatic: the tracker script is added to every page of the classic site.]]></string>
+	<string name="_bx_analytics_txt_setup_core_web_exclusions"><![CDATA[Not recorded: bots, members of excluded levels, browsers that ask not to be tracked (when the site respects it), excluded addresses and paths. Studio pages have no tracker.]]></string>
+	<string name="_bx_analytics_txt_setup_curl"><![CDATA[Example request]]></string>
+	<string name="_bx_analytics_txt_setup_events"><![CDATA[Custom events]]></string>
+	<string name="_bx_analytics_txt_setup_events_classic"><![CDATA[Classic site (JavaScript)]]></string>
+	<string name="_bx_analytics_txt_setup_events_hint"><![CDATA[Names are up to 64 letters, digits, spaces and _ . : / -. Properties are short text, numbers or true and false, up to 2 KB in all.]]></string>
+	<string name="_bx_analytics_txt_setup_events_neo"><![CDATA[NEO web and apps]]></string>
+	<string name="_bx_analytics_txt_setup_events_server"><![CDATA[Server (PHP)]]></string>
+	<string name="_bx_analytics_txt_setup_events_warning"><![CDATA[In server code call track, not collect: collect records the server's own address and user agent as an API client.]]></string>
+	<string name="_bx_analytics_txt_setup_hits_24h"><![CDATA[Hits, 24 h]]></string>
+	<string name="_bx_analytics_txt_setup_injection_off"><![CDATA[The tracker is not added to pages: its injection (bx_analytics_tracker) is off]]></string>
+	<string name="_bx_analytics_txt_setup_injection_on"><![CDATA[The tracker is added to pages]]></string>
+	<string name="_bx_analytics_txt_setup_install"><![CDATA[What each client needs]]></string>
+	<string name="_bx_analytics_txt_setup_last_hit"><![CDATA[Last hit]]></string>
+	<string name="_bx_analytics_txt_setup_neo"><![CDATA[Built in: NEO reads its settings from get_tracker_config and sends hits to the collect service. Their last hits are in the table above.]]></string>
+	<string name="_bx_analytics_txt_setup_neo_access"><![CDATA[API access by key or by origin is on]]></string>
+	<string name="_bx_analytics_txt_setup_neo_api"><![CDATA[The API is on]]></string>
+	<string name="_bx_analytics_txt_setup_neo_title"><![CDATA[NEO web and apps]]></string>
+	<string name="_bx_analytics_txt_setup_never"><![CDATA[Never]]></string>
+	<string name="_bx_analytics_txt_setup_none"><![CDATA[None]]></string>
+	<string name="_bx_analytics_txt_setup_open_settings"><![CDATA[Open settings]]></string>
+	<string name="_bx_analytics_txt_setup_platform_off"><![CDATA[{0} is not tracked (Settings > Platforms)]]></string>
+	<string name="_bx_analytics_txt_setup_platform_on"><![CDATA[{0} is tracked]]></string>
+	<string name="_bx_analytics_txt_setup_sessions_7d"><![CDATA[Visits, 7 days]]></string>
+	<string name="_bx_analytics_txt_setup_status"><![CDATA[Tracker status]]></string>
+	<string name="_bx_analytics_txt_setup_status_nodata"><![CDATA[No data]]></string>
+	<string name="_bx_analytics_txt_setup_status_off"><![CDATA[Off]]></string>
+	<string name="_bx_analytics_txt_setup_status_quiet"><![CDATA[Quiet]]></string>
+	<string name="_bx_analytics_txt_setup_status_receiving"><![CDATA[Receiving]]></string>
+	<string name="_bx_analytics_txt_setup_test"><![CDATA[Send test event]]></string>
+	<string name="_bx_analytics_txt_setup_test_dropped"><![CDATA[The test event was not stored: this browser isn't tracked. Check this browser to see why.]]></string>
+	<string name="_bx_analytics_txt_setup_test_stored"><![CDATA[The test event was stored: it is in Events as analytics_setup_test, and the status table is updated.]]></string>
+	<string name="_bx_analytics_txt_setup_test_title"><![CDATA[Analytics setup test]]></string>
+	<string name="_bx_analytics_txt_setup_test_warning"><![CDATA[The test event goes through the real collector, so it adds a visit from you to the reports.]]></string>
+	<string name="_bx_analytics_txt_setup_tracking_off"><![CDATA[Tracking is off: nothing is recorded on any platform.]]></string>
+	<string name="_bx_analytics_txt_setup_visitors_24h"><![CDATA[Visitors, 24 h]]></string>
+	<string name="_bx_analytics_txt_share"><![CDATA[Share]]></string>
+	<string name="_bx_analytics_txt_sort"><![CDATA[Order]]></string>
+	<string name="_bx_analytics_txt_sort_asc"><![CDATA[Lowest first]]></string>
+	<string name="_bx_analytics_txt_sort_desc"><![CDATA[Highest first]]></string>
+	<string name="_bx_analytics_txt_source"><![CDATA[Source]]></string>
+	<string name="_bx_analytics_txt_start_page"><![CDATA[Start page]]></string>
+	<string name="_bx_analytics_txt_started"><![CDATA[Started]]></string>
+	<string name="_bx_analytics_txt_step"><![CDATA[Step {0}]]></string>
+	<string name="_bx_analytics_txt_step_match"><![CDATA[Step {0}: page path or action name]]></string>
+	<string name="_bx_analytics_txt_step_type"><![CDATA[Step {0}: what the visitor does]]></string>
+	<string name="_bx_analytics_txt_threshold_good"><![CDATA[Good up to]]></string>
+	<string name="_bx_analytics_txt_threshold_poor"><![CDATA[Poor above]]></string>
+	<string name="_bx_analytics_txt_time"><![CDATA[Time]]></string>
+	<string name="_bx_analytics_txt_time_spent"><![CDATA[Time spent]]></string>
+	<string name="_bx_analytics_txt_timeline"><![CDATA[Timeline]]></string>
+	<string name="_bx_analytics_txt_top_paths"><![CDATA[Most common paths]]></string>
+	<string name="_bx_analytics_txt_date"><![CDATA[Date]]></string>
+	<string name="_bx_analytics_txt_delete"><![CDATA[Delete]]></string>
+	<string name="_bx_analytics_txt_deleted_member"><![CDATA[Deleted member]]></string>
+	<string name="_bx_analytics_txt_device"><![CDATA[Devices]]></string>
+	<string name="_bx_analytics_txt_device_all"><![CDATA[All devices]]></string>
+	<string name="_bx_analytics_txt_device_desktop"><![CDATA[Computers]]></string>
+	<string name="_bx_analytics_txt_device_mobile"><![CDATA[Phones and tablets]]></string>
+	<string name="_bx_analytics_txt_dimension"><![CDATA[Break down by]]></string>
+	<string name="_bx_analytics_txt_dims_event"><![CDATA[Pages]]></string>
+	<string name="_bx_analytics_txt_dims_session"><![CDATA[Sessions]]></string>
+	<string name="_bx_analytics_txt_direct"><![CDATA[Direct / none]]></string>
+	<string name="_bx_analytics_txt_download_csv"><![CDATA[Download CSV]]></string>
+	<string name="_bx_analytics_txt_dropped"><![CDATA[{0} dropped off]]></string>
+	<string name="_bx_analytics_txt_duration"><![CDATA[Duration]]></string>
+	<string name="_bx_analytics_txt_duration_h"><![CDATA[h]]></string>
+	<string name="_bx_analytics_txt_duration_m"><![CDATA[m]]></string>
+	<string name="_bx_analytics_txt_duration_s"><![CDATA[s]]></string>
+	<string name="_bx_analytics_txt_edit"><![CDATA[Edit]]></string>
+	<string name="_bx_analytics_txt_edit_funnel"><![CDATA[Edit funnel]]></string>
+	<string name="_bx_analytics_txt_edit_goal"><![CDATA[Edit goal]]></string>
+	<string name="_bx_analytics_txt_value"><![CDATA[Value]]></string>
+	<string name="_bx_analytics_txt_visitor"><![CDATA[Visitor]]></string>
+	<string name="_bx_analytics_txt_vital_distribution"><![CDATA[Good {0} · needs improvement {1} · poor {2}]]></string>
+	<string name="_bx_analytics_txt_vital_thresholds"><![CDATA[Good up to {0}, poor above {1}]]></string>
+	<string name="_bx_analytics_txt_vitals_metrics"><![CDATA[Metrics: pick one for the chart and the tables]]></string>
+	<string name="_bx_analytics_txt_vitals_pages"><![CDATA[Pages]]></string>
+	<string name="_bx_analytics_txt_week_n"><![CDATA[Week {0}]]></string>
+	<string name="_bx_analytics_txt_total_count"><![CDATA[Total]]></string>
+	<string name="_bx_analytics_txt_trend"><![CDATA[Trend]]></string>
+	<string name="_bx_analytics_txt_unknown"><![CDATA[(none)]]></string>
+	<string name="_bx_analytics_txt_count"><![CDATA[{0}]]></string>
+	<string name="_bx_analytics_txt_crash_free_apps"><![CDATA[iOS and Android apps]]></string>
+	<string name="_bx_analytics_txt_credits_all_platforms"><![CDATA[Credits figures are for all platforms: the modules that keep credits don't record where a member was.]]></string>
+	<string name="_bx_analytics_txt_credits_liability"><![CDATA[Worth {0}]]></string>
+	<string name="_bx_analytics_txt_credits_none"><![CDATA[No installed module keeps credits.]]></string>
+	<string name="_bx_analytics_txt_credits_not_in_ledger"><![CDATA[Credits were spent on checkouts the revenue ledger doesn't have yet, so they aren't listed by what they paid for. Importing past payments adds them.]]></string>
+	<string name="_bx_analytics_txt_credits_spenders"><![CDATA[Top spenders]]></string>
+	<string name="_bx_analytics_txt_credits_spent"><![CDATA[Credits spent]]></string>
+	<string name="_bx_analytics_txt_credits_spent_on"><![CDATA[What credits paid for]]></string>
+	<string name="_bx_analytics_txt_credits_value"><![CDATA[Value]]></string>
+	<string name="_bx_analytics_txt_csv_truncated"><![CDATA[The download has the first {0} of {1} rows.]]></string>
+	<string name="_bx_analytics_txt_dash_blocks"><![CDATA[Blocks]]></string>
+	<string name="_bx_analytics_txt_dash_col_actions"><![CDATA[Actions]]></string>
+	<string name="_bx_analytics_txt_dash_col_page"><![CDATA[Page]]></string>
+	<string name="_bx_analytics_txt_dash_create"><![CDATA[New analytics page]]></string>
+	<string name="_bx_analytics_txt_dash_create_btn"><![CDATA[Create page]]></string>
+	<string name="_bx_analytics_txt_dash_create_failed"><![CDATA[The page could not be created. Enter a title of up to 100 characters and try again.]]></string>
+	<string name="_bx_analytics_txt_dash_create_hint"><![CDATA[The page gets the filter bar, key figures and top lists, shown on the site and in NEO, and opens in the page builder.]]></string>
+	<string name="_bx_analytics_txt_dash_disable_warning"><![CDATA[Pages created here belong to Analytics: disabling the module removes them with their blocks.]]></string>
+	<string name="_bx_analytics_txt_dash_edit"><![CDATA[Edit]]></string>
+	<string name="_bx_analytics_txt_dash_edit_label"><![CDATA[Edit {0} in the page builder]]></string>
+	<string name="_bx_analytics_txt_dash_empty"><![CDATA[No site page has analytics blocks.]]></string>
+	<string name="_bx_analytics_txt_dash_intro"><![CDATA[Site pages with analytics blocks. Add blocks to any page in the page builder, or start a new analytics page here.]]></string>
+	<string name="_bx_analytics_txt_dash_neo_blocks"><![CDATA[In NEO]]></string>
+	<string name="_bx_analytics_txt_dash_neo_note"><![CDATA[In NEO counts the blocks NEO and the apps show. The others show on the classic site only.]]></string>
+	<string name="_bx_analytics_txt_dash_pages"><![CDATA[Pages with analytics blocks]]></string>
+	<string name="_bx_analytics_txt_dash_title"><![CDATA[Title]]></string>
+	<string name="_bx_analytics_txt_accounts"><![CDATA[Accounts]]></string>
+	<string name="_bx_analytics_txt_actions"><![CDATA[Events and actions]]></string>
+	<string name="_bx_analytics_txt_add_funnel"><![CDATA[Add funnel]]></string>
+	<string name="_bx_analytics_txt_add_goal"><![CDATA[Add goal]]></string>
+	<string name="_bx_analytics_txt_all_platforms"><![CDATA[All platforms]]></string>
+	<string name="_bx_analytics_txt_api_bad_params"><![CDATA[Parameters must be a JSON object, such as {"range": "7d"}.]]></string>
+	<string name="_bx_analytics_txt_api_col_accepts"><![CDATA[Accepts]]></string>
+	<string name="_bx_analytics_txt_api_col_param"><![CDATA[Parameter]]></string>
+	<string name="_bx_analytics_txt_api_common_params"><![CDATA[Filter parameters of every report]]></string>
+	<string name="_bx_analytics_txt_api_default"><![CDATA[Default: {0}.]]></string>
+	<string name="_bx_analytics_txt_api_disabled"><![CDATA[The API is off (Studio > API), so other clients can't call get_report. The playground still runs reports here.]]></string>
+	<string name="_bx_analytics_txt_api_docs"><![CDATA[What the parameters accept]]></string>
+	<string name="_bx_analytics_txt_api_intro"><![CDATA[Try get_report, the service NEO and other clients read reports from. It runs here as you, with your permissions, and answers with the same JSON as api.php.]]></string>
+	<string name="_bx_analytics_txt_api_meta"><![CDATA[Status {0}, {1} ms, {2}]]></string>
+	<string name="_bx_analytics_txt_api_neo"><![CDATA[Request of a NEO element]]></string>
+	<string name="_bx_analytics_txt_api_no_access"><![CDATA[API access by key and by origin are both off, so api.php refuses every client. The playground still runs reports here.]]></string>
+	<string name="_bx_analytics_txt_api_no_params"><![CDATA[This report has no parameters of its own.]]></string>
+	<string name="_bx_analytics_txt_api_params"><![CDATA[Parameters (JSON)]]></string>
+	<string name="_bx_analytics_txt_api_params_hint"><![CDATA[Filters and the report's own parameters, for example {"range": "30d", "platform": "next-web", "f": {"country": "US"}}.]]></string>
+	<string name="_bx_analytics_txt_api_report"><![CDATA[Report]]></string>
+	<string name="_bx_analytics_txt_api_report_params"><![CDATA[Parameters of the {0} report]]></string>
+	<string name="_bx_analytics_txt_api_response"><![CDATA[Response]]></string>
+	<string name="_bx_analytics_txt_api_run"><![CDATA[Run]]></string>
+	<string name="_bx_analytics_txt_api_running"><![CDATA[Running...]]></string>
+	<string name="_bx_analytics_txt_api_srv"><![CDATA[PHP]]></string>
+	<string name="_bx_analytics_txt_api_truncated"><![CDATA[Only the first 1 MB is shown.]]></string>
+	<string name="_bx_analytics_txt_api_type_date"><![CDATA[A date, YYYY-MM-DD, with the custom range.]]></string>
+	<string name="_bx_analytics_txt_api_type_dims"><![CDATA[Filters as an object of dimension and value. Dimensions: {0}.]]></string>
+	<string name="_bx_analytics_txt_api_type_enum"><![CDATA[One of: {0}.]]></string>
+	<string name="_bx_analytics_txt_api_type_enum_by"><![CDATA[Depends on {0}: {1}.]]></string>
+	<string name="_bx_analytics_txt_api_type_int"><![CDATA[A number, {0} or more.]]></string>
+	<string name="_bx_analytics_txt_api_type_int_range"><![CDATA[A number from {0} to {1}.]]></string>
+	<string name="_bx_analytics_txt_api_type_string"><![CDATA[Text.]]></string>
+	<string name="_bx_analytics_txt_api_url"><![CDATA[api.php URL (needs an API key or a registered origin)]]></string>
+	<string name="_bx_analytics_txt_back_errors"><![CDATA[All errors]]></string>
+	<string name="_bx_analytics_txt_back_events"><![CDATA[All events]]></string>
+	<string name="_bx_analytics_txt_back_members"><![CDATA[All members]]></string>
+	<string name="_bx_analytics_txt_back_sessions"><![CDATA[All sessions]]></string>
+	<string name="_bx_analytics_txt_bot"><![CDATA[Bot]]></string>
+	<string name="_bx_analytics_txt_bot_pages"><![CDATA[Most requested pages]]></string>
+	<string name="_bx_analytics_txt_cancel"><![CDATA[Cancel]]></string>
+	<string name="_bx_analytics_txt_chart_bots"><![CDATA[Bot requests per day by category]]></string>
+	<string name="_bx_analytics_txt_chart_credits"><![CDATA[Credits over time]]></string>
+	<string name="_bx_analytics_txt_chart_error"><![CDATA[Occurrences of {0} over time]]></string>
+	<string name="_bx_analytics_txt_chart_event"><![CDATA[{0} over time]]></string>
+	<string name="_bx_analytics_txt_chart_label"><![CDATA[Chart of the selected figure over the period]]></string>
+	<string name="_bx_analytics_txt_chart_mrr"><![CDATA[Monthly recurring revenue over time]]></string>
+	<string name="_bx_analytics_txt_chart_query"><![CDATA[{0} over time for the first {1} rows of the table]]></string>
+	<string name="_bx_analytics_txt_chart_revenue"><![CDATA[Revenue over time]]></string>
+	<string name="_bx_analytics_txt_chart_visitors_7d"><![CDATA[Unique visitors over the last 7 days]]></string>
+	<string name="_bx_analytics_txt_chart_vital"><![CDATA[{0}: 75th percentile over time]]></string>
+	<string name="_bx_analytics_txt_check_bot"><![CDATA[This browser identifies as a bot, so it is counted with the bots.]]></string>
+	<string name="_bx_analytics_txt_check_cross_site"><![CDATA[The request came from another site.]]></string>
+	<string name="_bx_analytics_txt_check_dnt"><![CDATA[This browser asks not to be tracked (Do Not Track or Global Privacy Control), and the site respects it.]]></string>
+	<string name="_bx_analytics_txt_check_excluded_ip"><![CDATA[Your address is excluded (Settings > Excluded addresses).]]></string>
+	<string name="_bx_analytics_txt_check_excluded_level"><![CDATA[Your membership level is excluded (Settings > Excluded levels).]]></string>
+	<string name="_bx_analytics_txt_check_excluded_path"><![CDATA[The home page is on an excluded path (Settings > Excluded paths).]]></string>
+	<string name="_bx_analytics_txt_check_geo"><![CDATA[Country]]></string>
+	<string name="_bx_analytics_txt_check_geo_header"><![CDATA[{1}, from the {0} header]]></string>
+	<string name="_bx_analytics_txt_check_geo_none"><![CDATA[No country header in this request]]></string>
+	<string name="_bx_analytics_txt_check_geo_off"><![CDATA[Not read: the location source is off]]></string>
+	<string name="_bx_analytics_txt_check_ip"><![CDATA[Address recorded]]></string>
+	<string name="_bx_analytics_txt_check_link_members_off"><![CDATA[Visits are recorded but not tied to your account: linking members to visits is off.]]></string>
+	<string name="_bx_analytics_txt_check_no_hits"><![CDATA[The page address is not one of the site's own.]]></string>
+	<string name="_bx_analytics_txt_check_not_tracked"><![CDATA[Visits from this browser are not recorded]]></string>
+	<string name="_bx_analytics_txt_check_platform_off"><![CDATA[The classic site is not among the tracked platforms (Settings > Platforms).]]></string>
+	<string name="_bx_analytics_txt_check_rate_limited"><![CDATA[Your address has sent more than the limits allow (Settings > Hits per minute, New visits per hour). Its hits are dropped until the next minute, or new visits until the next hour.]]></string>
+	<string name="_bx_analytics_txt_check_remote_addr"><![CDATA[Connection from (REMOTE_ADDR)]]></string>
+	<string name="_bx_analytics_txt_check_tracked"><![CDATA[Visits from this browser are recorded]]></string>
+	<string name="_bx_analytics_txt_check_tracking_off"><![CDATA[Tracking is off.]]></string>
+	<string name="_bx_analytics_txt_cohort"><![CDATA[First visit, week of]]></string>
+	<string name="_bx_analytics_txt_conversions"><![CDATA[{0} visits, {1} visitors]]></string>
+	<string name="_bx_analytics_txt_copied"><![CDATA[Copied]]></string>
+	<string name="_bx_analytics_txt_copy"><![CDATA[Copy]]></string>
+	<string name="_bx_analytics_txt_export_to_csv_title"><![CDATA[Export to CSV file]]></string>
+	<string name="_bx_analytics_txt_fatal"><![CDATA[Fatal]]></string>
+	<string name="_bx_analytics_txt_filter_by"><![CDATA[Show only {0}]]></string>
+	<string name="_bx_analytics_txt_first_seen"><![CDATA[First seen]]></string>
+	<string name="_bx_analytics_txt_funnel_delete_confirm"><![CDATA[Delete the funnel "{0}"?]]></string>
+	<string name="_bx_analytics_txt_funnel_hint"><![CDATA[Fill in 2 to 6 steps in the order visitors take them. A visit counts for a step when it reaches that step after the previous one. Each step is a page path (*view-post* for the post page on every platform) or an action (signup, content_add@bx_posts).]]></string>
+	<string name="_bx_analytics_txt_funnel_invalid"><![CDATA[Enter a name and 2 to 6 steps. Page paths start with / (or *); action names use letters, digits and _ . : / -]]></string>
+	<string name="_bx_analytics_txt_goal_badge_event"><![CDATA[Action]]></string>
+	<string name="_bx_analytics_txt_goal_badge_page"><![CDATA[Page]]></string>
+	<string name="_bx_analytics_txt_goal_delete_confirm"><![CDATA[Delete the goal "{0}"?]]></string>
+	<string name="_bx_analytics_txt_goal_invalid"><![CDATA[Enter a name and a page path that starts with / (or *), or an action name of letters, digits and _ . : / -]]></string>
+	<string name="_bx_analytics_txt_goal_limit"><![CDATA[There can be up to {0} goals. Delete one to add another.]]></string>
+	<string name="_bx_analytics_txt_goal_match"><![CDATA[Page path or action name]]></string>
+	<string name="_bx_analytics_txt_goal_type"><![CDATA[Completed when a visitor]]></string>
+	<string name="_bx_analytics_txt_goal_type_event"><![CDATA[Does an action]]></string>
+	<string name="_bx_analytics_txt_goal_type_page"><![CDATA[Views a page]]></string>
+	<string name="_bx_analytics_txt_guest"><![CDATA[Guest]]></string>
+	<string name="_bx_analytics_txt_import_again"><![CDATA[Import again]]></string>
+	<string name="_bx_analytics_txt_import_confirm"><![CDATA[Read every past payment again? Payments that are already here are skipped.]]></string>
+	<string name="_bx_analytics_txt_import_continue"><![CDATA[Continue now]]></string>
+	<string name="_bx_analytics_txt_import_done"><![CDATA[Past payments are imported: {0} in all.]]></string>
+	<string name="_bx_analytics_txt_import_hint"><![CDATA[The import runs every hour until it is done. Refunded orders can't be imported, because the payment module deletes them, and imported payments have no platform or visit.]]></string>
+	<string name="_bx_analytics_txt_import_running"><![CDATA[Importing past payments: {0} imported so far.]]></string>
+	<string name="_bx_analytics_txt_import_title"><![CDATA[Past payments]]></string>
+	<string name="_bx_analytics_txt_total_content"><![CDATA[All content]]></string>
+	<string name="_bx_analytics_type_top_by_views_label"><![CDATA[Views]]></string>
+	<string name="_bx_analytics_type_top_by_likes_label"><![CDATA[Likes]]></string>
+	<string name="_bx_analytics_type_top_by_followers_label"><![CDATA[Followed]]></string>
+	<string name="_bx_analytics_type_top_by_activity_label"><![CDATA[Content]]></string>
+	<string name="_bx_analytics_acl_action_manage"><![CDATA[Manage analytics goals and funnels]]></string>
+	<string name="_bx_analytics_acl_action_use_analytics"><![CDATA[Use Analytics]]></string>	
+	<string name="_bx_analytics_acl_action_view_people"><![CDATA[View members in analytics]]></string>
+	<string name="_bx_analytics_acl_action_view_revenue"><![CDATA[View analytics revenue]]></string>
+	<string name="_bx_analytics_action_comment"><![CDATA[Comment]]></string>
+	<string name="_bx_analytics_action_connection"><![CDATA[Connection]]></string>
+	<string name="_bx_analytics_action_content_add"><![CDATA[Content created]]></string>
+	<string name="_bx_analytics_action_login"><![CDATA[Login]]></string>
+	<string name="_bx_analytics_action_profile_add"><![CDATA[Profile created]]></string>
+	<string name="_bx_analytics_action_purchase"><![CDATA[Purchase]]></string>
+	<string name="_bx_analytics_action_renewal"><![CDATA[Renewal]]></string>
+	<string name="_bx_analytics_action_signup"><![CDATA[Sign-up]]></string>
+	<string name="_bx_analytics_action_trial_start"><![CDATA[Trial started]]></string>
+	<string name="_bx_analytics_action_vote"><![CDATA[Reaction]]></string>
+</resources>

+ 29 - 0
modules/boonex/analytics/updates/15.0.1_15.0.2/source/install/sql/disable.sql

@@ -0,0 +1,29 @@
+SET @sName = 'bx_analytics';
+
+-- SETTINGS
+SET @iTypeId = (SELECT `ID` FROM `sys_options_types` WHERE `name` = @sName LIMIT 1);
+DELETE FROM `sys_options` WHERE `category_id` IN (SELECT `ID` FROM `sys_options_categories` WHERE `type_id` = @iTypeId);
+DELETE FROM `sys_options_categories` WHERE `type_id` = @iTypeId;
+DELETE FROM `sys_options_types` WHERE `id` = @iTypeId;
+
+-- PAGES
+DELETE FROM `sys_objects_page` WHERE `module` = @sName;
+DELETE FROM `sys_pages_blocks` WHERE `module` = @sName;
+
+-- MENU
+DELETE FROM `sys_menu_items` WHERE `module` = @sName ;
+
+-- ACL
+DELETE `sys_acl_actions`, `sys_acl_matrix` FROM `sys_acl_actions`, `sys_acl_matrix` WHERE `sys_acl_matrix`.`IDAction` = `sys_acl_actions`.`ID` AND `sys_acl_actions`.`Module` = @sName;
+DELETE FROM `sys_acl_actions` WHERE `Module` = @sName;
+
+-- INJECTIONS
+DELETE FROM `sys_injections` WHERE `name` = 'bx_analytics_tracker';
+
+-- ALERTS
+SET @iHandler := (SELECT `id` FROM `sys_alerts_handlers` WHERE `name` = @sName LIMIT 1);
+DELETE FROM `sys_alerts` WHERE `handler_id` = @iHandler;
+DELETE FROM `sys_alerts_handlers` WHERE `id` = @iHandler;
+
+-- CRON
+DELETE FROM `sys_cron_jobs` WHERE `name` = 'bx_analytics_pruning';

+ 32 - 0
modules/boonex/analytics/updates/15.0.1_15.0.2/source/install/sql/enable-app.sql

@@ -0,0 +1,32 @@
+-- PAGES: config_api
+
+-- PAGES: active_api
+UPDATE `sys_pages_blocks` SET `active_api`=1 WHERE `object`='bx_analytics_page' AND `module`='bx_analytics' AND `title_system`='_bx_analytics_page_block_title_system_filters' AND `title`='_bx_analytics_page_block_title_filters';
+UPDATE `sys_pages_blocks` SET `active_api`=1 WHERE `object`='bx_analytics_page' AND `module`='bx_analytics' AND `title_system`='_bx_analytics_page_block_title_system_overview' AND `title`='_bx_analytics_page_block_title_overview';
+UPDATE `sys_pages_blocks` SET `active_api`=1 WHERE `object`='bx_analytics_page' AND `module`='bx_analytics' AND `title_system`='_bx_analytics_page_block_title_system_top_pages' AND `title`='_bx_analytics_page_block_title_top_pages';
+UPDATE `sys_pages_blocks` SET `active_api`=1 WHERE `object`='bx_analytics_page' AND `module`='bx_analytics' AND `title_system`='_bx_analytics_page_block_title_system_top_sources' AND `title`='_bx_analytics_page_block_title_top_sources';
+UPDATE `sys_pages_blocks` SET `active_api`=1 WHERE `object`='bx_analytics_page' AND `module`='bx_analytics' AND `title_system`='_bx_analytics_page_block_title_system_top_locations' AND `title`='_bx_analytics_page_block_title_top_locations';
+UPDATE `sys_pages_blocks` SET `active_api`=1 WHERE `object`='bx_analytics_page' AND `module`='bx_analytics' AND `title_system`='_bx_analytics_page_block_title_system_top_devices' AND `title`='_bx_analytics_page_block_title_top_devices';
+UPDATE `sys_pages_blocks` SET `active_api`=1 WHERE `object`='bx_analytics_page' AND `module`='bx_analytics' AND `title_system`='_bx_analytics_page_block_title_system_top_platforms' AND `title`='_bx_analytics_page_block_title_top_platforms';
+-- library blocks (object ''), copies the page builder places on other pages; only reports NEO has an element for
+UPDATE `sys_pages_blocks` SET `active_api`=1 WHERE `object`='' AND `module`='bx_analytics' AND `title_system`='_bx_analytics_page_block_title_system_filters' AND `title`='_bx_analytics_page_block_title_filters';
+UPDATE `sys_pages_blocks` SET `active_api`=1 WHERE `object`='' AND `module`='bx_analytics' AND `title_system`='_bx_analytics_page_block_title_system_overview' AND `title`='_bx_analytics_page_block_title_overview';
+UPDATE `sys_pages_blocks` SET `active_api`=1 WHERE `object`='' AND `module`='bx_analytics' AND `title_system`='_bx_analytics_page_block_title_system_top_pages' AND `title`='_bx_analytics_page_block_title_top_pages';
+UPDATE `sys_pages_blocks` SET `active_api`=1 WHERE `object`='' AND `module`='bx_analytics' AND `title_system`='_bx_analytics_page_block_title_system_top_sources' AND `title`='_bx_analytics_page_block_title_top_sources';
+UPDATE `sys_pages_blocks` SET `active_api`=1 WHERE `object`='' AND `module`='bx_analytics' AND `title_system`='_bx_analytics_page_block_title_system_top_locations' AND `title`='_bx_analytics_page_block_title_top_locations';
+UPDATE `sys_pages_blocks` SET `active_api`=1 WHERE `object`='' AND `module`='bx_analytics' AND `title_system`='_bx_analytics_page_block_title_system_top_devices' AND `title`='_bx_analytics_page_block_title_top_devices';
+UPDATE `sys_pages_blocks` SET `active_api`=1 WHERE `object`='' AND `module`='bx_analytics' AND `title_system`='_bx_analytics_page_block_title_system_top_platforms' AND `title`='_bx_analytics_page_block_title_top_platforms';
+UPDATE `sys_pages_blocks` SET `active_api`=1 WHERE `object`='' AND `module`='bx_analytics' AND `title_system`='_bx_analytics_page_block_title_system_revenue' AND `title`='_bx_analytics_page_block_title_revenue';
+UPDATE `sys_pages_blocks` SET `active_api`=1 WHERE `object`='' AND `module`='bx_analytics' AND `title_system`='_bx_analytics_page_block_title_system_subscriptions' AND `title`='_bx_analytics_page_block_title_subscriptions';
+UPDATE `sys_pages_blocks` SET `active_api`=1 WHERE `object`='' AND `module`='bx_analytics' AND `title_system`='_bx_analytics_page_block_title_system_sales' AND `title`='_bx_analytics_page_block_title_sales';
+UPDATE `sys_pages_blocks` SET `active_api`=1 WHERE `object`='' AND `module`='bx_analytics' AND `title_system`='_bx_analytics_page_block_title_system_credits' AND `title`='_bx_analytics_page_block_title_credits';
+-- the revenue page (account dashboard > Revenue); its blocks return nothing without "view analytics revenue"
+UPDATE `sys_pages_blocks` SET `active_api`=1 WHERE `object`='bx_analytics_revenue' AND `module`='bx_analytics';
+
+-- MENUS:
+
+-- MENUS: config_api
+
+-- MENUS: active_api
+UPDATE `sys_menu_items` SET `active_api`=1 WHERE `set_name`='sys_account_dashboard' AND `module`='bx_analytics' AND `name`='dashboard-analytics';
+UPDATE `sys_menu_items` SET `active_api`=1 WHERE `set_name`='sys_account_dashboard' AND `module`='bx_analytics' AND `name`='analytics-revenue';

+ 186 - 0
modules/boonex/analytics/updates/15.0.1_15.0.2/source/install/sql/enable.sql

@@ -0,0 +1,186 @@
+SET @sName = 'bx_analytics';
+
+-- SETTINGS
+SET @iTypeOrder = (SELECT IFNULL(MAX(`order`), 0) + 1 FROM `sys_options_types` WHERE `group` = 'modules');
+
+INSERT INTO `sys_options_types` (`group`, `name`, `caption`, `icon`, `order`)
+VALUES('modules', @sName, '_bx_analytics_adm_stg_cpt_type', 'bx_analytics@modules/boonex/analytics/|std-icon.svg', IF(ISNULL(@iTypeOrder), 1, @iTypeOrder + 1));
+
+SET @iTypeId = LAST_INSERT_ID();
+
+INSERT INTO `sys_options_categories` (`type_id`, `name`, `caption`, `hidden`, `order`) VALUES(@iTypeId, 'bx_analytics_tracking', '_bx_analytics_adm_stg_cpt_category_tracking', 0, 1);
+SET @iCategoryId = LAST_INSERT_ID();
+
+INSERT INTO `sys_options` (`category_id`, `name`, `caption`, `info`, `value`, `type`, `extra`, `check`, `check_params`, `check_error`, `order`) VALUES
+(@iCategoryId, 'bx_analytics_tracking', '_bx_analytics_adm_stg_cpt_tracking', '_bx_analytics_adm_stg_inf_tracking', 'on', 'checkbox', '', '', '', '', 1),
+(@iCategoryId, 'bx_analytics_platforms', '_bx_analytics_adm_stg_cpt_platforms', '_bx_analytics_adm_stg_inf_platforms', 'core-web,next-web,rn-ios,rn-android,api,core', 'list', 'a:2:{s:6:"module";s:12:"bx_analytics";s:6:"method";s:21:"get_options_platforms";}', '', '', '', 2),
+(@iCategoryId, 'bx_analytics_session_timeout', '_bx_analytics_adm_stg_cpt_session_timeout', '_bx_analytics_adm_stg_inf_session_timeout', '30', 'digit', '', '', '', '', 3),
+(@iCategoryId, 'bx_analytics_online_window', '_bx_analytics_adm_stg_cpt_online_window', '_bx_analytics_adm_stg_inf_online_window', '5', 'digit', '', '', '', '', 4),
+(@iCategoryId, 'bx_analytics_ignore_params', '_bx_analytics_adm_stg_cpt_ignore_params', '_bx_analytics_adm_stg_inf_ignore_params', 'range, tz, platform, metric, bucket, from, to, jfrom, f[*], tab_*, page_*, q_*', 'text', '', '', '', '', 5),
+(@iCategoryId, 'bx_analytics_track_bots', '_bx_analytics_adm_stg_cpt_track_bots', '_bx_analytics_adm_stg_inf_track_bots', 'on', 'checkbox', '', '', '', '', 6),
+(@iCategoryId, 'bx_analytics_track_actions', '_bx_analytics_adm_stg_cpt_track_actions', '_bx_analytics_adm_stg_inf_track_actions', 'on', 'checkbox', '', '', '', '', 7),
+(@iCategoryId, 'bx_analytics_limit_hits', '_bx_analytics_adm_stg_cpt_limit_hits', '_bx_analytics_adm_stg_inf_limit_hits', '300', 'digit', '', '', '', '', 8),
+(@iCategoryId, 'bx_analytics_limit_visits', '_bx_analytics_adm_stg_cpt_limit_visits', '_bx_analytics_adm_stg_inf_limit_visits', '100', 'digit', '', '', '', '', 9);
+
+INSERT INTO `sys_options_categories` (`type_id`, `name`, `caption`, `hidden`, `order`) VALUES(@iTypeId, 'bx_analytics_privacy', '_bx_analytics_adm_stg_cpt_category_privacy', 0, 2);
+SET @iCategoryId = LAST_INSERT_ID();
+
+INSERT INTO `sys_options` (`category_id`, `name`, `caption`, `info`, `value`, `type`, `extra`, `check`, `check_params`, `check_error`, `order`) VALUES
+(@iCategoryId, 'bx_analytics_identity', '_bx_analytics_adm_stg_cpt_identity', '_bx_analytics_adm_stg_inf_identity', 'cookieless', 'select', 'a:2:{s:6:"module";s:12:"bx_analytics";s:6:"method";s:20:"get_options_identity";}', '', '', '', 1),
+(@iCategoryId, 'bx_analytics_link_members', '_bx_analytics_adm_stg_cpt_link_members', '_bx_analytics_adm_stg_inf_link_members', 'on', 'checkbox', '', '', '', '', 2),
+(@iCategoryId, 'bx_analytics_respect_dnt', '_bx_analytics_adm_stg_cpt_respect_dnt', '_bx_analytics_adm_stg_inf_respect_dnt', 'on', 'checkbox', '', '', '', '', 3),
+(@iCategoryId, 'bx_analytics_exclude_levels', '_bx_analytics_adm_stg_cpt_exclude_levels', '_bx_analytics_adm_stg_inf_exclude_levels', '', 'list', 'a:4:{s:6:"module";s:6:"system";s:6:"method";s:15:"get_memberships";s:6:"params";a:0:{}s:5:"class";s:18:"TemplAuditServices";}', '', '', '', 4),
+(@iCategoryId, 'bx_analytics_exclude_ips', '_bx_analytics_adm_stg_cpt_exclude_ips', '_bx_analytics_adm_stg_inf_exclude_ips', '', 'text', '', '', '', '', 5),
+(@iCategoryId, 'bx_analytics_exclude_paths', '_bx_analytics_adm_stg_cpt_exclude_paths', '_bx_analytics_adm_stg_inf_exclude_paths', '', 'text', '', '', '', '', 6),
+(@iCategoryId, 'bx_analytics_trusted_proxies', '_bx_analytics_adm_stg_cpt_trusted_proxies', '_bx_analytics_adm_stg_inf_trusted_proxies', '', 'text', '', '', '', '', 7),
+(@iCategoryId, 'bx_analytics_geo_source', '_bx_analytics_adm_stg_cpt_geo_source', '_bx_analytics_adm_stg_inf_geo_source', 'headers', 'select', 'a:2:{s:6:"module";s:12:"bx_analytics";s:6:"method";s:22:"get_options_geo_source";}', '', '', '', 8),
+(@iCategoryId, 'bx_analytics_retention_days', '_bx_analytics_adm_stg_cpt_retention_days', '_bx_analytics_adm_stg_inf_retention_days', '400', 'digit', '', '', '', '', 9);
+
+INSERT INTO `sys_options_categories` (`type_id`, `name`, `caption`, `hidden`, `order`) VALUES(@iTypeId, 'bx_analytics_health', '_bx_analytics_adm_stg_cpt_category_health', 0, 3);
+SET @iCategoryId = LAST_INSERT_ID();
+
+INSERT INTO `sys_options` (`category_id`, `name`, `caption`, `info`, `value`, `type`, `extra`, `check`, `check_params`, `check_error`, `order`) VALUES
+(@iCategoryId, 'bx_analytics_track_errors', '_bx_analytics_adm_stg_cpt_track_errors', '_bx_analytics_adm_stg_inf_track_errors', 'on', 'checkbox', '', '', '', '', 1),
+(@iCategoryId, 'bx_analytics_errors_ignore', '_bx_analytics_adm_stg_cpt_errors_ignore', '_bx_analytics_adm_stg_inf_errors_ignore', 'ResizeObserver loop*\nScript error.*\n*Non-Error promise rejection*', 'text', '', '', '', '', 2),
+(@iCategoryId, 'bx_analytics_track_vitals', '_bx_analytics_adm_stg_cpt_track_vitals', '_bx_analytics_adm_stg_inf_track_vitals', 'on', 'checkbox', '', '', '', '', 3),
+(@iCategoryId, 'bx_analytics_vitals_sample', '_bx_analytics_adm_stg_cpt_vitals_sample', '_bx_analytics_adm_stg_inf_vitals_sample', '100', 'digit', '', '', '', '', 4),
+(@iCategoryId, 'bx_analytics_vitals_retention_days', '_bx_analytics_adm_stg_cpt_vitals_retention_days', '_bx_analytics_adm_stg_inf_vitals_retention_days', '90', 'digit', '', '', '', '', 5);
+
+INSERT INTO `sys_options_categories` (`type_id`, `name`, `caption`, `hidden`, `order`) VALUES(@iTypeId,  'bx_analytics_general', '_bx_analytics_adm_stg_cpt_category_content', 0, 4);
+SET @iCategoryId = LAST_INSERT_ID();
+
+INSERT INTO `sys_options` (`category_id`, `name`, `caption`, `value`, `type`, `extra`, `check`, `check_params`, `check_error`, `order`) VALUES
+(@iCategoryId, 'bx_analytics_modules_disabled', '_bx_analytics_adm_stg_cpt_modules_enabled', '', 'rlist', 'a:2:{s:6:"module";s:12:"bx_analytics";s:6:"method";s:11:"get_modules";}', '', '', '', 1),
+(@iCategoryId, 'bx_analytics_items_count', '_bx_analytics_adm_stg_cpt_items_count', '20', 'digit', '', '', '', '', 2),
+(@iCategoryId, 'bx_analytics_default_interval_day', '_bx_analytics_adm_stg_cpt_default_interval_day', '90', 'digit', '', '', '', '', 3);
+
+-- hidden: where the import of past payments stands; the transactions cursor is the last
+-- imported transaction id (-1 when done), the subscriptions cursor is "active:<pending id>",
+-- "deleted:<pending id>" or -1
+INSERT INTO `sys_options_categories` (`type_id`, `name`, `caption`, `hidden`, `order`) VALUES(@iTypeId, 'bx_analytics_system', '_bx_analytics_adm_stg_cpt_category_system', 1, 5);
+SET @iCategoryId = LAST_INSERT_ID();
+
+INSERT INTO `sys_options` (`category_id`, `name`, `caption`, `value`, `type`, `extra`, `check`, `check_params`, `check_error`, `order`) VALUES
+(@iCategoryId, 'bx_analytics_revenue_backfill', '', '0', 'digit', '', '', '', '', 1),
+(@iCategoryId, 'bx_analytics_revenue_backfill_sbs', '', '', 'text', '', '', '', '', 2);
+
+-- PAGES & BLOCKS
+INSERT INTO `sys_objects_page`(`object`, `title_system`, `title`, `module`, `layout_id`, `visible_for_levels`, `visible_for_levels_editable`, `uri`, `url`, `meta_description`, `meta_keywords`, `meta_robots`, `cache_lifetime`, `cache_editable`, `deletable`, `override_class_name`, `override_class_file`) VALUES
+('bx_analytics_page', '_bx_analytics_page_title_sys_page', '_bx_analytics_page_title_page', @sName, 12, 2147483647, 1, 'analytics', '', '', '', 'noindex', 0, 1, 0, 'BxAnalyticsPage', 'modules/boonex/analytics/classes/BxAnalyticsPage.php'),
+('bx_analytics_revenue', '_bx_analytics_page_title_sys_revenue', '_bx_analytics_page_title_revenue', @sName, 5, 2147483647, 1, 'analytics-revenue', '', '', '', 'noindex', 0, 1, 0, 'BxAnalyticsPage', 'modules/boonex/analytics/classes/BxAnalyticsPage.php');
+
+INSERT INTO `sys_pages_blocks` (`object`, `cell_id`, `module`, `title_system`, `title`, `designbox_id`, `visible_for_levels`, `type`, `content`, `deletable`, `copyable`, `active`, `order`) VALUES
+('bx_analytics_page', 2, @sName, '_bx_analytics_page_block_title_system_filters', '_bx_analytics_page_block_title_filters', 11, 2147483647, 'service', 'a:2:{s:6:"module";s:12:"bx_analytics";s:6:"method";s:17:"get_block_filters";}', 0, 1, 1, 1),
+('bx_analytics_page', 2, @sName, '_bx_analytics_page_block_title_system_overview', '_bx_analytics_page_block_title_overview', 11, 2147483647, 'service', 'a:2:{s:6:"module";s:12:"bx_analytics";s:6:"method";s:18:"get_block_overview";}', 0, 1, 1, 2),
+('bx_analytics_page', 2, @sName, '_bx_analytics_page_block_title_system_top_pages', '_bx_analytics_page_block_title_top_pages', 11, 2147483647, 'service', 'a:3:{s:6:"module";s:12:"bx_analytics";s:6:"method";s:13:"get_block_top";s:6:"params";a:1:{i:0;s:5:"pages";}}', 0, 1, 1, 3),
+('bx_analytics_page', 2, @sName, '_bx_analytics_page_block_title_system_top_sources', '_bx_analytics_page_block_title_top_sources', 11, 2147483647, 'service', 'a:3:{s:6:"module";s:12:"bx_analytics";s:6:"method";s:13:"get_block_top";s:6:"params";a:1:{i:0;s:7:"sources";}}', 0, 1, 1, 4),
+('bx_analytics_page', 3, @sName, '_bx_analytics_page_block_title_system_top_locations', '_bx_analytics_page_block_title_top_locations', 11, 2147483647, 'service', 'a:3:{s:6:"module";s:12:"bx_analytics";s:6:"method";s:13:"get_block_top";s:6:"params";a:1:{i:0;s:9:"locations";}}', 0, 1, 1, 1),
+('bx_analytics_page', 3, @sName, '_bx_analytics_page_block_title_system_top_devices', '_bx_analytics_page_block_title_top_devices', 11, 2147483647, 'service', 'a:3:{s:6:"module";s:12:"bx_analytics";s:6:"method";s:13:"get_block_top";s:6:"params";a:1:{i:0;s:7:"devices";}}', 0, 1, 1, 2),
+('bx_analytics_page', 3, @sName, '_bx_analytics_page_block_title_system_top_platforms', '_bx_analytics_page_block_title_top_platforms', 11, 2147483647, 'service', 'a:3:{s:6:"module";s:12:"bx_analytics";s:6:"method";s:13:"get_block_top";s:6:"params";a:1:{i:0;s:9:"platforms";}}', 0, 1, 1, 3),
+('bx_analytics_page', 4, @sName, '_bx_analytics_page_block_title_system_canvas', '_bx_analytics_page_block_title_canvas', 11, 2147483647, 'service', 'a:2:{s:6:"module";s:12:"bx_analytics";s:6:"method";s:10:"get_canvas";}', 0, 1, 1, 1),
+('bx_analytics_revenue', 1, @sName, '_bx_analytics_page_block_title_system_filters', '_bx_analytics_page_block_title_filters', 11, 2147483647, 'service', 'a:2:{s:6:"module";s:12:"bx_analytics";s:6:"method";s:17:"get_block_filters";}', 0, 1, 1, 1),
+('bx_analytics_revenue', 1, @sName, '_bx_analytics_page_block_title_system_revenue', '_bx_analytics_page_block_title_revenue', 11, 2147483647, 'service', 'a:3:{s:6:"module";s:12:"bx_analytics";s:6:"method";s:16:"get_block_report";s:6:"params";a:1:{i:0;s:7:"revenue";}}', 0, 1, 1, 2),
+('bx_analytics_revenue', 1, @sName, '_bx_analytics_page_block_title_system_subscriptions', '_bx_analytics_page_block_title_subscriptions', 11, 2147483647, 'service', 'a:3:{s:6:"module";s:12:"bx_analytics";s:6:"method";s:16:"get_block_report";s:6:"params";a:1:{i:0;s:13:"subscriptions";}}', 0, 1, 1, 3),
+('bx_analytics_revenue', 1, @sName, '_bx_analytics_page_block_title_system_sales', '_bx_analytics_page_block_title_sales', 11, 2147483647, 'service', 'a:3:{s:6:"module";s:12:"bx_analytics";s:6:"method";s:16:"get_block_report";s:6:"params";a:1:{i:0;s:5:"sales";}}', 0, 1, 1, 4),
+('bx_analytics_revenue', 1, @sName, '_bx_analytics_page_block_title_system_credits', '_bx_analytics_page_block_title_credits', 11, 2147483647, 'service', 'a:3:{s:6:"module";s:12:"bx_analytics";s:6:"method";s:16:"get_block_report";s:6:"params";a:1:{i:0;s:7:"credits";}}', 0, 1, 1, 5);
+
+-- BLOCKS: every report for the page builder
+INSERT INTO `sys_pages_blocks` (`object`, `cell_id`, `module`, `title_system`, `title`, `designbox_id`, `visible_for_levels`, `type`, `content`, `deletable`, `copyable`, `active`, `order`) VALUES
+('', 0, @sName, '_bx_analytics_page_block_title_system_filters', '_bx_analytics_page_block_title_filters', 11, 2147483647, 'service', 'a:2:{s:6:"module";s:12:"bx_analytics";s:6:"method";s:17:"get_block_filters";}', 0, 1, 1, 1),
+('', 0, @sName, '_bx_analytics_page_block_title_system_overview', '_bx_analytics_page_block_title_overview', 11, 2147483647, 'service', 'a:2:{s:6:"module";s:12:"bx_analytics";s:6:"method";s:18:"get_block_overview";}', 0, 1, 1, 2),
+('', 0, @sName, '_bx_analytics_page_block_title_system_top_pages', '_bx_analytics_page_block_title_top_pages', 11, 2147483647, 'service', 'a:3:{s:6:"module";s:12:"bx_analytics";s:6:"method";s:13:"get_block_top";s:6:"params";a:1:{i:0;s:5:"pages";}}', 0, 1, 1, 3),
+('', 0, @sName, '_bx_analytics_page_block_title_system_top_sources', '_bx_analytics_page_block_title_top_sources', 11, 2147483647, 'service', 'a:3:{s:6:"module";s:12:"bx_analytics";s:6:"method";s:13:"get_block_top";s:6:"params";a:1:{i:0;s:7:"sources";}}', 0, 1, 1, 4),
+('', 0, @sName, '_bx_analytics_page_block_title_system_top_locations', '_bx_analytics_page_block_title_top_locations', 11, 2147483647, 'service', 'a:3:{s:6:"module";s:12:"bx_analytics";s:6:"method";s:13:"get_block_top";s:6:"params";a:1:{i:0;s:9:"locations";}}', 0, 1, 1, 5),
+('', 0, @sName, '_bx_analytics_page_block_title_system_top_devices', '_bx_analytics_page_block_title_top_devices', 11, 2147483647, 'service', 'a:3:{s:6:"module";s:12:"bx_analytics";s:6:"method";s:13:"get_block_top";s:6:"params";a:1:{i:0;s:7:"devices";}}', 0, 1, 1, 6),
+('', 0, @sName, '_bx_analytics_page_block_title_system_top_platforms', '_bx_analytics_page_block_title_top_platforms', 11, 2147483647, 'service', 'a:3:{s:6:"module";s:12:"bx_analytics";s:6:"method";s:13:"get_block_top";s:6:"params";a:1:{i:0;s:9:"platforms";}}', 0, 1, 1, 7),
+('', 0, @sName, '_bx_analytics_page_block_title_system_table_pages', '_bx_analytics_page_block_title_table_pages', 11, 2147483647, 'service', 'a:3:{s:6:"module";s:12:"bx_analytics";s:6:"method";s:15:"get_block_table";s:6:"params";a:1:{i:0;s:5:"pages";}}', 0, 1, 1, 8),
+('', 0, @sName, '_bx_analytics_page_block_title_system_table_sources', '_bx_analytics_page_block_title_table_sources', 11, 2147483647, 'service', 'a:3:{s:6:"module";s:12:"bx_analytics";s:6:"method";s:15:"get_block_table";s:6:"params";a:1:{i:0;s:7:"sources";}}', 0, 1, 1, 9),
+('', 0, @sName, '_bx_analytics_page_block_title_system_realtime', '_bx_analytics_page_block_title_realtime', 11, 2147483647, 'service', 'a:3:{s:6:"module";s:12:"bx_analytics";s:6:"method";s:16:"get_block_report";s:6:"params";a:1:{i:0;s:8:"realtime";}}', 0, 1, 1, 10),
+('', 0, @sName, '_bx_analytics_page_block_title_system_map', '_bx_analytics_page_block_title_map', 11, 2147483647, 'service', 'a:3:{s:6:"module";s:12:"bx_analytics";s:6:"method";s:16:"get_block_report";s:6:"params";a:1:{i:0;s:3:"map";}}', 0, 1, 1, 11),
+('', 0, @sName, '_bx_analytics_page_block_title_system_bots', '_bx_analytics_page_block_title_bots', 11, 2147483647, 'service', 'a:3:{s:6:"module";s:12:"bx_analytics";s:6:"method";s:16:"get_block_report";s:6:"params";a:1:{i:0;s:4:"bots";}}', 0, 1, 1, 12),
+('', 0, @sName, '_bx_analytics_page_block_title_system_events', '_bx_analytics_page_block_title_events', 11, 2147483647, 'service', 'a:3:{s:6:"module";s:12:"bx_analytics";s:6:"method";s:16:"get_block_report";s:6:"params";a:1:{i:0;s:6:"events";}}', 0, 1, 1, 13),
+('', 0, @sName, '_bx_analytics_page_block_title_system_sessions', '_bx_analytics_page_block_title_sessions', 11, 2147483647, 'service', 'a:3:{s:6:"module";s:12:"bx_analytics";s:6:"method";s:16:"get_block_report";s:6:"params";a:1:{i:0;s:8:"sessions";}}', 0, 1, 1, 14),
+('', 0, @sName, '_bx_analytics_page_block_title_system_users', '_bx_analytics_page_block_title_users', 11, 2147483647, 'service', 'a:3:{s:6:"module";s:12:"bx_analytics";s:6:"method";s:16:"get_block_report";s:6:"params";a:1:{i:0;s:5:"users";}}', 0, 1, 1, 15),
+('', 0, @sName, '_bx_analytics_page_block_title_system_journeys', '_bx_analytics_page_block_title_journeys', 11, 2147483647, 'service', 'a:3:{s:6:"module";s:12:"bx_analytics";s:6:"method";s:16:"get_block_report";s:6:"params";a:1:{i:0;s:8:"journeys";}}', 0, 1, 1, 16),
+('', 0, @sName, '_bx_analytics_page_block_title_system_retention', '_bx_analytics_page_block_title_retention', 11, 2147483647, 'service', 'a:3:{s:6:"module";s:12:"bx_analytics";s:6:"method";s:16:"get_block_report";s:6:"params";a:1:{i:0;s:9:"retention";}}', 0, 1, 1, 17),
+('', 0, @sName, '_bx_analytics_page_block_title_system_goals', '_bx_analytics_page_block_title_goals', 11, 2147483647, 'service', 'a:3:{s:6:"module";s:12:"bx_analytics";s:6:"method";s:16:"get_block_report";s:6:"params";a:1:{i:0;s:5:"goals";}}', 0, 1, 1, 18),
+('', 0, @sName, '_bx_analytics_page_block_title_system_funnels', '_bx_analytics_page_block_title_funnels', 11, 2147483647, 'service', 'a:3:{s:6:"module";s:12:"bx_analytics";s:6:"method";s:16:"get_block_report";s:6:"params";a:1:{i:0;s:7:"funnels";}}', 0, 1, 1, 19),
+('', 0, @sName, '_bx_analytics_page_block_title_system_revenue', '_bx_analytics_page_block_title_revenue', 11, 2147483647, 'service', 'a:3:{s:6:"module";s:12:"bx_analytics";s:6:"method";s:16:"get_block_report";s:6:"params";a:1:{i:0;s:7:"revenue";}}', 0, 1, 1, 20),
+('', 0, @sName, '_bx_analytics_page_block_title_system_subscriptions', '_bx_analytics_page_block_title_subscriptions', 11, 2147483647, 'service', 'a:3:{s:6:"module";s:12:"bx_analytics";s:6:"method";s:16:"get_block_report";s:6:"params";a:1:{i:0;s:13:"subscriptions";}}', 0, 1, 1, 21),
+('', 0, @sName, '_bx_analytics_page_block_title_system_sales', '_bx_analytics_page_block_title_sales', 11, 2147483647, 'service', 'a:3:{s:6:"module";s:12:"bx_analytics";s:6:"method";s:16:"get_block_report";s:6:"params";a:1:{i:0;s:5:"sales";}}', 0, 1, 1, 22),
+('', 0, @sName, '_bx_analytics_page_block_title_system_credits', '_bx_analytics_page_block_title_credits', 11, 2147483647, 'service', 'a:3:{s:6:"module";s:12:"bx_analytics";s:6:"method";s:16:"get_block_report";s:6:"params";a:1:{i:0;s:7:"credits";}}', 0, 1, 1, 23),
+('', 0, @sName, '_bx_analytics_page_block_title_system_errors', '_bx_analytics_page_block_title_errors', 11, 2147483647, 'service', 'a:3:{s:6:"module";s:12:"bx_analytics";s:6:"method";s:16:"get_block_report";s:6:"params";a:1:{i:0;s:6:"errors";}}', 0, 1, 1, 24),
+('', 0, @sName, '_bx_analytics_page_block_title_system_performance', '_bx_analytics_page_block_title_performance', 11, 2147483647, 'service', 'a:3:{s:6:"module";s:12:"bx_analytics";s:6:"method";s:16:"get_block_report";s:6:"params";a:1:{i:0;s:11:"performance";}}', 0, 1, 1, 25),
+('', 0, @sName, '_bx_analytics_page_block_title_system_query', '_bx_analytics_page_block_title_query', 11, 2147483647, 'service', 'a:3:{s:6:"module";s:12:"bx_analytics";s:6:"method";s:16:"get_block_report";s:6:"params";a:1:{i:0;s:5:"query";}}', 0, 1, 1, 26),
+('', 0, @sName, '_bx_analytics_page_block_title_system_canvas', '_bx_analytics_page_block_title_canvas', 11, 2147483647, 'service', 'a:2:{s:6:"module";s:12:"bx_analytics";s:6:"method";s:10:"get_canvas";}', 0, 1, 1, 27);
+
+-- STUDIO DASHBOARD: traffic next to the core blocks
+SET @iStdDashboardOrder = (SELECT IFNULL(MAX(`order`), 0) + 1 FROM `sys_pages_blocks` WHERE `object` = 'sys_std_dashboard' AND `cell_id` = 1);
+INSERT INTO `sys_pages_blocks` (`object`, `cell_id`, `module`, `title_system`, `title`, `icon`, `designbox_id`, `class`, `tabs`, `async`, `visible_for_levels`, `type`, `content`, `deletable`, `copyable`, `active`, `order`) VALUES
+('sys_std_dashboard', 1, @sName, '', '_bx_analytics_page_block_title_std_dash_traffic', 'chart-line', 11, 'bx-dbd-block-medium', 0, 0, 2147483647, 'service', 'a:2:{s:6:"module";s:12:"bx_analytics";s:6:"method";s:28:"get_studio_dashboard_traffic";}', 0, 0, 1, @iStdDashboardOrder);
+
+-- MENU: account dashboard
+SET @iMoAccountDashboard = (SELECT IFNULL(MAX(`order`), 0) FROM `sys_menu_items` WHERE `set_name`='sys_account_dashboard' LIMIT 1);
+INSERT INTO `sys_menu_items` (`set_name`, `module`, `name`, `title_system`, `title`, `link`, `onclick`, `target`, `icon`, `addon`, `submenu_object`, `visible_for_levels`, `visibility_custom`, `active`, `copyable`, `editable`, `order`) VALUES
+('sys_account_dashboard', @sName, 'dashboard-analytics', '_bx_analytics_item_title_system_analytics', '_bx_analytics_item_title_analytics', 'page.php?i=analytics', '', '', 'chart-line', '', '', 2147483647, 'a:2:{s:6:"module";s:12:"bx_analytics";s:6:"method";s:12:"is_avaliable";}', 1, 0, 1, @iMoAccountDashboard + 1),
+('sys_account_dashboard', @sName, 'analytics-revenue', '_bx_analytics_item_title_system_revenue', '_bx_analytics_item_title_revenue', 'page.php?i=analytics-revenue', '', '', 'circle-dollar-sign', '', '', 2147483647, 'a:2:{s:6:"module";s:12:"bx_analytics";s:6:"method";s:20:"is_revenue_avaliable";}', 1, 0, 1, @iMoAccountDashboard + 2);
+
+-- ACL
+INSERT INTO `sys_acl_actions` (`Module`, `Name`, `AdditionalParamName`, `Title`, `Desc`, `Countable`, `DisabledForLevels`) VALUES
+(@sName, 'use analytics', NULL, '_bx_analytics_acl_action_use_analytics', '', 1, 3);
+SET @iIdActionUseAnalytics = LAST_INSERT_ID();
+
+INSERT INTO `sys_acl_actions` (`Module`, `Name`, `AdditionalParamName`, `Title`, `Desc`, `Countable`, `DisabledForLevels`) VALUES
+(@sName, 'view analytics people', NULL, '_bx_analytics_acl_action_view_people', '', 0, 3);
+SET @iIdActionViewPeople = LAST_INSERT_ID();
+
+INSERT INTO `sys_acl_actions` (`Module`, `Name`, `AdditionalParamName`, `Title`, `Desc`, `Countable`, `DisabledForLevels`) VALUES
+(@sName, 'manage analytics', NULL, '_bx_analytics_acl_action_manage', '', 0, 3);
+SET @iIdActionManage = LAST_INSERT_ID();
+
+INSERT INTO `sys_acl_actions` (`Module`, `Name`, `AdditionalParamName`, `Title`, `Desc`, `Countable`, `DisabledForLevels`) VALUES
+(@sName, 'view analytics revenue', NULL, '_bx_analytics_acl_action_view_revenue', '', 0, 3);
+SET @iIdActionViewRevenue = LAST_INSERT_ID();
+
+SET @iModerator = 7;
+SET @iAdministrator = 8;
+INSERT INTO `sys_acl_matrix` (`IDLevel`, `IDAction`) VALUES
+(@iModerator, @iIdActionUseAnalytics),
+(@iAdministrator, @iIdActionUseAnalytics),
+(@iAdministrator, @iIdActionViewPeople),
+(@iAdministrator, @iIdActionManage),
+(@iAdministrator, @iIdActionViewRevenue);
+
+-- INJECTIONS: the tracker on every page
+INSERT INTO `sys_injections` (`name`, `page_index`, `key`, `type`, `data`, `replace`, `active`) VALUES
+('bx_analytics_tracker', 0, 'injection_footer', 'service', 'a:2:{s:6:"module";s:12:"bx_analytics";s:6:"method";s:16:"get_tracker_code";}', 0, 1);
+
+-- ALERTS: forget deleted profiles, record site actions (new content is subscribed per
+-- module by the sync_actions service), and keep the revenue ledger from the payment alerts
+-- every payment module fires on the `system` unit
+INSERT INTO `sys_alerts_handlers` (`name`, `class`, `file`, `service_call`) VALUES
+(@sName, 'BxAnalyticsAlertsResponse', 'modules/boonex/analytics/classes/BxAnalyticsAlertsResponse.php', '');
+SET @iHandler := LAST_INSERT_ID();
+
+INSERT INTO `sys_alerts` (`unit`, `action`, `handler_id`) VALUES
+('profile', 'delete', @iHandler),
+('system', 'enable', @iHandler),
+('system', 'disable', @iHandler),
+('account', 'add', @iHandler),
+('account', 'login', @iHandler),
+('profile', 'add', @iHandler),
+('system', 'connection_added', @iHandler),
+('comment', 'added', @iHandler),
+('vote', 'do', @iHandler),
+('system', 'register_payment', @iHandler),
+('system', 'refund_payment', @iHandler),
+('system', 'create_subscription', @iHandler),
+('system', 'prolong_subscription', @iHandler),
+('system', 'overdue_subscription', @iHandler),
+('system', 'cancel_subscription', @iHandler),
+('system', 'cancel_order', @iHandler),
+('system', 'finalize_checkout', @iHandler);
+
+-- CRON: daily salts and data retention
+INSERT INTO `sys_cron_jobs` (`name`, `time`, `class`, `file`, `service_call`) VALUES
+('bx_analytics_pruning', '15 * * * *', '', '', 'a:2:{s:6:"module";s:12:"bx_analytics";s:6:"method";s:7:"pruning";}');

+ 319 - 0
modules/boonex/analytics/updates/15.0.1_15.0.2/source/install/sql/install.sql

@@ -0,0 +1,319 @@
+SET @sName = 'bx_analytics';
+
+-- TABLES
+
+-- One row per visit: the visitor, the client it came from and how it arrived
+CREATE TABLE IF NOT EXISTS `bx_analytics_sessions` (
+  `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
+  `visitor` binary(16) NOT NULL,
+  `profile_id` int(11) unsigned NOT NULL DEFAULT '0',
+  `platform` varchar(16) NOT NULL DEFAULT '',
+  `app` varchar(64) NOT NULL DEFAULT '',
+  `app_version` varchar(32) NOT NULL DEFAULT '',
+  `started` int(11) unsigned NOT NULL DEFAULT '0',
+  `last_seen` int(11) unsigned NOT NULL DEFAULT '0',
+  `duration` int(11) unsigned NOT NULL DEFAULT '0',
+  `pageviews` smallint(5) unsigned NOT NULL DEFAULT '0',
+  `events` smallint(5) unsigned NOT NULL DEFAULT '0',
+  `errors` smallint(5) unsigned NOT NULL DEFAULT '0',
+  `bounce` tinyint(4) unsigned NOT NULL DEFAULT '1',
+  `hostname` varchar(128) NOT NULL DEFAULT '',
+  `entry_path` varchar(255) NOT NULL DEFAULT '',
+  `exit_path` varchar(255) NOT NULL DEFAULT '',
+  `referrer` varchar(255) NOT NULL DEFAULT '',
+  `referrer_host` varchar(128) NOT NULL DEFAULT '',
+  `channel` varchar(16) NOT NULL DEFAULT '',
+  `utm_source` varchar(128) NOT NULL DEFAULT '',
+  `utm_medium` varchar(128) NOT NULL DEFAULT '',
+  `utm_campaign` varchar(128) NOT NULL DEFAULT '',
+  `utm_term` varchar(128) NOT NULL DEFAULT '',
+  `utm_content` varchar(128) NOT NULL DEFAULT '',
+  `country` char(2) NOT NULL DEFAULT '',
+  `region` varchar(64) NOT NULL DEFAULT '',
+  `city` varchar(64) NOT NULL DEFAULT '',
+  `lat` decimal(4,1) DEFAULT NULL,
+  `lng` decimal(4,1) DEFAULT NULL,
+  `language` varchar(16) NOT NULL DEFAULT '',
+  `browser` varchar(32) NOT NULL DEFAULT '',
+  `browser_version` varchar(16) NOT NULL DEFAULT '',
+  `os` varchar(32) NOT NULL DEFAULT '',
+  `os_version` varchar(16) NOT NULL DEFAULT '',
+  `device` varchar(16) NOT NULL DEFAULT '',
+  `screen` varchar(16) NOT NULL DEFAULT '',
+  PRIMARY KEY (`id`),
+  -- the visitor's open visit; read in this order, retention cohorts need no sort
+  KEY `visitor` (`visitor`, `last_seen`, `started`, `platform`, `profile_id`),
+  -- covers the key figures and their chart (no row is read for them)
+  KEY `started` (`started`, `platform`, `visitor`, `profile_id`, `pageviews`, `bounce`, `duration`),
+  KEY `last_seen` (`last_seen`),
+  KEY `platform_seen` (`platform`, `last_seen`),
+  -- a member's visits; with the last hit and the platform it pages the members list alone
+  KEY `profile_id` (`profile_id`, `started`, `last_seen`, `platform`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+-- One row per pageview or event; visitor, profile and platform are copied from the session
+CREATE TABLE IF NOT EXISTS `bx_analytics_events` (
+  `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
+  `session_id` bigint(20) unsigned NOT NULL,
+  `visitor` binary(16) NOT NULL,
+  `profile_id` int(11) unsigned NOT NULL DEFAULT '0',
+  `platform` varchar(16) NOT NULL DEFAULT '',
+  `ts` int(11) unsigned NOT NULL DEFAULT '0',
+  `type` varchar(16) NOT NULL DEFAULT 'pageview',
+  `name` varchar(64) NOT NULL DEFAULT '',
+  `hostname` varchar(128) NOT NULL DEFAULT '',
+  `path` varchar(255) NOT NULL DEFAULT '',
+  `title` varchar(255) NOT NULL DEFAULT '',
+  `module` varchar(32) NOT NULL DEFAULT '',
+  `object_id` int(11) unsigned NOT NULL DEFAULT '0',
+  `context_id` int(11) unsigned NOT NULL DEFAULT '0',
+  `value` double NOT NULL DEFAULT '0',
+  `engaged` int(11) unsigned NOT NULL DEFAULT '0',
+  `props` text NOT NULL,
+  PRIMARY KEY (`id`),
+  KEY `ts` (`ts`, `type`, `platform`),
+  -- covers pages and the events list by type and period
+  KEY `type_ts` (`type`, `ts`, `platform`, `path`, `name`, `module`, `visitor`, `session_id`, `engaged`),
+  KEY `platform` (`platform`, `ts`),
+  KEY `session_id` (`session_id`, `ts`),
+  -- covers one page (a filter, a goal, a funnel step, the Query chart) over a period, on a platform too
+  KEY `path` (`type`, `path`, `ts`, `platform`, `session_id`, `visitor`, `engaged`),
+  -- the same for page titles: the titles tab, a title filter
+  KEY `title` (`type`, `title`, `ts`, `platform`, `session_id`, `visitor`, `engaged`),
+  KEY `name` (`type`, `name`, `ts`),
+  -- a site action about something: forgetting a deleted profile finds its creation by it
+  KEY `object` (`type`, `name`, `object_id`),
+  -- a member's events: forgetting a deleted profile, the member's actions in a period
+  KEY `profile_id` (`profile_id`, `ts`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+-- A random salt per day for cookieless visitor ids; cron deletes old ones
+CREATE TABLE IF NOT EXISTS `bx_analytics_salts` (
+  `day` date NOT NULL,
+  `salt` binary(32) NOT NULL,
+  PRIMARY KEY (`day`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+-- Rate limits of the public collector: hits per address and minute, new visits per address
+-- and hour. The key is a keyed hash of the kind and the address with the day's salt, never
+-- the address; `bucket` is the minute's or hour's start. Cron deletes buckets over 2 hours old
+CREATE TABLE IF NOT EXISTS `bx_analytics_limits` (
+  `key` binary(8) NOT NULL,
+  `bucket` int(11) unsigned NOT NULL,
+  `hits` int(11) unsigned NOT NULL DEFAULT '0',
+  PRIMARY KEY (`key`, `bucket`),
+  KEY `bucket` (`bucket`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+
+-- Bots and agents per day: crawlers, AI fetchers, link previews and scripts don't run the
+-- tracker, so they are counted on the server and kept as daily totals, not as visits
+CREATE TABLE IF NOT EXISTS `bx_analytics_bots` (
+  `day` date NOT NULL,
+  `bot` varchar(64) NOT NULL,
+  `category` varchar(16) NOT NULL DEFAULT '',
+  `platform` varchar(16) NOT NULL DEFAULT '',
+  `hits` int(11) unsigned NOT NULL DEFAULT '0',
+  `last_seen` int(11) unsigned NOT NULL DEFAULT '0',
+  PRIMARY KEY (`day`, `bot`, `platform`),
+  KEY `category` (`category`, `day`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+-- Pages bots asked for, per day and bot category
+CREATE TABLE IF NOT EXISTS `bx_analytics_bot_pages` (
+  `day` date NOT NULL,
+  `category` varchar(16) NOT NULL DEFAULT '',
+  `path_hash` binary(8) NOT NULL,
+  `path` varchar(255) NOT NULL DEFAULT '',
+  `hits` int(11) unsigned NOT NULL DEFAULT '0',
+  PRIMARY KEY (`day`, `category`, `path_hash`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+-- Goals: a page (path, * for any characters) or an event (name, or name@module) that
+-- counts as a conversion
+CREATE TABLE IF NOT EXISTS `bx_analytics_goals` (
+  `id` int(11) unsigned NOT NULL AUTO_INCREMENT,
+  `name` varchar(128) NOT NULL DEFAULT '',
+  `type` varchar(16) NOT NULL DEFAULT 'page',
+  `pattern` varchar(255) NOT NULL DEFAULT '',
+  `added` int(11) unsigned NOT NULL DEFAULT '0',
+  `order` int(11) NOT NULL DEFAULT '0',
+  PRIMARY KEY (`id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+-- Funnels: steps as a JSON list of {type, pattern}, matched in order within a visit
+CREATE TABLE IF NOT EXISTS `bx_analytics_funnels` (
+  `id` int(11) unsigned NOT NULL AUTO_INCREMENT,
+  `name` varchar(128) NOT NULL DEFAULT '',
+  `steps` text NOT NULL,
+  `added` int(11) unsigned NOT NULL DEFAULT '0',
+  `order` int(11) NOT NULL DEFAULT '0',
+  PRIMARY KEY (`id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+-- Revenue ledger: one row per money movement the payment module reports (a sale, a renewal,
+-- a refund or a reversal of a processed order), with the amount in the site's default
+-- currency and the platform and traffic source of the buyer. Pruning never touches it.
+-- A subscription's trial is registered at the full price although nothing was charged: those
+-- rows have the kind `trial` and no revenue report reads them. A transaction has one charge
+-- row (sale, renewal or trial) and at most one negation (refund or reversal), hence the
+-- unique key on the payment module, transaction and kind.
+CREATE TABLE IF NOT EXISTS `bx_analytics_revenue` (
+  `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
+  `ts` int(11) unsigned NOT NULL DEFAULT '0',
+  `kind` varchar(16) NOT NULL DEFAULT '',
+  `cash` tinyint(4) unsigned NOT NULL DEFAULT '1',
+  `payment` varchar(32) NOT NULL DEFAULT '',
+  `pending_id` int(11) unsigned NOT NULL DEFAULT '0',
+  `transaction_id` int(11) unsigned NOT NULL DEFAULT '0',
+  `order` varchar(32) NOT NULL DEFAULT '',
+  `module_id` int(11) unsigned NOT NULL DEFAULT '0',
+  `module` varchar(32) NOT NULL DEFAULT '',
+  `item_id` int(11) unsigned NOT NULL DEFAULT '0',
+  `item_count` int(11) unsigned NOT NULL DEFAULT '0',
+  `seller_id` int(11) unsigned NOT NULL DEFAULT '0',
+  `author_id` int(11) unsigned NOT NULL DEFAULT '0',
+  `client_id` int(11) unsigned NOT NULL DEFAULT '0',
+  `provider` varchar(32) NOT NULL DEFAULT '',
+  `type` varchar(16) NOT NULL DEFAULT '',
+  `amount` decimal(14,4) NOT NULL DEFAULT '0',
+  `currency` varchar(4) NOT NULL DEFAULT '',
+  `rate` double NOT NULL DEFAULT '1',
+  `amount_default` decimal(14,4) DEFAULT NULL,
+  `platform` varchar(16) NOT NULL DEFAULT '',
+  `session_id` bigint(20) unsigned NOT NULL DEFAULT '0',
+  `first_channel` varchar(16) NOT NULL DEFAULT '',
+  `first_referrer_host` varchar(128) NOT NULL DEFAULT '',
+  `first_utm_campaign` varchar(128) NOT NULL DEFAULT '',
+  `last_channel` varchar(16) NOT NULL DEFAULT '',
+  `last_referrer_host` varchar(128) NOT NULL DEFAULT '',
+  `last_utm_campaign` varchar(128) NOT NULL DEFAULT '',
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `movement` (`payment`, `transaction_id`, `kind`),
+  KEY `ts` (`cash`, `ts`, `platform`),
+  KEY `pending` (`payment`, `pending_id`),
+  KEY `module` (`module_id`, `ts`),
+  KEY `seller` (`seller_id`, `ts`),
+  KEY `client` (`client_id`, `ts`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+-- Subscriptions as the ledger sees them: plan, price per period and per month (MRR) in the
+-- default currency, when they started, were first and last paid, and were cancelled
+CREATE TABLE IF NOT EXISTS `bx_analytics_subscriptions` (
+  `payment` varchar(32) NOT NULL DEFAULT '',
+  `pending_id` int(11) unsigned NOT NULL DEFAULT '0',
+  `subscription_id` varchar(32) NOT NULL DEFAULT '',
+  `client_id` int(11) unsigned NOT NULL DEFAULT '0',
+  `seller_id` int(11) unsigned NOT NULL DEFAULT '0',
+  `module_id` int(11) unsigned NOT NULL DEFAULT '0',
+  `module` varchar(32) NOT NULL DEFAULT '',
+  `item_id` int(11) unsigned NOT NULL DEFAULT '0',
+  `provider` varchar(32) NOT NULL DEFAULT '',
+  `period` int(11) unsigned NOT NULL DEFAULT '1',
+  `period_unit` varchar(16) NOT NULL DEFAULT '',
+  `trial` int(11) unsigned NOT NULL DEFAULT '0',
+  `amount_default` decimal(14,4) DEFAULT NULL,
+  `mrr_default` decimal(14,4) DEFAULT NULL,
+  `status` varchar(16) NOT NULL DEFAULT '',
+  `started` int(11) unsigned NOT NULL DEFAULT '0',
+  `first_paid` int(11) unsigned NOT NULL DEFAULT '0',
+  `last_paid` int(11) unsigned NOT NULL DEFAULT '0',
+  `paid_count` int(11) unsigned NOT NULL DEFAULT '0',
+  `canceled` int(11) unsigned NOT NULL DEFAULT '0',
+  `reason` varchar(255) NOT NULL DEFAULT '',
+  `platform` varchar(16) NOT NULL DEFAULT '',
+  PRIMARY KEY (`payment`, `pending_id`),
+  KEY `paid` (`first_paid`, `canceled`),
+  KEY `started` (`started`),
+  KEY `canceled` (`canceled`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+-- Client errors grouped by fingerprint (kind, normalized message, top frame, HTTP status),
+-- with a sample stack. `created` is server time, for the limit of new groups an hour;
+-- `first_seen` and `last_seen` are when the errors happened.
+CREATE TABLE IF NOT EXISTS `bx_analytics_errors` (
+  `id` int(11) unsigned NOT NULL AUTO_INCREMENT,
+  `fingerprint` binary(16) NOT NULL,
+  `kind` varchar(16) NOT NULL DEFAULT 'js',
+  `message` varchar(255) NOT NULL DEFAULT '',
+  `source` varchar(255) NOT NULL DEFAULT '',
+  `line` int(11) unsigned NOT NULL DEFAULT '0',
+  `col` int(11) unsigned NOT NULL DEFAULT '0',
+  `status_code` smallint(5) unsigned NOT NULL DEFAULT '0',
+  `stack` text NOT NULL,
+  `stack_ts` int(11) unsigned NOT NULL DEFAULT '0',
+  `first_seen` int(11) unsigned NOT NULL DEFAULT '0',
+  `last_seen` int(11) unsigned NOT NULL DEFAULT '0',
+  `occurrences` int(11) unsigned NOT NULL DEFAULT '0',
+  `fatal` tinyint(4) unsigned NOT NULL DEFAULT '0',
+  `status` varchar(16) NOT NULL DEFAULT 'open',
+  `status_changed` int(11) unsigned NOT NULL DEFAULT '0',
+  `created` int(11) unsigned NOT NULL DEFAULT '0',
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `fingerprint` (`fingerprint`),
+  KEY `created` (`created`),
+  KEY `last_seen` (`last_seen`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+-- One row per error report from a client (repeats on one page counted in `repeats`). The
+-- client's columns are copied, so an error outside a visit (`session_id` 0) still has them.
+CREATE TABLE IF NOT EXISTS `bx_analytics_error_hits` (
+  `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
+  `error_id` int(11) unsigned NOT NULL,
+  `ts` int(11) unsigned NOT NULL DEFAULT '0',
+  `session_id` bigint(20) unsigned NOT NULL DEFAULT '0',
+  `visitor` binary(16) NOT NULL,
+  `profile_id` int(11) unsigned NOT NULL DEFAULT '0',
+  `platform` varchar(16) NOT NULL DEFAULT '',
+  `app_version` varchar(32) NOT NULL DEFAULT '',
+  `browser` varchar(32) NOT NULL DEFAULT '',
+  `os` varchar(32) NOT NULL DEFAULT '',
+  `device` varchar(16) NOT NULL DEFAULT '',
+  `country` char(2) NOT NULL DEFAULT '',
+  `path` varchar(255) NOT NULL DEFAULT '',
+  `route` varchar(128) NOT NULL DEFAULT '',
+  `repeats` smallint(5) unsigned NOT NULL DEFAULT '1',
+  `fatal` tinyint(4) unsigned NOT NULL DEFAULT '0',
+  PRIMARY KEY (`id`),
+  KEY `ts` (`ts`, `platform`),
+  KEY `error_id` (`error_id`, `ts`),
+  KEY `profile_id` (`profile_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+-- Web vitals and app timings: one row per sampled page view or screen, NULL for what wasn't
+-- measured; milliseconds, CLS in thousandths. No visitor or member is stored.
+CREATE TABLE IF NOT EXISTS `bx_analytics_vitals` (
+  `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
+  `ts` int(11) unsigned NOT NULL DEFAULT '0',
+  `session_id` bigint(20) unsigned NOT NULL DEFAULT '0',
+  `platform` varchar(16) NOT NULL DEFAULT '',
+  `device` varchar(16) NOT NULL DEFAULT '',
+  `path` varchar(255) NOT NULL DEFAULT '',
+  `route` varchar(128) NOT NULL DEFAULT '',
+  `nav` varchar(16) NOT NULL DEFAULT '',
+  `ttfb` mediumint(8) unsigned DEFAULT NULL,
+  `fcp` mediumint(8) unsigned DEFAULT NULL,
+  `lcp` mediumint(8) unsigned DEFAULT NULL,
+  `inp` mediumint(8) unsigned DEFAULT NULL,
+  `cls` smallint(5) unsigned DEFAULT NULL,
+  `screen_load` mediumint(8) unsigned DEFAULT NULL,
+  `app_start` mediumint(8) unsigned DEFAULT NULL,
+  PRIMARY KEY (`id`),
+  KEY `ts` (`ts`, `platform`),
+  KEY `route` (`route`, `ts`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+-- STUDIO: page & widget
+INSERT INTO `sys_std_pages`(`index`, `name`, `header`, `caption`, `icon`) VALUES
+(3, @sName, '_bx_analytics', '_bx_analytics', 'bx_analytics@modules/boonex/analytics/|std-icon.svg');
+
+SET @iPageId = LAST_INSERT_ID();
+
+INSERT INTO `sys_std_widgets` (`page_id`, `module`, `type`, `url`, `click`, `icon`, `caption`, `cnt_notices`, `cnt_actions`, `featured`) 
+VALUES(@iPageId, @sName, 'extensions', '{url_studio}module.php?name=bx_analytics', '', 'bx_analytics@modules/boonex/analytics/|std-icon.svg', '_bx_analytics', '', 'a:4:{s:6:"module";s:6:"system";s:6:"method";s:11:"get_actions";s:6:"params";a:0:{}s:5:"class";s:18:"TemplStudioModules";}', 0);
+
+SET @iParentPageId = (SELECT `id` FROM `sys_std_pages` WHERE `name` = 'home');
+SET @iParentPageOrder = (SELECT IFNULL(MAX(`order`), 0) + 1 FROM `sys_std_pages_widgets` WHERE `page_id` = @iParentPageId);
+
+INSERT INTO `sys_std_pages_widgets` (`page_id`, `widget_id`, `order`) VALUES
+(@iParentPageId, LAST_INSERT_ID(), @iParentPageOrder);

+ 9 - 0
modules/boonex/analytics/updates/15.0.1_15.0.2/source/install/sql/uninstall.sql

@@ -0,0 +1,9 @@
+SET @sName = 'bx_analytics';
+
+-- TABLES
+DROP TABLE IF EXISTS `bx_analytics_sessions`, `bx_analytics_events`, `bx_analytics_salts`, `bx_analytics_limits`, `bx_analytics_bots`, `bx_analytics_bot_pages`, `bx_analytics_goals`, `bx_analytics_funnels`, `bx_analytics_revenue`, `bx_analytics_subscriptions`, `bx_analytics_errors`, `bx_analytics_error_hits`, `bx_analytics_vitals`;
+
+-- STUDIO WIDGET
+DELETE FROM `tp`, `tw`, `twb`, `tpw` 
+USING `sys_std_pages` AS `tp` LEFT JOIN `sys_std_widgets` AS `tw` ON `tp`.`id` = `tw`.`page_id` LEFT JOIN `sys_std_widgets_bookmarks` AS `twb` ON `tw`.`id` = `twb`.`widget_id` LEFT JOIN `sys_std_pages_widgets` AS `tpw` ON `tw`.`id` = `tpw`.`widget_id`
+WHERE  `tp`.`name` = @sName;

+ 815 - 0
modules/boonex/analytics/updates/15.0.1_15.0.2/source/js/dashboard.js

@@ -0,0 +1,815 @@
+/**
+ * Copyright (c) UNA, Inc - https://una.io
+ * MIT License - https://opensource.org/licenses/MIT
+ *
+ * @defgroup    Analytics Analytics
+ * @ingroup     UnaModules
+ *
+ * @{
+ */
+
+/**
+ * Analytics blocks on a page share one set of filters kept in the page URL: range, tz,
+ * platform, f[dimension] and the chart metric. Changing a filter reloads every block
+ * from the server, which renders it from the same templates as the first page load.
+ * A block's own state (its tab, its page) is kept in the URL too, as tab_<name> and
+ * page_<name>, so a reload or a shared link shows the same thing.
+ */
+function BxAnalyticsDashboard(oOptions) {
+    this._sActionUrl = oOptions.sActionUrl;
+    this._aStrings = oOptions.aStrings || {};
+
+    var $this = this;
+    if (document.readyState === 'loading')
+        document.addEventListener('DOMContentLoaded', function () { $this.init(); });
+    else
+        this.init();
+}
+
+BxAnalyticsDashboard.prototype.init = function () {
+    var $this = this;
+
+    document.addEventListener('change', function (oEvent) {
+        var oSelect = oEvent.target.closest('[data-bx-analytics-param]');
+        if (oSelect)
+            $this.setParam(oSelect.getAttribute('data-bx-analytics-param'), oSelect.value);
+    });
+
+    document.addEventListener('click', function (oEvent) {
+        var oTarget, oBlock, sName;
+
+        if ((oTarget = oEvent.target.closest('[data-bx-analytics-metric]'))) {
+            $this.selectMetric(oTarget);
+            return;
+        }
+
+        if ((oTarget = oEvent.target.closest('[data-bx-analytics-tab]'))) {
+            oBlock = oTarget.closest('[data-bx-analytics-block]');
+            sName = $this.getBlockName(oBlock);
+            $this.setUrlParam('tab_' + sName, oTarget.getAttribute('data-bx-analytics-tab'));
+            $this.setUrlParam('page_' + sName, null);
+            $this.reload(oBlock, true).catch(function () {}); // reload() shows its own failure
+            return;
+        }
+
+        if ((oTarget = oEvent.target.closest('[data-bx-analytics-pager]'))) {
+            oBlock = oTarget.closest('[data-bx-analytics-block]');
+            $this.setUrlParam('page_' + oTarget.getAttribute('data-bx-analytics-pager'), oTarget.getAttribute('data-bx-analytics-page'));
+            $this.reload(oBlock, false).catch(function () {}); // reload() shows its own failure
+            return;
+        }
+
+        if ((oTarget = oEvent.target.closest('[data-bx-analytics-open]'))) {
+            oBlock = oTarget.closest('[data-bx-analytics-block]');
+            sName = oTarget.getAttribute('data-bx-analytics-open');
+            var sValue = oTarget.getAttribute('data-bx-analytics-value');
+            $this.setUrlParam(sName, sValue);
+            $this.reload(oBlock, false).then(function (oNew) {
+                $this.focusAfterOpen(oNew, sName, sValue);
+            }, function () {}); // reload() shows its own failure
+            return;
+        }
+
+        if ((oTarget = oEvent.target.closest('[data-bx-analytics-action]'))) {
+            $this.runAction(oTarget);
+            return;
+        }
+
+        if ((oTarget = oEvent.target.closest('[data-bx-analytics-filter]'))) {
+            // the platform has a parameter of its own (the filter bar's select), not f[platform]
+            sName = oTarget.getAttribute('data-bx-analytics-filter');
+            $this.setParam(sName === 'platform' ? 'platform' : 'f[' + sName + ']', oTarget.getAttribute('data-bx-analytics-value'));
+            return;
+        }
+
+        if ((oTarget = oEvent.target.closest('[data-bx-analytics-unfilter]')))
+            $this.setParam('f[' + oTarget.getAttribute('data-bx-analytics-unfilter') + ']', null);
+    });
+
+    document.addEventListener('submit', function (oEvent) {
+        var oForm = oEvent.target.closest('[data-bx-analytics-form]');
+        if (!oForm)
+            return;
+
+        oEvent.preventDefault();
+        $this.submitForm(oForm);
+    });
+
+    // arrow keys move between tabs, as in any tab list
+    document.addEventListener('keydown', function (oEvent) {
+        var oTab = oEvent.target.closest('[data-bx-analytics-tab]');
+        if (!oTab || (oEvent.key !== 'ArrowRight' && oEvent.key !== 'ArrowLeft'))
+            return;
+
+        var aTabs = Array.prototype.slice.call(oTab.parentNode.querySelectorAll('[data-bx-analytics-tab]'));
+        var iNext = (aTabs.indexOf(oTab) + (oEvent.key === 'ArrowRight' ? 1 : -1) + aTabs.length) % aTabs.length;
+        aTabs[iNext].focus();
+        aTabs[iNext].click();
+        oEvent.preventDefault();
+    });
+
+    // a focused tab in a row that scrolls is moved clear of the row's faded ends; focus()
+    // alone scrolls only a tab that is out of sight, not one that is cut off
+    document.addEventListener('focusin', function (oEvent) {
+        var oTab = oEvent.target.closest('[data-bx-analytics-tab]');
+        if (oTab)
+            $this.showTab(oTab);
+    });
+
+    this.drawCharts(document);
+    this.drawMaps(document);
+    this.showSelectedTabs(document);
+
+    // blocks that follow live data reload themselves while the page is visible
+    setInterval(function () {
+        if (document.visibilityState !== 'visible')
+            return;
+
+        document.querySelectorAll('[data-bx-analytics-refresh]').forEach(function (oRefresh) {
+            var oBlock = oRefresh.closest('[data-bx-analytics-block]');
+            var iEvery = 1000 * parseInt(oRefresh.getAttribute('data-bx-analytics-refresh'), 10);
+            if (oBlock && !oBlock._bxAnalyticsBusy && Date.now() - (oBlock._bxAnalyticsLoaded || $this._iStarted) >= iEvery)
+                $this.reload(oBlock, false, true).catch(function () {}); // a quiet refresh tries again later
+        });
+    }, 5000);
+    this._iStarted = Date.now();
+
+    // without a timezone in the page URL the server takes the viewer's from a cookie, kept
+    // here; only a page drawn before the cookie was set (or in another zone) loads again
+    var sTz = this.getTimezone();
+    var oUrl = new URL(window.location.href);
+    if (!oUrl.searchParams.get('tz') && sTz) {
+        var sRendered = this.getTimezoneCookie() || 'UTC';
+        this.setTimezoneCookie(sTz);
+        if (sTz !== sRendered) {
+            this.setUrlParam('tz', sTz);
+            this.reloadAll();
+        }
+    }
+};
+
+/**
+ * The timezone cookie the server reads when a request has no `tz` (BxAnalyticsFilter).
+ */
+BxAnalyticsDashboard.prototype.getTimezoneCookie = function () {
+    var aMatch = document.cookie.match(/(?:^|;\s*)bx_analytics_tz=([^;]*)/);
+    try {
+        return aMatch ? decodeURIComponent(aMatch[1]) : '';
+    } catch (e) {
+        return '';
+    }
+};
+
+BxAnalyticsDashboard.prototype.setTimezoneCookie = function (sTz) {
+    document.cookie = 'bx_analytics_tz=' + encodeURIComponent(sTz) + '; path=/; max-age=31536000; SameSite=Lax' + (window.location.protocol === 'https:' ? '; Secure' : '');
+};
+
+BxAnalyticsDashboard.prototype.getTimezone = function () {
+    try {
+        return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
+    } catch (e) {
+        return 'UTC';
+    }
+};
+
+/**
+ * A block's own parameters (group, table...), set by the server.
+ */
+BxAnalyticsDashboard.prototype.getBlockParams = function (oBlock) {
+    try {
+        return JSON.parse(oBlock.getAttribute('data-bx-analytics-params') || '{}') || {};
+    } catch (e) {
+        return {};
+    }
+};
+
+/**
+ * The name a block keeps its tab and page under in the URL.
+ */
+BxAnalyticsDashboard.prototype.getBlockName = function (oBlock) {
+    var oParams = this.getBlockParams(oBlock);
+    return oParams.group || oParams.table || oBlock.getAttribute('data-bx-analytics-block');
+};
+
+/**
+ * Set a filter parameter (null removes it) and reload every block; lists start again
+ * from their first page.
+ */
+BxAnalyticsDashboard.prototype.setParam = function (sName, sValue) {
+    var oUrl = new URL(window.location.href);
+    Array.from(oUrl.searchParams.keys()).forEach(function (sKey) {
+        if (sKey.indexOf('page_') === 0)
+            oUrl.searchParams.delete(sKey);
+    });
+    window.history.replaceState(window.history.state, '', oUrl.toString());
+
+    this.setUrlParam(sName, sValue);
+    if (!new URL(window.location.href).searchParams.get('tz'))
+        this.setUrlParam('tz', this.getTimezone());
+
+    this.reloadAll();
+};
+
+BxAnalyticsDashboard.prototype.setUrlParam = function (sName, sValue) {
+    var oUrl = new URL(window.location.href);
+    if (sValue === null || sValue === '')
+        oUrl.searchParams.delete(sName);
+    else
+        oUrl.searchParams.set(sName, sValue);
+
+    window.history.replaceState(window.history.state, '', oUrl.toString());
+};
+
+BxAnalyticsDashboard.prototype.reloadAll = function () {
+    var $this = this;
+    document.querySelectorAll('[data-bx-analytics-block]').forEach(function (oBlock) {
+        $this.reload(oBlock, false).catch(function () {}); // reload() shows its own failure
+    });
+};
+
+/**
+ * Replace a block with a fresh copy for the current URL parameters. A quiet reload (live
+ * blocks) keeps the block as it is until the new copy arrives.
+ */
+BxAnalyticsDashboard.prototype.reload = function (oBlock, bKeepFocus, bQuiet) {
+    var $this = this;
+    var sReport = oBlock.getAttribute('data-bx-analytics-block');
+    var oParams = new URL(window.location.href).searchParams;
+    oParams.set('bp', oBlock.getAttribute('data-bx-analytics-params') || '{}');
+
+    var sFocusTab = bKeepFocus && document.activeElement ? document.activeElement.getAttribute('data-bx-analytics-tab') : null;
+
+    oBlock._bxAnalyticsBusy = true;
+    if (!bQuiet) {
+        oBlock.setAttribute('aria-busy', 'true');
+        oBlock.classList.add('bx-analytics-loading');
+    }
+
+    // the module URL is m/analytics/ with permalinks and modules/?r=analytics/ without
+    var sUrl = this._sActionUrl + 'report/' + sReport + '/';
+    sUrl += (sUrl.indexOf('?') === -1 ? '?' : '&') + oParams.toString();
+
+    return fetch(sUrl, {credentials: 'same-origin'})
+        .then(function (oResponse) {
+            if (!oResponse.ok)
+                throw new Error(oResponse.status);
+            return oResponse.json();
+        })
+        .then(function (oData) {
+            var oWrapper = document.createElement('div');
+            oWrapper.innerHTML = oData.html;
+            var oNew = oWrapper.firstElementChild;
+            if (!oNew) {
+                oBlock._bxAnalyticsBusy = false;
+                return null;
+            }
+
+            $this.destroyChart(oBlock);
+            oBlock.replaceWith(oNew);
+            oNew._bxAnalyticsLoaded = Date.now();
+            $this.drawCharts(oNew);
+            $this.drawMaps(oNew);
+            $this.showSelectedTabs(oNew);
+            if (typeof bx_time === 'function')
+                bx_time(undefined, false, oNew);
+
+            if (sFocusTab) {
+                var oTab = oNew.querySelector('[data-bx-analytics-tab="' + sFocusTab + '"]');
+                if (oTab)
+                    oTab.focus();
+            }
+
+            return oNew;
+        })
+        .catch(function () {
+            oBlock._bxAnalyticsBusy = false;
+            oBlock.removeAttribute('aria-busy');
+            oBlock.classList.remove('bx-analytics-loading');
+            if (!bQuiet) {
+                oBlock.classList.add('bx-analytics-failed');
+                oBlock.setAttribute('data-bx-analytics-error', $this._aStrings.error || '');
+            }
+
+            return null;
+        });
+};
+
+/**
+ * Scroll each tab row that is wider than its card (touch screens) to its selected tab, as
+ * a fresh row starts at its first tab.
+ */
+BxAnalyticsDashboard.prototype.showSelectedTabs = function (oRoot) {
+    var $this = this;
+    oRoot.querySelectorAll('[data-bx-analytics-tab][aria-selected="true"]').forEach(function (oTab) {
+        $this.showTab(oTab);
+    });
+};
+
+/**
+ * Scroll a tab's row, not the page, so that the whole tab shows clear of the row's faded
+ * ends (its scroll-padding).
+ */
+BxAnalyticsDashboard.prototype.showTab = function (oTab) {
+    var oRow = oTab.parentNode;
+    if (!oRow || oRow.scrollWidth <= oRow.clientWidth)
+        return;
+
+    var oStyle = window.getComputedStyle(oRow);
+    var fPadStart = parseFloat(oStyle.scrollPaddingInlineStart) || 0;
+    var fPadEnd = parseFloat(oStyle.scrollPaddingInlineEnd) || 0;
+    var bRtl = oStyle.direction === 'rtl';
+    var fPadLeft = bRtl ? fPadEnd : fPadStart;
+    var fPadRight = bRtl ? fPadStart : fPadEnd;
+
+    var oRowRect = oRow.getBoundingClientRect();
+    var oTabRect = oTab.getBoundingClientRect();
+    if (oTabRect.left < oRowRect.left + fPadLeft)
+        oRow.scrollLeft -= oRowRect.left + fPadLeft - oTabRect.left;
+    else if (oTabRect.right > oRowRect.right - fPadRight)
+        oRow.scrollLeft += oTabRect.right - oRowRect.right + fPadRight;
+};
+
+/**
+ * Keyboard focus after a block opened something: the first field of a form it shows, or the
+ * control for the same thing (a back or cancel leads to the control that opened it).
+ */
+BxAnalyticsDashboard.prototype.focusAfterOpen = function (oBlock, sName, sValue) {
+    if (!oBlock)
+        return;
+
+    var oFocus = oBlock.querySelector('[data-bx-analytics-form] input:not([type="hidden"]), [data-bx-analytics-form] select');
+    if (!oFocus && sValue)
+        oFocus = oBlock.querySelector('[data-bx-analytics-open="' + sName + '"][data-bx-analytics-value="' + CSS.escape(sValue) + '"]');
+    if (!oFocus)
+        oFocus = oBlock.querySelector('[data-bx-analytics-open="' + sName + '"]');
+
+    if (oFocus)
+        oFocus.focus();
+};
+
+/**
+ * Send a goal or funnel form; on success close the form and show the saved item.
+ */
+BxAnalyticsDashboard.prototype.submitForm = function (oForm) {
+    var $this = this;
+    var sKind = oForm.getAttribute('data-bx-analytics-form');
+    var oBlock = oForm.closest('[data-bx-analytics-block]');
+    var oError = oForm.querySelector('.bx-analytics-form-error');
+    var oSubmit = oForm.querySelector('[type="submit"]');
+    var sSaved = '';
+
+    var oData = new FormData(oForm);
+    oData.set('op', 'save');
+
+    oSubmit.disabled = true;
+    this.post(sKind, oData)
+        .then(function (oResult) {
+            if (!oResult.ok)
+                throw new Error(oResult.error || $this._aStrings.error || '');
+
+            sSaved = oResult.id ? String(oResult.id) : '';
+            $this.setUrlParam('edit_' + sKind, null);
+            if (sKind === 'funnel' && oResult.id)
+                $this.setUrlParam('funnel', String(oResult.id));
+
+            return $this.reload(oBlock, false);
+        })
+        .then(function (oNew) {
+            $this.focusAfterOpen(oNew, 'edit_' + sKind, sSaved);
+        })
+        .catch(function (oException) {
+            oSubmit.disabled = false;
+            oError.textContent = oException.message || $this._aStrings.error || '';
+            oError.hidden = false;
+        });
+};
+
+/**
+ * A button that changes data: delete a goal or a funnel after the viewer confirms it.
+ */
+BxAnalyticsDashboard.prototype.runAction = function (oButton) {
+    var $this = this;
+    var sKind = oButton.getAttribute('data-bx-analytics-action');
+    var sConfirm = oButton.getAttribute('data-bx-analytics-confirm');
+    if (sConfirm && !window.confirm(sConfirm))
+        return;
+
+    var oBlock = oButton.closest('[data-bx-analytics-block]');
+    var oData = new FormData();
+    oData.set('op', oButton.getAttribute('data-bx-analytics-op'));
+    oData.set('id', oButton.getAttribute('data-bx-analytics-id'));
+    oData.set('csrf_token', oButton.getAttribute('data-bx-analytics-csrf') || '');
+
+    oButton.disabled = true;
+    this.post(sKind, oData)
+        .then(function (oResult) {
+            if (!oResult.ok)
+                throw new Error(oResult.error || '');
+
+            if (sKind === 'funnel')
+                $this.setUrlParam('funnel', null);
+
+            return $this.reload(oBlock, false);
+        })
+        .then(function (oNew) {
+            // a status change keeps the view: focus goes to the first action left
+            var oAction = oNew && sKind === 'error_status' ? oNew.querySelector('[data-bx-analytics-action="error_status"]') : null;
+            if (oAction)
+                oAction.focus();
+            else
+                $this.focusAfterOpen(oNew, 'edit_' + sKind, 'new');
+        })
+        .catch(function (oException) {
+            oButton.disabled = false;
+            window.alert(oException.message || $this._aStrings.error || '');
+        });
+};
+
+BxAnalyticsDashboard.prototype.post = function (sAction, oData) {
+    var sUrl = this._sActionUrl + sAction + '/';
+
+    return fetch(sUrl, {method: 'POST', body: oData, credentials: 'same-origin'})
+        .then(function (oResponse) {
+            return oResponse.json().catch(function () {
+                return {error: ''};
+            });
+        });
+};
+
+BxAnalyticsDashboard.prototype.selectMetric = function (oButton) {
+    var oBlock = oButton.closest('[data-bx-analytics-block]');
+    oBlock.querySelectorAll('[data-bx-analytics-metric]').forEach(function (o) {
+        o.setAttribute('aria-pressed', o === oButton ? 'true' : 'false');
+    });
+
+    this.setUrlParam('metric', oButton.getAttribute('data-bx-analytics-metric'));
+
+    var oJson = oBlock.querySelector('.bx-analytics-chart-data');
+    var oData = oJson ? this.getChartData(oJson) : null;
+    if (oData) {
+        oData.metric = oButton.getAttribute('data-bx-analytics-metric');
+        this.drawChart(oJson, oData);
+    }
+};
+
+/**
+ * The data of a chart: JSON in a script element right after the chart's container.
+ */
+BxAnalyticsDashboard.prototype.getChartData = function (oJson) {
+    if (!oJson._bxAnalyticsData) {
+        try {
+            oJson._bxAnalyticsData = JSON.parse(oJson.textContent);
+        } catch (e) {
+            return null;
+        }
+    }
+
+    return oJson._bxAnalyticsData;
+};
+
+BxAnalyticsDashboard.prototype.drawCharts = function (oRoot) {
+    var $this = this;
+    var aJson = oRoot.querySelectorAll ? oRoot.querySelectorAll('.bx-analytics-chart-data') : [];
+    Array.prototype.forEach.call(aJson, function (oJson) {
+        var oData = $this.getChartData(oJson);
+        if (oData)
+            $this.drawChart(oJson, oData);
+    });
+};
+
+/**
+ * Visitors on a Leaflet map: a circle per country (or place), its area by visitors.
+ */
+BxAnalyticsDashboard.prototype.drawMaps = function (oRoot) {
+    if (typeof L === 'undefined')
+        return;
+
+    var aMaps = oRoot.querySelectorAll ? oRoot.querySelectorAll('.bx-analytics-map') : [];
+    Array.prototype.forEach.call(aMaps, function (oElement) {
+        if (oElement._bxAnalyticsMap)
+            return;
+
+        var oJson = oElement.parentNode.querySelector('.bx-analytics-map-data');
+        var oData;
+        try {
+            oData = JSON.parse(oJson.textContent);
+        } catch (e) {
+            return;
+        }
+
+        var oMap = L.map(oElement, {worldCopyJump: true, minZoom: 1, scrollWheelZoom: false}).setView([25, 10], 2);
+        oElement._bxAnalyticsMap = oMap;
+
+        try {
+            L.tileLayer.provider(oData.provider || 'OpenStreetMap.Mapnik').addTo(oMap);
+        } catch (e) {
+            L.tileLayer.provider('OpenStreetMap.Mapnik').addTo(oMap);
+        }
+
+        var sAccent = window.getComputedStyle(oElement).getPropertyValue('--bx-analytics-accent').trim() || '#4f46e5';
+        var aItems = oData.points && oData.points.length ? oData.points : oData.countries;
+        var iMax = 1;
+        aItems.forEach(function (o) { iMax = Math.max(iMax, o.visitors); });
+
+        // the whole world when there is little to show, otherwise just the places with visitors
+        if (aItems.length > 1)
+            oMap.fitBounds(L.latLngBounds(aItems.map(function (o) { return [o.lat, o.lng]; })), {padding: [30, 30], maxZoom: 5});
+        else if (aItems.length === 1)
+            oMap.setView([aItems[0].lat, aItems[0].lng], 4);
+
+        aItems.forEach(function (o) {
+            L.circleMarker([o.lat, o.lng], {
+                radius: 4 + 26 * Math.sqrt(o.visitors / iMax),
+                color: sAccent,
+                weight: 1,
+                fillColor: sAccent,
+                fillOpacity: 0.35
+            }).bindTooltip(function () {
+                // place names come from request headers: text, never HTML
+                var oTip = document.createElement('span');
+                oTip.textContent = o.label + ': ' + o.visitors + ' ' + oData.visitors.toLowerCase();
+                return oTip;
+            }).addTo(oMap);
+        });
+    });
+};
+
+BxAnalyticsDashboard.prototype.destroyChart = function (oBlock) {
+    var oMap = oBlock.querySelector('.bx-analytics-map');
+    if (oMap && oMap._bxAnalyticsMap) {
+        oMap._bxAnalyticsMap.remove();
+        oMap._bxAnalyticsMap = null;
+    }
+
+    oBlock.querySelectorAll('canvas').forEach(function (oCanvas) {
+        if (oCanvas._bxAnalyticsChart) {
+            oCanvas._bxAnalyticsChart.destroy();
+            oCanvas._bxAnalyticsChart = null;
+        }
+    });
+};
+
+/**
+ * Colours for the chart: the accent and a categorical palette, from the block's CSS.
+ */
+BxAnalyticsDashboard.prototype.getColors = function (oElement) {
+    var oStyle = window.getComputedStyle(oElement);
+    var get = function (sName, sDefault) {
+        return oStyle.getPropertyValue(sName).trim() || sDefault;
+    };
+
+    var aPalette = [];
+    for (var i = 1; i <= 8; i++)
+        aPalette.push(get('--bx-analytics-c' + i, '#4f46e5'));
+
+    return {
+        accent: get('--bx-analytics-accent', '#4f46e5'),
+        fill: get('--bx-analytics-accent-fill', 'rgba(79, 70, 229, 0.15)'),
+        grid: get('--bx-analytics-grid', 'rgba(0, 0, 0, 0.08)'),
+        muted: get('--bx-analytics-muted', '#6b7280'),
+        good: get('--bx-analytics-good', '#047857'),
+        bad: get('--bx-analytics-bad', '#b91c1c'),
+        palette: aPalette
+    };
+};
+
+/**
+ * Several values over time (kind `series`: `series` of {label, points of {t, v, partial}})
+ * as lines per category, the bucket in progress dashed.
+ */
+BxAnalyticsDashboard.prototype.getSeriesLines = function (oData) {
+    var aSeries = oData.series || [];
+
+    return {
+        kind: 'lines',
+        metric: oData.metric,
+        bucket: oData.bucket,
+        tz: oData.tz,
+        legend: true,
+        labels: aSeries.length ? aSeries[0].points.map(function (oPoint) { return oPoint.t; }) : [],
+        datasets: aSeries.map(function (oSeries) {
+            return {
+                label: oSeries.label,
+                values: oSeries.points.map(function (oPoint) { return oPoint.v; }),
+                partial: oSeries.points.map(function (oPoint) { return !!oPoint.partial; })
+            };
+        })
+    };
+};
+
+/**
+ * Draw the chart a JSON element describes, in the canvas of the container before it.
+ * Kinds: a metric over time (the overview, `series` with every metric), stacked bars per
+ * category (`labels` and `datasets`), lines per category (`datasets`, kind `lines`), and
+ * several values of one metric over time (kind `series`, the query).
+ */
+BxAnalyticsDashboard.prototype.drawChart = function (oJson, oData) {
+    var oContainer = oJson.previousElementSibling;
+    var oCanvas = oContainer ? oContainer.querySelector('canvas') : null;
+    if (!oCanvas || typeof Chart === 'undefined')
+        return;
+
+    if (oCanvas._bxAnalyticsChart) {
+        oCanvas._bxAnalyticsChart.destroy();
+        oCanvas._bxAnalyticsChart = null;
+    }
+
+    var $this = this;
+    var oColors = this.getColors(oContainer);
+    var bReducedMotion = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
+    if (oData.kind === 'series')
+        oData = this.getSeriesLines(oData);
+
+    var sKind = oData.kind || 'metric';
+    var sMetric = oData.metric || 'number';
+    var oChart;
+
+    var oScales = {
+        x: {
+            stacked: sKind === 'stacked',
+            grid: {display: false},
+            ticks: {color: oColors.muted, autoSkip: true, maxRotation: 0, maxTicksLimit: 8}
+        },
+        y: {
+            stacked: sKind === 'stacked',
+            beginAtZero: true,
+            grid: {color: oColors.grid},
+            border: {display: false},
+            ticks: {
+                color: oColors.muted,
+                precision: sMetric === 'pages_per_session' ? 1 : (sMetric === 'cls' ? undefined : 0),
+                callback: function (fValue) {
+                    return $this.formatMetric(sMetric, fValue, oData);
+                }
+            }
+        }
+    };
+
+    if (sKind === 'metric') {
+        var aLabels = [], aValues = [], aPartial = [];
+        oData.series.forEach(function (oPoint) {
+            aLabels.push($this.formatBucket(oPoint.t, oData.bucket, oData.tz));
+            aValues.push(oPoint.future ? null : oPoint[sMetric]);
+            aPartial.push(!!oPoint.partial);
+        });
+
+        oChart = {
+            type: 'line',
+            data: {
+                labels: aLabels,
+                datasets: [{
+                    label: oData.titles[sMetric],
+                    data: aValues,
+                    borderColor: oColors.accent,
+                    backgroundColor: oColors.fill,
+                    borderWidth: 2,
+                    fill: true,
+                    tension: 0.3,
+                    pointRadius: 0,
+                    pointHoverRadius: 4,
+                    spanGaps: false,
+                    // the period still in progress is drawn dashed, so its low value doesn't read as a drop
+                    segment: {
+                        borderDash: function (oContext) {
+                            return aPartial[oContext.p1DataIndex] ? [4, 4] : undefined;
+                        }
+                    }
+                }]
+            },
+            legend: false,
+            label: function (oContext) {
+                return oData.titles[sMetric] + ': ' + $this.formatMetric(sMetric, oContext.parsed.y, oData);
+            }
+        };
+    }
+    else {
+        oChart = {
+            type: sKind === 'stacked' ? 'bar' : 'line',
+            data: {
+                labels: oData.labels.map(function (iTime) { return $this.formatBucket(iTime, oData.bucket || 'day', oData.tz || 'UTC'); }),
+                datasets: oData.datasets.map(function (oSet, iIndex) {
+                    // a set may ask for a meaning colour (good, bad) instead of the next in the palette
+                    var sColor = oSet.tone && oColors[oSet.tone] ? oColors[oSet.tone] : oColors.palette[iIndex % oColors.palette.length];
+                    return {
+                        label: oSet.label,
+                        data: oSet.values,
+                        backgroundColor: sColor,
+                        borderColor: sColor,
+                        borderWidth: sKind === 'stacked' ? 0 : (oSet.dashed ? 1.5 : 2),
+                        // reference lines, such as a metric's thresholds
+                        borderDash: oSet.dashed ? [6, 4] : undefined,
+                        spanGaps: false,
+                        borderRadius: sKind === 'stacked' ? 2 : 0,
+                        pointRadius: 0,
+                        pointHoverRadius: sKind === 'stacked' ? 0 : 4,
+                        tension: 0.3,
+                        // the bucket still in progress is drawn dashed
+                        segment: oSet.partial ? {
+                            borderDash: function (oContext) {
+                                return oSet.partial[oContext.p1DataIndex] ? [4, 4] : undefined;
+                            }
+                        } : undefined
+                    };
+                })
+            },
+            legend: oData.legend || oData.datasets.length > 1,
+            label: function (oContext) {
+                return oContext.dataset.label + ': ' + $this.formatMetric(sMetric, oContext.parsed.y, oData);
+            }
+        };
+    }
+
+    oCanvas._bxAnalyticsChart = new Chart(oCanvas, {
+        type: oChart.type,
+        data: oChart.data,
+        options: {
+            responsive: true,
+            maintainAspectRatio: false,
+            animation: bReducedMotion ? false : {duration: 300},
+            interaction: {mode: 'index', intersect: false},
+            plugins: {
+                legend: {
+                    display: oChart.legend,
+                    position: 'bottom',
+                    labels: {color: oColors.muted, boxWidth: 12, boxHeight: 12}
+                },
+                tooltip: {
+                    callbacks: {label: oChart.label}
+                }
+            },
+            scales: oScales
+        }
+    });
+};
+
+BxAnalyticsDashboard.prototype.formatBucket = function (iTime, sBucket, sTz) {
+    var oDate = new Date(iTime * 1000);
+    var oOptions = {timeZone: sTz || 'UTC'};
+
+    if (sBucket === 'hour') {
+        oOptions.hour = 'numeric';
+        oOptions.minute = '2-digit';
+    }
+    else if (sBucket === 'month') {
+        oOptions.month = 'short';
+        oOptions.year = 'numeric';
+    }
+    else {
+        oOptions.month = 'short';
+        oOptions.day = 'numeric';
+    }
+
+    try {
+        return new Intl.DateTimeFormat(document.documentElement.lang || undefined, oOptions).format(oDate);
+    } catch (e) {
+        return oDate.toISOString().slice(0, sBucket === 'hour' ? 16 : 10);
+    }
+};
+
+BxAnalyticsDashboard.prototype.formatMetric = function (sMetric, fValue, oData) {
+    if (fValue === null || fValue === undefined)
+        return '';
+
+    switch (sMetric) {
+        // amounts in the site's default currency, which the chart data names
+        case 'money':
+            try {
+                if (oData && oData.currency)
+                    return new Intl.NumberFormat(document.documentElement.lang || undefined, {style: 'currency', currency: oData.currency, notation: Math.abs(fValue) >= 10000 ? 'compact' : 'standard', minimumFractionDigits: 0, maximumFractionDigits: Math.abs(fValue) >= 100 ? 0 : 2}).format(fValue);
+            } catch (e) {
+                // an unknown currency code: the number alone
+            }
+            break;
+
+        case 'bounce_rate':
+            return Math.round(fValue) + '%';
+
+        // timings: 850ms, 2.40s
+        case 'ms':
+            return fValue >= 1000 ? (fValue / 1000).toFixed(2) + (this._aStrings.duration_s || 's') : Math.round(fValue) + (this._aStrings.ms || 'ms');
+
+        // layout shift: a score
+        case 'cls':
+            return Number(fValue).toFixed(fValue && fValue < 0.1 ? 3 : 2);
+
+        case 'pages_per_session':
+            return Number(fValue).toFixed(1);
+
+        case 'duration':
+        case 'engaged':
+            var iSeconds = Math.round(fValue);
+            if (iSeconds < 60)
+                return iSeconds + (this._aStrings.duration_s || 's');
+            if (iSeconds < 3600)
+                return Math.floor(iSeconds / 60) + (this._aStrings.duration_m || 'm') + ' ' + (iSeconds % 60) + (this._aStrings.duration_s || 's');
+            return Math.floor(iSeconds / 3600) + (this._aStrings.duration_h || 'h') + ' ' + Math.floor((iSeconds % 3600) / 60) + (this._aStrings.duration_m || 'm');
+    }
+
+    try {
+        return new Intl.NumberFormat(document.documentElement.lang || undefined, {notation: 'compact', maximumFractionDigits: 1}).format(fValue);
+    } catch (e) {
+        return String(fValue);
+    }
+};
+
+/** @} */

+ 362 - 0
modules/boonex/analytics/updates/15.0.1_15.0.2/source/js/studio_tools.js

@@ -0,0 +1,362 @@
+/**
+ * Copyright (c) UNA, Inc - https://una.io
+ * MIT License - https://opensource.org/licenses/MIT
+ *
+ * @defgroup    Analytics Analytics
+ * @ingroup     UnaModules
+ *
+ * @{
+ */
+
+/**
+ * Studio tools of Analytics, for site administrators: the API playground, new analytics
+ * pages, and the tracker checks in Setup. Every action is a POST with the form token; the
+ * server renders any HTML, and report data is shown as text, never as HTML.
+ */
+function BxAnalyticsStudioTools(oOptions) {
+    this._sActionUrl = oOptions.sActionUrl;
+    this._aStrings = oOptions.aStrings || {};
+
+    // a report's response is shown up to this many characters
+    this._iMaxOutput = 1024 * 1024;
+
+    var $this = this;
+    if (document.readyState === 'loading')
+        document.addEventListener('DOMContentLoaded', function () { $this.init(); });
+    else
+        this.init();
+}
+
+BxAnalyticsStudioTools.prototype.init = function () {
+    var $this = this;
+
+    document.addEventListener('click', function (oEvent) {
+        var oTarget;
+
+        if ((oTarget = oEvent.target.closest('[data-bx-analytics-copy]'))) {
+            $this.copy(oTarget);
+            return;
+        }
+
+        if ((oTarget = oEvent.target.closest('[data-bx-analytics-setup]')))
+            $this.runSetup(oTarget);
+    });
+
+    document.addEventListener('submit', function (oEvent) {
+        var oForm;
+
+        if ((oForm = oEvent.target.closest('[data-bx-analytics-api-form]'))) {
+            oEvent.preventDefault();
+            $this.runApi(oForm);
+            return;
+        }
+
+        if ((oForm = oEvent.target.closest('[data-bx-analytics-dashboard-form]'))) {
+            oEvent.preventDefault();
+            $this.createDashboard(oForm);
+        }
+    });
+
+    var oApi = document.querySelector('[data-bx-analytics-api-form]');
+    if (oApi) {
+        var fUpdate = function () { $this.updateApi(oApi); };
+        oApi.querySelector('[data-bx-analytics-api-report]').addEventListener('change', fUpdate);
+        oApi.querySelector('[data-bx-analytics-api-params]').addEventListener('input', fUpdate);
+        fUpdate();
+    }
+};
+
+/**
+ * Copy the text of the element a button names, and say so for a moment.
+ */
+BxAnalyticsStudioTools.prototype.copy = function (oButton) {
+    var oSource = document.getElementById(oButton.getAttribute('data-bx-analytics-copy'));
+    if (!oSource)
+        return;
+
+    var sText = oSource.textContent;
+    var fDone = function () {
+        var sLabel = oButton.getAttribute('data-bx-analytics-copied');
+        if (!sLabel)
+            return;
+
+        if (!oButton._bxAnalyticsLabel)
+            oButton._bxAnalyticsLabel = oButton.textContent;
+        oButton.textContent = sLabel;
+        clearTimeout(oButton._bxAnalyticsTimer);
+        oButton._bxAnalyticsTimer = setTimeout(function () {
+            oButton.textContent = oButton._bxAnalyticsLabel;
+        }, 2000);
+    };
+
+    if (navigator.clipboard && window.isSecureContext) {
+        navigator.clipboard.writeText(sText).then(fDone, function () {
+            if (BxAnalyticsStudioTools.copyFallback(sText))
+                fDone();
+        });
+        return;
+    }
+
+    if (BxAnalyticsStudioTools.copyFallback(sText))
+        fDone();
+};
+
+/**
+ * Copy without the Clipboard API (a page served over plain HTTP).
+ */
+BxAnalyticsStudioTools.copyFallback = function (sText) {
+    var oArea = document.createElement('textarea');
+    oArea.value = sText;
+    oArea.setAttribute('readonly', '');
+    oArea.style.position = 'fixed';
+    oArea.style.opacity = '0';
+    document.body.appendChild(oArea);
+    oArea.select();
+
+    var bCopied = false;
+    try {
+        bCopied = document.execCommand('copy');
+    } catch (e) {
+        bCopied = false;
+    }
+    document.body.removeChild(oArea);
+
+    return bCopied;
+};
+
+/**
+ * The parameters typed in the playground: an object, or null when it isn't valid JSON.
+ */
+BxAnalyticsStudioTools.prototype.getApiParams = function (oForm) {
+    var sValue = oForm.querySelector('[data-bx-analytics-api-params]').value.trim();
+    if (sValue === '')
+        return {};
+
+    try {
+        var oParams = JSON.parse(sValue);
+        return oParams && typeof oParams === 'object' && !Array.isArray(oParams) ? oParams : null;
+    } catch (e) {
+        return null;
+    }
+};
+
+BxAnalyticsStudioTools.prototype.getApiCatalog = function (oForm) {
+    var oTool = oForm.closest('.bx-analytics-api');
+    var oJson = oTool ? oTool.querySelector('[data-bx-analytics-api-catalog]') : null;
+    if (!oJson)
+        return {root: '/', module: 'bx_analytics'};
+
+    if (!oJson._bxAnalyticsData) {
+        try {
+            oJson._bxAnalyticsData = JSON.parse(oJson.textContent);
+        } catch (e) {
+            oJson._bxAnalyticsData = {root: '/', module: 'bx_analytics'};
+        }
+    }
+
+    return oJson._bxAnalyticsData;
+};
+
+/**
+ * Show the chosen report's parameters and write the call three ways.
+ */
+BxAnalyticsStudioTools.prototype.updateApi = function (oForm) {
+    var sReport = oForm.querySelector('[data-bx-analytics-api-report]').value;
+    var oInput = oForm.querySelector('[data-bx-analytics-api-params]');
+    var oParams = this.getApiParams(oForm);
+    var oCatalog = this.getApiCatalog(oForm);
+
+    oForm.querySelectorAll('[data-bx-analytics-api-doc]').forEach(function (oDoc) {
+        oDoc.hidden = oDoc.getAttribute('data-bx-analytics-api-doc') !== sReport;
+    });
+
+    if (oParams === null) {
+        oInput.setAttribute('aria-invalid', 'true');
+        return;
+    }
+    oInput.removeAttribute('aria-invalid');
+
+    var sJson = JSON.stringify(oParams);
+    var aCalls = {
+        'bx-analytics-api-srv': "bx_srv('" + oCatalog.module + "', 'get_report', [" + this.toPhp(sReport) + ', ' + this.toPhp(oParams) + ']);',
+        'bx-analytics-api-url': oCatalog.root + 'api.php?r=' + oCatalog.module + '/get_report&params=' + encodeURIComponent(JSON.stringify([sReport, oParams])),
+        'bx-analytics-api-neo': '/api.php?r=' + oCatalog.module + '/get_report&params[]=' + encodeURIComponent(sReport) + '&params[]=' + encodeURIComponent(sJson)
+    };
+
+    Object.keys(aCalls).forEach(function (sId) {
+        var oCode = document.querySelector('#' + sId + ' code');
+        if (oCode)
+            oCode.textContent = aCalls[sId];
+    });
+};
+
+/**
+ * A JSON value as a PHP literal: short arrays, single-quoted strings.
+ */
+BxAnalyticsStudioTools.prototype.toPhp = function (mixedValue) {
+    var $this = this;
+
+    if (mixedValue === null || mixedValue === undefined)
+        return 'null';
+    if (typeof mixedValue === 'boolean')
+        return mixedValue ? 'true' : 'false';
+    if (typeof mixedValue === 'number')
+        return String(mixedValue);
+    if (typeof mixedValue === 'string')
+        return "'" + mixedValue.replace(/\\/g, '\\\\').replace(/'/g, "\\'") + "'";
+    if (Array.isArray(mixedValue))
+        return '[' + mixedValue.map(function (m) { return $this.toPhp(m); }).join(', ') + ']';
+
+    return '[' + Object.keys(mixedValue).map(function (sKey) {
+        return $this.toPhp(sKey) + ' => ' + $this.toPhp(mixedValue[sKey]);
+    }).join(', ') + ']';
+};
+
+/**
+ * Run the report as the signed-in administrator and show api.php's answer as text.
+ */
+BxAnalyticsStudioTools.prototype.runApi = function (oForm) {
+    var $this = this;
+    var oError = oForm.querySelector('.bx-analytics-form-error');
+    var oSubmit = oForm.querySelector('[type="submit"]');
+    var oTool = oForm.closest('.bx-analytics-api');
+    var oResult = oTool.querySelector('[data-bx-analytics-api-result]');
+    var oOutput = oTool.querySelector('[data-bx-analytics-api-output]');
+    var oMeta = oTool.querySelector('[data-bx-analytics-api-meta]');
+
+    oError.hidden = true;
+    if (this.getApiParams(oForm) === null) {
+        oError.textContent = this._aStrings.bad_json || '';
+        oError.hidden = false;
+        oForm.querySelector('[data-bx-analytics-api-params]').focus();
+        return;
+    }
+
+    var oData = new FormData(oForm);
+    oSubmit.disabled = true;
+    oMeta.textContent = this._aStrings.running || '';
+
+    this.post('api', oData)
+        .then(function (oResponse) {
+            if (!oResponse.ok)
+                throw new Error(oResponse.error || $this._aStrings.error || '');
+
+            var sJson = JSON.stringify(oResponse.response, null, 2);
+            var iBytes = typeof TextEncoder !== 'undefined' ? new TextEncoder().encode(JSON.stringify(oResponse.response)).length : sJson.length;
+            var bTruncated = sJson.length > $this._iMaxOutput;
+
+            oOutput.textContent = bTruncated ? sJson.slice(0, $this._iMaxOutput) + '\n…' : sJson;
+            oMeta.textContent = ($this._aStrings.meta || '')
+                .replace('{status}', String(oResponse.response.status))
+                .replace('{ms}', String(oResponse.ms))
+                .replace('{size}', $this.formatBytes(iBytes)) + (bTruncated ? ' ' + ($this._aStrings.truncated || '') : '');
+            oResult.hidden = false;
+        })
+        .catch(function (oException) {
+            oMeta.textContent = '';
+            oError.textContent = oException.message || $this._aStrings.error || '';
+            oError.hidden = false;
+        })
+        .then(function () {
+            oSubmit.disabled = false;
+        })
+        .catch(function () {}); // nothing is left to fail: the catch above reports errors
+};
+
+BxAnalyticsStudioTools.prototype.formatBytes = function (iBytes) {
+    var sLang = document.documentElement.lang || undefined;
+    try {
+        if (iBytes >= 1024 * 1024)
+            return new Intl.NumberFormat(sLang, {maximumFractionDigits: 1}).format(iBytes / 1024 / 1024) + ' MB';
+        if (iBytes >= 1024)
+            return new Intl.NumberFormat(sLang, {maximumFractionDigits: 1}).format(iBytes / 1024) + ' KB';
+    } catch (e) {
+        // a plain number below
+    }
+
+    return iBytes + ' B';
+};
+
+/**
+ * Create an analytics page and open it in the page builder.
+ */
+BxAnalyticsStudioTools.prototype.createDashboard = function (oForm) {
+    var $this = this;
+    var oError = oForm.querySelector('.bx-analytics-form-error');
+    var oSubmit = oForm.querySelector('[type="submit"]');
+    var oTitle = oForm.querySelector('[name="title"]');
+
+    oError.hidden = true;
+    if (!oTitle.value.trim()) {
+        oTitle.setAttribute('aria-invalid', 'true');
+        oTitle.focus();
+        return;
+    }
+    oTitle.removeAttribute('aria-invalid');
+
+    oSubmit.disabled = true;
+    this.post('dashboard_create', new FormData(oForm))
+        .then(function (oResponse) {
+            if (!oResponse.ok || !oResponse.url)
+                throw new Error(oResponse.error || $this._aStrings.error || '');
+
+            window.location.href = oResponse.url;
+        })
+        .catch(function (oException) {
+            oSubmit.disabled = false;
+            oError.textContent = oException.message || $this._aStrings.error || '';
+            oError.hidden = false;
+        });
+};
+
+/**
+ * Setup: check this browser (stores nothing) or send a test event (stores a visit).
+ */
+BxAnalyticsStudioTools.prototype.runSetup = function (oButton) {
+    var $this = this;
+    var sAction = oButton.getAttribute('data-bx-analytics-setup');
+    var oTool = oButton.closest('.bx-analytics-setup');
+    var oResult = oTool.querySelector('[data-bx-analytics-setup-result]');
+
+    var oData = new FormData();
+    oData.set('csrf_token', oButton.getAttribute('data-bx-analytics-csrf') || '');
+
+    oButton.disabled = true;
+    this.post(sAction, oData)
+        .then(function (oResponse) {
+            if (!oResponse.ok)
+                throw new Error(oResponse.error || $this._aStrings.error || '');
+
+            if (sAction === 'check') {
+                // rendered and escaped by the server
+                oResult.innerHTML = oResponse.html;
+                return;
+            }
+
+            oResult.textContent = oResponse.message || '';
+            var oStatus = oTool.querySelector('[data-bx-analytics-setup-status]');
+            if (oStatus && oResponse.html) {
+                oStatus.innerHTML = oResponse.html;
+                if (typeof bx_time === 'function')
+                    bx_time(undefined, false, oStatus);
+            }
+        })
+        .catch(function (oException) {
+            oResult.textContent = oException.message || $this._aStrings.error || '';
+        })
+        .then(function () {
+            oButton.disabled = false;
+        })
+        .catch(function () {}); // nothing is left to fail: the catch above reports errors
+};
+
+BxAnalyticsStudioTools.prototype.post = function (sAction, oData) {
+    return fetch(this._sActionUrl + sAction + '/', {method: 'POST', body: oData, credentials: 'same-origin'})
+        .then(function (oResponse) {
+            return oResponse.json().catch(function () {
+                return {error: ''};
+            });
+        });
+};
+
+/** @} */

+ 750 - 0
modules/boonex/analytics/updates/15.0.1_15.0.2/source/js/tracker.js

@@ -0,0 +1,750 @@
+/**
+ * Copyright (c) UNA, Inc - https://una.io
+ * MIT License - https://opensource.org/licenses/MIT
+ *
+ * @defgroup    Analytics Analytics
+ * @ingroup     UnaModules
+ *
+ * @{
+ */
+
+/**
+ * Pageviews, engaged time, custom events, errors and web vitals from the classic UI.
+ *
+ * Sends a pageview when a page loads and after every htmx navigation (not on preload), and the
+ * time the page was visible when it is hidden or left. Custom events:
+ *     bx_analytics.track('signup_click', {plan: 'pro'});
+ * Clicks on links to other sites and to files are sent as the custom events `outbound_link` and
+ * `file_download`, with the link (without its query string) in the `link` property.
+ * Errors the page catches itself:
+ *     bx_analytics.trackError(oError);
+ * Uncaught errors, unhandled rejections, failed htmx requests (500 and up) and the site's own
+ * scripts and styles that fail to load are sent when the site tracks errors: the same error
+ * once per page view with a count of repeats, at most 10 different ones, without query strings.
+ * Web vitals (TTFB, FCP, LCP, CLS, INP) are measured on a share of page loads and sent once,
+ * with the time the page was visible; an htmx navigation sends its load time, CLS and INP.
+ * Stores nothing in the browser unless the site uses persistent visitor ids: then a random id in
+ * localStorage, and in the bx_analytics_vid cookie for the site's own requests, so sign-ups and
+ * other actions join this browser's visit. To stop counting
+ * your own visits in this browser: localStorage.setItem('bx_analytics_ignore', '1').
+ */
+(function (window, document) {
+    'use strict';
+
+    var oScript = document.currentScript || document.querySelector('script[data-bx-analytics-endpoint]');
+    if (!oScript || window.bx_analytics_tracker)
+        return;
+
+    var sEndpoint = oScript.getAttribute('data-bx-analytics-endpoint');
+    var sPlatform = oScript.getAttribute('data-bx-analytics-platform') || 'core-web';
+    var bPersistent = oScript.getAttribute('data-bx-analytics-identity') === 'persistent';
+    var bRespectDnt = oScript.getAttribute('data-bx-analytics-dnt') === '1';
+    var bTrackErrors = oScript.getAttribute('data-bx-analytics-errors') === '1';
+    var iVitalsSample = parseInt(oScript.getAttribute('data-bx-analytics-vitals') || '0', 10) || 0;
+
+    var oNavigator = window.navigator || {};
+    var oPerformance = window.performance;
+
+    function storageGet(sKey) {
+        try {
+            return window.localStorage.getItem(sKey);
+        } catch (e) {
+            return null;
+        }
+    }
+
+    function isDisabled() {
+        if (!sEndpoint || oNavigator.webdriver)
+            return true;
+        if (bRespectDnt && (oNavigator.doNotTrack === '1' || window.doNotTrack === '1' || oNavigator.globalPrivacyControl === true))
+            return true;
+        return storageGet('bx_analytics_ignore') === '1';
+    }
+
+    function getVisitorId() {
+        if (!bPersistent)
+            return '';
+
+        var sId = storageGet('bx_analytics_vid');
+        if (sId && /^[a-f0-9]{32}$/.test(sId))
+            return sId;
+
+        sId = '';
+        var aBytes = new Uint8Array(16);
+        (window.crypto || window.msCrypto).getRandomValues(aBytes);
+        for (var i = 0; i < aBytes.length; i++)
+            sId += ('0' + aBytes[i].toString(16)).slice(-2);
+
+        try {
+            window.localStorage.setItem('bx_analytics_vid', sId);
+        } catch (e) {
+            return '';
+        }
+
+        return sId;
+    }
+
+    var bDisabled = isDisabled();
+    var sVisitorId = bDisabled ? '' : getVisitorId();
+
+    /**
+     * The visitor id for the site's own requests (persistent mode), or none: a cookie left
+     * from persistent mode, or from before tracking was turned off here, is removed.
+     */
+    function setVisitorCookie(sId) {
+        var sCookie = 'bx_analytics_vid=' + sId + '; path=/; SameSite=Lax; max-age=' + (sId ? 34560000 : 0);
+        if (window.location.protocol === 'https:')
+            sCookie += '; Secure';
+        try {
+            document.cookie = sCookie;
+        } catch (e) {
+            // cookies blocked: site actions are counted outside the visit
+        }
+    }
+
+    function hasVisitorCookie() {
+        try {
+            return /(?:^|;\s*)bx_analytics_vid=/.test(document.cookie || '');
+        } catch (e) {
+            return false;
+        }
+    }
+
+    if (sVisitorId)
+        setVisitorCookie(sVisitorId);
+    else if (hasVisitorCookie())
+        setVisitorCookie('');
+    var sReferrer = document.referrer;
+    var sCurrentUrl = '';
+    var iVisibleSince = 0;
+    var iEngaged = 0;
+
+    function send(aHits, bLeaving) {
+        if (bDisabled)
+            return;
+
+        // errors waiting for their batch go with whatever is sent
+        aHits = aHits.concat(takeErrors(bLeaving));
+        if (!aHits.length)
+            return;
+
+        var oPayload = {
+            platform: sPlatform,
+            language: oNavigator.language || '',
+            screen: window.screen ? window.screen.width + 'x' + window.screen.height : '',
+            hits: aHits
+        };
+        if (sReferrer)
+            oPayload.referrer = sReferrer;
+        if (sVisitorId)
+            oPayload.vid = sVisitorId;
+
+        var sBody = JSON.stringify(oPayload);
+
+        // text/plain keeps the beacon a simple request
+        if (oNavigator.sendBeacon && oNavigator.sendBeacon(sEndpoint, new Blob([sBody], {type: 'text/plain'})))
+            return;
+
+        if (window.fetch)
+            window.fetch(sEndpoint, {method: 'POST', body: sBody, keepalive: true, credentials: 'same-origin', headers: {'Content-Type': 'text/plain'}}).catch(function () {});
+    }
+
+    function engagedNow() {
+        var iTotal = iEngaged;
+        if (iVisibleSince)
+            iTotal += Date.now() - iVisibleSince;
+        return iTotal;
+    }
+
+    function leaveHit() {
+        var iMs = engagedNow();
+        iEngaged = 0;
+        iVisibleSince = document.visibilityState === 'visible' ? Date.now() : 0;
+
+        return iMs > 0 && sCurrentUrl ? {type: 'leave', url: sCurrentUrl, engaged: iMs} : null;
+    }
+
+    /**
+     * What a page left behind: engaged time and its vitals, sent together.
+     */
+    function leaveHits() {
+        var aHits = [];
+        var oLeave = leaveHit();
+        if (oLeave)
+            aHits.push(oLeave);
+
+        var oVitals = takeVitals();
+        if (oVitals)
+            aHits.push(oVitals);
+
+        return aHits;
+    }
+
+    function pageview(sNav) {
+        var sUrl = window.location.href;
+        if (sUrl === sCurrentUrl)
+            return;
+
+        var aHits = [];
+        var bFirst = !sCurrentUrl;
+        if (!bFirst) {
+            aHits = leaveHits().concat(takeErrors(true));
+            resetErrors();
+            startSoftView(sNav || 'soft');
+        }
+
+        // after the first page, the referrer of a page is the page before it
+        if (sCurrentUrl)
+            sReferrer = sCurrentUrl;
+
+        sCurrentUrl = sUrl;
+        aHits.push({type: 'pageview', url: sUrl, title: document.title});
+
+        send(aHits);
+    }
+
+    /*
+     * Errors
+     */
+
+    var MAX_ERRORS = 10;
+    var oErrors = {};
+    var aErrorKeys = [];
+    var iErrorTimer = 0;
+
+    function isSameOrigin(sUrl) {
+        try {
+            return new URL(sUrl, window.location.href).origin === window.location.origin;
+        } catch (e) {
+            return false;
+        }
+    }
+
+    /**
+     * A URL without its query string and fragment (only the page parameters `i` and `r` stay),
+     * and without the site's own origin.
+     */
+    function cleanUrl(sUrl) {
+        var oUrl;
+        try {
+            oUrl = new URL(sUrl, window.location.href);
+        } catch (e) {
+            return String(sUrl || '').replace(/[?#].*$/, '');
+        }
+
+        var aKeep = [];
+        ['i', 'r'].forEach(function (sKey) {
+            var sValue = oUrl.searchParams.get(sKey);
+            if (sValue)
+                aKeep.push(sKey + '=' + encodeURIComponent(sValue).replace(/%2F/gi, '/'));
+        });
+
+        return (oUrl.origin === window.location.origin ? '' : oUrl.origin) + oUrl.pathname + (aKeep.length ? '?' + aKeep.join('&') : '');
+    }
+
+    function pageUrl() {
+        var sPath = cleanUrl(window.location.href);
+        return window.location.origin + sPath;
+    }
+
+    /**
+     * Text with every URL in it cleaned; a stack frame's :line:column stays.
+     */
+    function cleanText(s) {
+        return String(s || '').replace(/\b(?:https?|wss?):\/\/[^\s()<>"'`]+/gi, function (sUrl) {
+            var aPosition = sUrl.match(/(:\d+){1,2}$/);
+            var sPosition = aPosition ? aPosition[0] : '';
+            return cleanUrl(sPosition ? sUrl.slice(0, -sPosition.length) : sUrl) + sPosition;
+        });
+    }
+
+    function isExtension(s) {
+        return /\b[a-z-]*extension:\/\//i.test(String(s || ''));
+    }
+
+    /**
+     * At most 30 frames, without the frames of browser extensions.
+     */
+    function cleanStack(sStack) {
+        if (typeof sStack !== 'string' || !sStack)
+            return '';
+
+        return cleanText(sStack.split('\n').filter(function (sLine) {
+            return sLine.trim() !== '' && !isExtension(sLine);
+        }).slice(0, 30).join('\n')).slice(0, 8000);
+    }
+
+    /**
+     * Message and stack of whatever was thrown.
+     */
+    function describe(mixedError, sPrefix) {
+        if (mixedError && typeof mixedError === 'object' && typeof mixedError.message === 'string')
+            return {message: (mixedError.name ? mixedError.name + ': ' : '') + mixedError.message, stack: mixedError.stack};
+
+        // an object that is no Error: its kind and keys, not its values (a form or an API
+        // payload would carry what someone typed)
+        var sText;
+        try {
+            if (Array.isArray(mixedError))
+                sText = 'Array(' + mixedError.length + ')';
+            else if (mixedError && typeof mixedError === 'object') {
+                var aKeys = Object.keys(mixedError);
+                sText = (mixedError.constructor && mixedError.constructor.name || 'Object') + '{' + aKeys.slice(0, 10).join(',') + (aKeys.length > 10 ? ',...' : '') + '}';
+            }
+            else
+                sText = String(mixedError);
+        } catch (e) {
+            sText = Object.prototype.toString.call(mixedError);
+        }
+
+        return {message: (sPrefix || '') + String(sText).slice(0, 200), stack: ''};
+    }
+
+    function captureError(sKind, oData) {
+        if (bDisabled || !bTrackErrors)
+            return;
+
+        var sStack = cleanStack(oData.stack);
+        var sMessage = cleanText(oData.message).trim().slice(0, 500);
+        var sSource = cleanText(oData.source || '');
+        if (!sMessage || (/^Script error\.?$/i.test(sMessage) && !sStack))
+            return;
+
+        // an extension's error, or one whose stack was all extension frames
+        if (isExtension(oData.source) || (oData.stack && !sStack && isExtension(oData.stack)))
+            return;
+
+        var sKey = [sKind, sMessage, sSource, oData.line || 0].join('|');
+        var oItem = oErrors[sKey];
+        if (!oItem) {
+            if (aErrorKeys.length >= MAX_ERRORS)
+                return;
+
+            aErrorKeys.push(sKey);
+            oItem = oErrors[sKey] = {
+                hit: {
+                    type: 'error',
+                    url: pageUrl(),
+                    kind: sKind,
+                    message: sMessage,
+                    source: sSource || undefined,
+                    line: oData.line || undefined,
+                    col: oData.col || undefined,
+                    stack: sStack || undefined,
+                    status: oData.status || undefined,
+                    fatal: oData.fatal ? 1 : undefined
+                },
+                pending: 0,
+                sent: false,
+                time: 0
+            };
+        }
+
+        oItem.pending = Math.min(100, oItem.pending + 1);
+        oItem.time = Date.now();
+
+        // a new error goes out within 2 seconds; its repeats go with the page's last beacon
+        if (!oItem.sent && !iErrorTimer)
+            iErrorTimer = setTimeout(function () {
+                iErrorTimer = 0;
+                send([]);
+            }, 2000);
+    }
+
+    /**
+     * Error hits to send: new errors, and the repeats of sent ones when the page is left.
+     */
+    function takeErrors(bLeaving) {
+        var aHits = [];
+        aErrorKeys.forEach(function (sKey) {
+            var oItem = oErrors[sKey];
+            if (!oItem.pending || (oItem.sent && !bLeaving))
+                return;
+
+            var oHit = {};
+            for (var sField in oItem.hit)
+                oHit[sField] = oItem.hit[sField];
+            oHit.repeats = oItem.pending;
+            oHit.age = Math.max(0, Date.now() - oItem.time);
+
+            oItem.pending = 0;
+            oItem.sent = true;
+            aHits.push(oHit);
+        });
+
+        return aHits;
+    }
+
+    function resetErrors() {
+        oErrors = {};
+        aErrorKeys = [];
+    }
+
+    function onHttpError(oEvent) {
+        var oDetail = oEvent.detail || {};
+        var oCtx = oDetail.ctx || {};
+        var iStatus = oCtx.response ? oCtx.response.status : (oDetail.xhr ? oDetail.xhr.status : 0);
+        if (!iStatus || iStatus < 500)
+            return;
+
+        var sUrl = oCtx.request && oCtx.request.action ? oCtx.request.action : (oDetail.pathInfo && oDetail.pathInfo.requestPath ? oDetail.pathInfo.requestPath : (oDetail.xhr && oDetail.xhr.responseURL) || '');
+        var sPath = cleanUrl(sUrl);
+        captureError('http', {message: 'HTTP ' + iStatus + ' ' + sPath, source: sPath, status: iStatus});
+    }
+
+    /*
+     * Web vitals
+     */
+
+    // a roll in [0, 100) from the CSPRNG getVisitorId() uses; no vitals without one
+    var oCrypto = window.crypto || window.msCrypto;
+    var fVitalsRoll = oCrypto && oCrypto.getRandomValues ? oCrypto.getRandomValues(new Uint32Array(1))[0] / 4294967296 * 100 : 100;
+    var bVitals = !bDisabled && iVitalsSample > 0 && fVitalsRoll < iVitalsSample && typeof window.PerformanceObserver === 'function' && !!oPerformance && typeof oPerformance.now === 'function';
+    var aSupported = bVitals && window.PerformanceObserver.supportedEntryTypes ? window.PerformanceObserver.supportedEntryTypes : [];
+    var oView = null;
+    var aObservers = [];
+    var iFirstHidden = document.visibilityState === 'hidden' ? 0 : Infinity;
+    var iSoftStart = 0;
+    var iLastSettle = 0;
+
+    function getNavigation() {
+        var aEntries = oPerformance && oPerformance.getEntriesByType ? oPerformance.getEntriesByType('navigation') : [];
+        return aEntries && aEntries[0] ? aEntries[0] : null;
+    }
+
+    function getActivationStart() {
+        var oNavigation = getNavigation();
+        return oNavigation && oNavigation.activationStart > 0 ? oNavigation.activationStart : 0;
+    }
+
+    function newView(sNav, iStart, bHard) {
+        oView = {nav: sNav, url: pageUrl(), start: iStart, hard: bHard, metrics: {}, lcpDone: !bHard, cls: 0, clsWindow: 0, clsFirst: 0, clsLast: 0, inp: {}, interactions: 0, waitLoad: false, sent: false};
+    }
+
+    function startSoftView(sNav) {
+        if (!bVitals)
+            return;
+
+        // the navigation's request, when htmx made one for it a moment ago
+        var iNow = oPerformance.now();
+        var iStart = iSoftStart && iNow - iSoftStart < 30000 ? iSoftStart : 0;
+        iSoftStart = 0;
+
+        newView(sNav, iStart || iNow, false);
+        if (iStart && iLastSettle > iStart)
+            oView.metrics.screen_load = iLastSettle - iStart;
+        else if (iStart)
+            oView.waitLoad = true;
+    }
+
+    function observe(sType, fCallback, oOptions) {
+        if (aSupported.indexOf(sType) === -1)
+            return false;
+
+        try {
+            var oParams = {type: sType, buffered: true};
+            for (var sKey in oOptions || {})
+                oParams[sKey] = oOptions[sKey];
+
+            var oObserver = new window.PerformanceObserver(function (oList) {
+                oList.getEntries().forEach(fCallback);
+            });
+            oObserver.observe(oParams);
+            aObservers.push({observer: oObserver, callback: fCallback});
+        } catch (e) {
+            return false;
+        }
+
+        return true;
+    }
+
+    /**
+     * Entries recorded but not yet delivered (observers are called in a later task), so the
+     * last shifts, interaction and paint count before a view's vitals are read.
+     */
+    function flushObservers() {
+        aObservers.forEach(function (oItem) {
+            try {
+                oItem.observer.takeRecords().forEach(oItem.callback);
+            } catch (e) {}
+        });
+    }
+
+    function onInteraction(oEntry) {
+        if (!oView || !oEntry.interactionId || oEntry.startTime < oView.start)
+            return;
+
+        var iId = oEntry.interactionId;
+        if (oView.inp[iId] === undefined)
+            oView.interactions++;
+        if (oView.inp[iId] === undefined || oEntry.duration > oView.inp[iId])
+            oView.inp[iId] = oEntry.duration;
+
+        // only the longest ones matter: keep the list short
+        var aIds = Object.keys(oView.inp);
+        if (aIds.length > 50) {
+            aIds.sort(function (a, b) { return oView.inp[b] - oView.inp[a]; });
+            aIds.slice(10).forEach(function (sId) { delete oView.inp[sId]; });
+        }
+    }
+
+    /**
+     * The view's vitals hit, once; null when nothing was measured.
+     */
+    function takeVitals() {
+        if (!oView || oView.sent)
+            return null;
+        flushObservers();
+        oView.sent = true;
+
+        var oMetrics = {};
+        for (var sKey in oView.metrics)
+            oMetrics[sKey] = oView.metrics[sKey];
+
+        if (oView.hard) {
+            var oNavigation = getNavigation();
+            if (oNavigation && oNavigation.responseStart > 0)
+                oMetrics.ttfb = Math.max(0, oNavigation.responseStart - getActivationStart());
+        }
+
+        if (aSupported.indexOf('layout-shift') !== -1)
+            oMetrics.cls = Math.round(oView.cls * 10000) / 10000;
+
+        // INP: the longest interaction, or a high percentile of many (one in 50 left out)
+        var aDurations = Object.keys(oView.inp).map(function (sId) { return oView.inp[sId]; }).sort(function (a, b) { return b - a; });
+        var iCount = Math.max(oView.interactions, oView.hard && oPerformance.interactionCount ? oPerformance.interactionCount : 0);
+        if (aDurations.length)
+            oMetrics.inp = aDurations[Math.min(aDurations.length - 1, Math.min(9, Math.floor(iCount / 50)))];
+
+        var bAny = false;
+        for (sKey in oMetrics) {
+            if (sKey !== 'cls')
+                oMetrics[sKey] = Math.round(oMetrics[sKey]);
+            bAny = true;
+        }
+
+        return bAny ? {type: 'vitals', url: oView.url, nav: oView.nav, metrics: oMetrics} : null;
+    }
+
+    function stopLcp() {
+        if (!oView || oView.lcpDone)
+            return;
+        // a paint before the input may not be delivered yet
+        flushObservers();
+        oView.lcpDone = true;
+    }
+
+    function startVitals() {
+        var oNavigation = getNavigation();
+        var sNav = oNavigation && oNavigation.type ? String(oNavigation.type).replace('-', '_') : 'navigate';
+        if (document.prerendering || getActivationStart() > 0)
+            sNav = 'prerender';
+
+        newView(sNav, 0, true);
+
+        observe('paint', function (oEntry) {
+            if (oView && oView.hard && oEntry.name === 'first-contentful-paint' && oEntry.startTime < iFirstHidden)
+                oView.metrics.fcp = Math.max(0, oEntry.startTime - getActivationStart());
+        });
+
+        // the largest paint until the visitor first clicks or types, or leaves
+        observe('largest-contentful-paint', function (oEntry) {
+            if (oView && oView.hard && !oView.lcpDone && oEntry.startTime < iFirstHidden)
+                oView.metrics.lcp = Math.max(0, oEntry.startTime - getActivationStart());
+        });
+        ['pointerdown', 'keydown'].forEach(function (sEvent) {
+            window.addEventListener(sEvent, stopLcp, {capture: true, passive: true});
+        });
+
+        // layout shifts in session windows (less than 1 s apart, 5 s at most): the largest counts
+        observe('layout-shift', function (oEntry) {
+            if (!oView || oEntry.hadRecentInput || oEntry.startTime < oView.start)
+                return;
+
+            if (oView.clsWindow && oEntry.startTime - oView.clsLast < 1000 && oEntry.startTime - oView.clsFirst < 5000)
+                oView.clsWindow += oEntry.value;
+            else {
+                oView.clsWindow = oEntry.value;
+                oView.clsFirst = oEntry.startTime;
+            }
+            oView.clsLast = oEntry.startTime;
+            oView.cls = Math.max(oView.cls, oView.clsWindow);
+        });
+
+        observe('event', onInteraction, {durationThreshold: 40});
+        observe('first-input', onInteraction);
+
+        // htmx navigations: from the page's request to its content settled
+        var fRequest = function (oEvent) {
+            var oCtx = oEvent.detail && oEvent.detail.ctx;
+            var oHeaders = oCtx && oCtx.request && oCtx.request.headers;
+            if (!oHeaders || !oHeaders['HX-Request-Type'] || oHeaders['HX-Request-Type'] === 'full')
+                iSoftStart = oPerformance.now();
+        };
+        var fSettle = function () {
+            iLastSettle = oPerformance.now();
+            if (oView && oView.waitLoad) {
+                oView.metrics.screen_load = iLastSettle - oView.start;
+                oView.waitLoad = false;
+            }
+        };
+        document.addEventListener('htmx:before:request', fRequest);
+        document.addEventListener('htmx:beforeRequest', fRequest);
+        document.addEventListener('htmx:after:settle', fSettle);
+        document.addEventListener('htmx:afterSettle', fSettle);
+
+        // a page restored from the back-forward cache is a new view without a load
+        window.addEventListener('pageshow', function (oEvent) {
+            if (oEvent.persisted)
+                newView('restore', oPerformance.now(), false);
+        });
+    }
+
+    window.bx_analytics_tracker = true;
+
+    /**
+     * Public API for custom events. `props` holds short scalar values.
+     */
+    window.bx_analytics = window.bx_analytics || {};
+    window.bx_analytics.track = function (sName, oProps, oOptions) {
+        if (!sName)
+            return;
+
+        send([{
+            type: 'event',
+            name: String(sName),
+            url: window.location.href,
+            props: oProps || undefined,
+            value: oOptions && typeof oOptions.value === 'number' ? oOptions.value : undefined,
+            non_interactive: oOptions && oOptions.nonInteractive ? 1 : undefined
+        }]);
+    };
+
+    /**
+     * An error the page caught itself. Options: `fatal` when the page can't go on.
+     */
+    window.bx_analytics.trackError = function (mixedError, oOptions) {
+        var oError = describe(mixedError);
+        captureError('js', {message: oError.message, stack: oError.stack, fatal: oOptions && oOptions.fatal});
+    };
+
+    if (bDisabled)
+        return;
+
+    iVisibleSince = document.visibilityState === 'visible' ? Date.now() : 0;
+
+    document.addEventListener('visibilitychange', function () {
+        if (document.visibilityState === 'hidden') {
+            iFirstHidden = Math.min(iFirstHidden, oPerformance && oPerformance.now ? oPerformance.now() : 0);
+            stopLcp();
+            send(leaveHits(), true);
+        }
+        else if (!iVisibleSince) {
+            iVisibleSince = Date.now();
+        }
+    });
+    window.addEventListener('pagehide', function () {
+        stopLcp();
+        send(leaveHits(), true);
+    });
+
+    if (bTrackErrors) {
+        // capture: script and stylesheet elements that fail to load don't bubble
+        window.addEventListener('error', function (oEvent) {
+            var oTarget = oEvent.target;
+            if (oTarget && oTarget !== window && oTarget.nodeType === 1) {
+                var sUrl = oTarget.tagName === 'SCRIPT' ? oTarget.src : (oTarget.tagName === 'LINK' && /stylesheet/i.test(oTarget.rel) ? oTarget.href : '');
+                // the site's own files: a blocked tracker or ad elsewhere is not the site's error
+                if (sUrl && isSameOrigin(sUrl))
+                    captureError('resource', {message: 'Failed to load ' + (oTarget.tagName === 'SCRIPT' ? 'script' : 'stylesheet') + ' ' + cleanUrl(sUrl), source: sUrl});
+                return;
+            }
+
+            captureError('js', {
+                message: oEvent.message || (oEvent.error && oEvent.error.message) || '',
+                source: oEvent.filename,
+                line: oEvent.lineno,
+                col: oEvent.colno,
+                stack: oEvent.error && oEvent.error.stack
+            });
+        }, true);
+
+        window.addEventListener('unhandledrejection', function (oEvent) {
+            var oError = describe(oEvent.reason, 'Non-Error promise rejection: ');
+            captureError('promise', {message: oError.message, stack: oError.stack});
+        });
+
+        document.addEventListener('htmx:response:error', onHttpError);
+        document.addEventListener('htmx:responseError', onHttpError);
+    }
+
+    if (bVitals)
+        startVitals();
+
+    // htmx navigation pushes the new URL after swapping the page in (htmx 4 and older names)
+    ['htmx:after:history:push', 'htmx:after:history:replace', 'htmx:pushedIntoHistory', 'htmx:replacedInHistory'].forEach(function (sEvent) {
+        document.addEventListener(sEvent, function () {
+            setTimeout(pageview, 0);
+        });
+    });
+    window.addEventListener('popstate', function () {
+        setTimeout(function () {
+            pageview('back_forward');
+        }, 0);
+    });
+
+    /*
+     * Outbound links and downloads
+     */
+
+    var rDownload = /\.(pdf|zip|rar|7z|gz|tgz|tar|dmg|exe|msi|pkg|apk|ipa|docx?|xlsx?|pptx?|odt|ods|csv|txt|rtf|mp3|wav|flac|mp4|mov|avi|mkv|webm|epub)$/i;
+
+    /**
+     * A click on a link to another site (`outbound_link`) or to a file (`file_download`: a
+     * `download` attribute or a file extension, in the path or in UNA's `r` and `f` parameters
+     * when permalinks are off), sent as a custom event with the link without its query string
+     * and with a download route's token masked. The beacon survives the navigation the click
+     * starts.
+     */
+    function onLinkClick(oEvent) {
+        if (oEvent.type === 'auxclick' && oEvent.button !== 1)
+            return;
+
+        var oLink = oEvent.target && oEvent.target.closest ? oEvent.target.closest('a[href]') : null;
+        if (!oLink)
+            return;
+
+        var sHref = oLink.getAttribute('href') || '';
+        if (!sHref || sHref.charAt(0) === '#' || /^(javascript|mailto|tel|sms|data|blob):/i.test(sHref))
+            return;
+
+        var oUrl;
+        try {
+            oUrl = new URL(oLink.href, window.location.href);
+        } catch (e) {
+            return;
+        }
+        if (oUrl.protocol !== 'http:' && oUrl.protocol !== 'https:')
+            return;
+
+        var sName = '';
+        if (oLink.hasAttribute('download') || rDownload.test(oUrl.pathname) || rDownload.test(oUrl.searchParams.get('r') || '') || rDownload.test(oUrl.searchParams.get('f') || ''))
+            sName = 'file_download';
+        else if (oUrl.origin !== window.location.origin)
+            sName = 'outbound_link';
+        if (!sName)
+            return;
+
+        // files/download/<token>/<id>.<ext>: the token opens the file, keep it out of reports
+        var sLinkUrl = cleanUrl(oUrl.href).replace(/(\/download\/)[^\/?#&]+\//, '$1*/');
+        send([{type: 'event', name: sName, url: window.location.href, props: {link: sLinkUrl}}]);
+    }
+
+    // on the document, so links htmx swaps in later count too
+    document.addEventListener('click', onLinkClick, true);
+    document.addEventListener('auxclick', onLinkClick, true);
+
+    pageview();
+})(window, document);
+
+/** @} */

+ 24 - 0
modules/boonex/analytics/updates/15.0.1_15.0.2/source/template/api_params.html

@@ -0,0 +1,24 @@
+<bx_if:params>
+    <div class="bx-analytics-table-wrap" role="region" aria-label="__caption__" tabindex="0">
+        <table class="bx-analytics-table bx-analytics-tool-table bx-analytics-api-table">
+            <caption class="bx-analytics-sr">__caption__</caption>
+            <thead>
+                <tr>
+                    <th scope="col" class="bx-analytics-col-text">__col_param__</th>
+                    <th scope="col" class="bx-analytics-col-text">__col_accepts__</th>
+                </tr>
+            </thead>
+            <tbody>
+                <bx_repeat:params>
+                    <tr>
+                        <td class="bx-analytics-col-text"><code>__param_name__</code></td>
+                        <td class="bx-analytics-col-text bx-analytics-col-wrap">__param_accepts__</td>
+                    </tr>
+                </bx_repeat:params>
+            </tbody>
+        </table>
+    </div>
+</bx_if:params>
+<bx_if:none>
+    <p class="bx-analytics-note">__none__</p>
+</bx_if:none>

+ 14 - 0
modules/boonex/analytics/updates/15.0.1_15.0.2/source/template/bars.html

@@ -0,0 +1,14 @@
+<bx_if:items>
+    <ul class="bx-analytics-rows">
+        <bx_repeat:items>
+            <li class="bx-analytics-row bx-analytics-std-platform">
+                <span class="bx-analytics-row-bar" style="width: __bar__%" aria-hidden="true"></span>
+                <span class="bx-analytics-row-label">__label__</span>
+                <span class="bx-analytics-row-value">__value__ <span class="bx-analytics-row-share">__share__%</span></span>
+            </li>
+        </bx_repeat:items>
+    </ul>
+</bx_if:items>
+<bx_if:empty>
+    <div class="bx-analytics-empty">__empty__</div>
+</bx_if:empty>

+ 1 - 0
modules/boonex/analytics/updates/15.0.1_15.0.2/source/template/block.html

@@ -0,0 +1 @@
+<div class="bx-analytics bx-analytics-block-__class__" data-bx-analytics-block="__report__" data-bx-analytics-params="__params__">__content__</div>

+ 15 - 0
modules/boonex/analytics/updates/15.0.1_15.0.2/source/template/bots.html

@@ -0,0 +1,15 @@
+__tabs__
+<div class="bx-analytics-panel" role="tabpanel" id="bx-analytics-panel-bots">
+    __tiles__
+    <div class="bx-analytics-chart bx-analytics-chart-sm">
+        <canvas role="img" aria-label="__chart_label__"></canvas>
+    </div>
+    <script type="application/json" class="bx-analytics-chart-data">__chart_json__</script>
+    <div class="bx-analytics-columns">
+        <div class="bx-analytics-column-wide">__bots__</div>
+        <div>
+            <h3 class="bx-analytics-subtitle">__pages_title__</h3>
+            __pages__
+        </div>
+    </div>
+</div>

+ 7 - 0
modules/boonex/analytics/updates/15.0.1_15.0.2/source/template/code.html

@@ -0,0 +1,7 @@
+<div class="bx-analytics-code-block">
+    <div class="bx-analytics-code-head">
+        <span class="bx-analytics-code-label" id="__code_id__-label">__code_label__</span>
+        <button type="button" class="bx-analytics-copy-btn" data-bx-analytics-copy="__code_id__" data-bx-analytics-copied="__copied__" aria-describedby="__code_id__-label">__copy__</button>
+    </div>
+    <pre class="bx-analytics-code" id="__code_id__" tabindex="0" aria-labelledby="__code_id__-label"><code>__code__</code></pre>
+</div>

+ 21 - 0
modules/boonex/analytics/updates/15.0.1_15.0.2/source/template/credits.html

@@ -0,0 +1,21 @@
+<bx_if:credits>
+    __tiles__
+    <div class="bx-analytics-chart bx-analytics-chart-sm">
+        <canvas role="img" aria-label="__chart_label__"></canvas>
+    </div>
+    <script type="application/json" class="bx-analytics-chart-data">__chart_json__</script>
+    <div class="bx-analytics-columns">
+        <div>
+            <h3 class="bx-analytics-subtitle">__spent_title__</h3>
+            __spent__
+        </div>
+        <div>
+            <h3 class="bx-analytics-subtitle">__spenders_title__</h3>
+            __spenders__
+        </div>
+    </div>
+</bx_if:credits>
+<bx_if:none>
+    <div class="bx-analytics-empty">__empty__</div>
+</bx_if:none>
+__notes__

+ 1743 - 0
modules/boonex/analytics/updates/15.0.1_15.0.2/source/template/css/dashboard.css

@@ -0,0 +1,1743 @@
+/**
+ * Analytics dashboard blocks: overview, top lists and the Studio dashboard traffic block.
+ * Colours are custom properties, read by the chart script too, with a dark set for html.dark.
+ */
+.bx-analytics,
+.bx-analytics-std {
+    --bx-analytics-accent: #4f46e5;
+    --bx-analytics-accent-fill: rgba(79, 70, 229, 0.12);
+    --bx-analytics-bar: rgba(79, 70, 229, 0.09);
+    --bx-analytics-hover: rgba(15, 23, 42, 0.05);
+    --bx-analytics-border: rgba(15, 23, 42, 0.1);
+    --bx-analytics-grid: rgba(15, 23, 42, 0.08);
+    --bx-analytics-muted: #57606a;
+    --bx-analytics-good: #047857;
+    --bx-analytics-bad: #b91c1c;
+    --bx-analytics-warn: #b45309;
+    --bx-analytics-online: #16a34a;
+    --bx-analytics-c1: #4f46e5;
+    --bx-analytics-c2: #0891b2;
+    --bx-analytics-c3: #d97706;
+    --bx-analytics-c4: #db2777;
+    --bx-analytics-c5: #16a34a;
+    --bx-analytics-c6: #7c3aed;
+    --bx-analytics-c7: #dc2626;
+    --bx-analytics-c8: #64748b;
+}
+html.dark .bx-analytics,
+html.dark .bx-analytics-std {
+    --bx-analytics-accent: #a5b4fc;
+    --bx-analytics-accent-fill: rgba(165, 180, 252, 0.14);
+    --bx-analytics-bar: rgba(165, 180, 252, 0.12);
+    --bx-analytics-hover: rgba(255, 255, 255, 0.06);
+    --bx-analytics-border: rgba(255, 255, 255, 0.1);
+    --bx-analytics-grid: rgba(255, 255, 255, 0.08);
+    --bx-analytics-muted: #a8b0bb;
+    --bx-analytics-good: #6ee7b7;
+    --bx-analytics-bad: #fca5a5;
+    --bx-analytics-warn: #fcd34d;
+    --bx-analytics-online: #4ade80;
+    --bx-analytics-c1: #a5b4fc;
+    --bx-analytics-c2: #67e8f9;
+    --bx-analytics-c3: #fcd34d;
+    --bx-analytics-c4: #f9a8d4;
+    --bx-analytics-c5: #86efac;
+    --bx-analytics-c6: #c4b5fd;
+    --bx-analytics-c7: #fca5a5;
+    --bx-analytics-c8: #cbd5e1;
+}
+
+.bx-analytics {
+    position: relative;
+    transition: opacity 0.15s ease;
+}
+.bx-analytics button {
+    font: inherit;
+    color: inherit;
+    background: none;
+    border: 0;
+    margin: 0;
+    cursor: pointer;
+    text-align: inherit;
+}
+.bx-analytics button:focus-visible,
+.bx-analytics select:focus-visible,
+.bx-analytics-std a:focus-visible {
+    outline: 2px solid var(--bx-analytics-accent);
+    outline-offset: 2px;
+}
+.bx-analytics-loading {
+    opacity: 0.55;
+    pointer-events: none;
+}
+.bx-analytics-failed::after {
+    content: attr(data-bx-analytics-error);
+    display: block;
+    margin-top: 0.5rem;
+    color: var(--bx-analytics-bad);
+    font-size: 0.875rem;
+}
+.bx-analytics-sr {
+    position: absolute;
+    width: 1px;
+    height: 1px;
+    margin: -1px;
+    padding: 0;
+    overflow: hidden;
+    clip: rect(0, 0, 0, 0);
+    white-space: nowrap;
+    border: 0;
+}
+
+/* toolbar */
+.bx-analytics-toolbar {
+    display: flex;
+    flex-wrap: wrap;
+    align-items: flex-end;
+    justify-content: space-between;
+    gap: 0.75rem;
+    margin-bottom: 1rem;
+}
+.bx-analytics-controls {
+    display: flex;
+    flex-wrap: wrap;
+    align-items: flex-end;
+    gap: 0.5rem 0.75rem;
+}
+.bx-analytics-control {
+    display: flex;
+    flex-direction: column;
+    gap: 0.25rem;
+}
+.bx-analytics-control-label {
+    font-size: 0.75rem;
+    color: var(--bx-analytics-muted);
+}
+.bx-analytics-select {
+    min-width: 10rem;
+}
+.bx-analytics-chips {
+    display: flex;
+    flex-wrap: wrap;
+    gap: 0.5rem;
+}
+.bx-analytics .bx-analytics-chip {
+    display: inline-flex;
+    align-items: center;
+    gap: 0.25rem;
+    min-height: 2.25rem;
+    padding: 0 0.75rem;
+    border-radius: 999px;
+    background: var(--bx-analytics-accent-fill);
+    font-size: 0.875rem;
+}
+.bx-analytics-chip-name {
+    color: var(--bx-analytics-muted);
+}
+.bx-analytics-chip-value {
+    max-width: 16rem;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+}
+.bx-analytics-chip-x {
+    margin-left: 0.25rem;
+    font-size: 1.125rem;
+    line-height: 1;
+}
+
+/* online now */
+.bx-analytics-online {
+    display: inline-flex;
+    align-items: center;
+    gap: 0.5rem;
+    font-variant-numeric: tabular-nums;
+    font-weight: 600;
+}
+.bx-analytics-online-dot {
+    width: 0.625rem;
+    height: 0.625rem;
+    border-radius: 50%;
+    background: var(--bx-analytics-online);
+    box-shadow: 0 0 0 0 var(--bx-analytics-online);
+    animation: bx-analytics-pulse 2s ease-out infinite;
+}
+@keyframes bx-analytics-pulse {
+    0% { box-shadow: 0 0 0 0 rgba(22, 163, 74, 0.45); }
+    70% { box-shadow: 0 0 0 0.5rem rgba(22, 163, 74, 0); }
+    100% { box-shadow: 0 0 0 0 rgba(22, 163, 74, 0); }
+}
+@media (prefers-reduced-motion: reduce) {
+    .bx-analytics-online-dot {
+        animation: none;
+    }
+    .bx-analytics {
+        transition: none;
+    }
+}
+
+/* key figures */
+.bx-analytics-kpis {
+    display: grid;
+    grid-template-columns: repeat(2, 1fr);
+    border: 1px solid var(--bx-analytics-border);
+    border-radius: 0.75rem;
+    overflow: hidden;
+}
+.bx-analytics .bx-analytics-kpi {
+    display: flex;
+    flex-direction: column;
+    gap: 0.25rem;
+    padding: 0.75rem 1rem;
+    box-shadow: inset -1px 0 0 var(--bx-analytics-border), inset 0 -1px 0 var(--bx-analytics-border);
+}
+.bx-analytics .bx-analytics-kpi:hover {
+    background: var(--bx-analytics-hover);
+}
+.bx-analytics .bx-analytics-kpi[aria-pressed="true"] {
+    background: var(--bx-analytics-accent-fill);
+    box-shadow: inset -1px 0 0 var(--bx-analytics-border), inset 0 -3px 0 var(--bx-analytics-accent);
+}
+.bx-analytics-kpi-title {
+    font-size: 0.8125rem;
+    color: var(--bx-analytics-muted);
+}
+.bx-analytics-kpi-figures {
+    display: flex;
+    flex-wrap: wrap;
+    align-items: baseline;
+    justify-content: space-between;
+    gap: 0.25rem 0.5rem;
+}
+.bx-analytics-kpi-value {
+    font-size: 1.5rem;
+    font-weight: 600;
+    line-height: 1.2;
+    font-variant-numeric: tabular-nums;
+}
+.bx-analytics-kpi-change {
+    font-size: 0.8125rem;
+    font-variant-numeric: tabular-nums;
+    color: var(--bx-analytics-muted);
+}
+.bx-analytics-trend-good {
+    color: var(--bx-analytics-good);
+}
+.bx-analytics-trend-bad {
+    color: var(--bx-analytics-bad);
+}
+
+@media (min-width: 40rem) {
+    .bx-analytics-kpis {
+        grid-template-columns: repeat(3, 1fr);
+    }
+}
+@media (min-width: 72rem) {
+    .bx-analytics-kpis {
+        grid-template-columns: repeat(6, 1fr);
+    }
+}
+
+/* chart */
+.bx-analytics-chart {
+    position: relative;
+    height: 18rem;
+    margin-top: 1rem;
+}
+
+/*
+ * Tabbed top lists. On a touch screen the tabs stay in one row, and a row wider than its card scrolls sideways, like the site's main
+ * menu bar, with the scrollbar hidden; where scroll-driven animations work, the clipped end fades out to show there is more. The
+ * fade stops at the ends, and the script scrolls the selected or focused tab clear of it (scroll-padding sets by how much). With a
+ * mouse, which can't swipe a row, the tabs wrap onto a second line instead.
+ */
+@property --bx-analytics-tabs-fade-start {
+    syntax: "<length>";
+    inherits: false;
+    initial-value: 0px;
+}
+@property --bx-analytics-tabs-fade-end {
+    syntax: "<length>";
+    inherits: false;
+    initial-value: 0px;
+}
+.bx-analytics-tabs {
+    display: flex;
+    flex-wrap: nowrap;
+    min-width: 0;
+    margin-bottom: 0.75rem;
+    overflow-x: auto;
+    overflow-y: hidden;
+    overscroll-behavior-x: contain;
+    scroll-padding-inline: 2rem;
+    scrollbar-width: none;
+    -webkit-overflow-scrolling: touch;
+    box-shadow: inset 0 -1px 0 var(--bx-analytics-border);
+}
+.bx-analytics-tabs::-webkit-scrollbar {
+    display: none;
+}
+@supports (animation-timeline: scroll()) {
+    .bx-analytics-tabs {
+        -webkit-mask-image: linear-gradient(to right, transparent, #000 var(--bx-analytics-tabs-fade-start), #000 calc(100% - var(--bx-analytics-tabs-fade-end)), transparent);
+        mask-image: linear-gradient(to right, transparent, #000 var(--bx-analytics-tabs-fade-start), #000 calc(100% - var(--bx-analytics-tabs-fade-end)), transparent);
+        animation: bx-analytics-tabs-fade linear both;
+        animation-timeline: scroll(self inline);
+    }
+}
+@keyframes bx-analytics-tabs-fade {
+    from {
+        --bx-analytics-tabs-fade-start: 0px;
+        --bx-analytics-tabs-fade-end: 2rem;
+    }
+    10%, 90% {
+        --bx-analytics-tabs-fade-start: 2rem;
+        --bx-analytics-tabs-fade-end: 2rem;
+    }
+    to {
+        --bx-analytics-tabs-fade-start: 2rem;
+        --bx-analytics-tabs-fade-end: 0px;
+    }
+}
+@media (hover: hover) and (pointer: fine) {
+    .bx-analytics-tabs {
+        flex-wrap: wrap;
+        column-gap: 0.25rem;
+        overflow: visible;
+        -webkit-mask-image: none;
+        mask-image: none;
+        animation: none;
+    }
+}
+.bx-analytics .bx-analytics-tab {
+    flex: none;
+    min-height: 2.75rem;
+    padding: 0 0.5rem;
+    font-size: 0.9375rem;
+    color: var(--bx-analytics-muted);
+    white-space: nowrap;
+}
+.bx-analytics .bx-analytics-tabs .bx-analytics-tab:focus-visible {
+    /* inside the tab, so the scrolling row does not clip it; outranks the site's body:not(.bx-std) [tabindex="0"]:focus-visible */
+    outline-offset: -2px;
+}
+.bx-analytics .bx-analytics-tab:hover {
+    color: inherit;
+}
+.bx-analytics .bx-analytics-tab[aria-selected="true"] {
+    color: inherit;
+    font-weight: 600;
+    box-shadow: inset 0 -2px 0 var(--bx-analytics-accent);
+}
+.bx-analytics-list-head {
+    display: flex;
+    justify-content: space-between;
+    padding: 0 0.625rem 0.375rem;
+    font-size: 0.8125rem;
+    color: var(--bx-analytics-muted);
+}
+.bx-analytics-rows {
+    list-style: none;
+    margin: 0;
+    padding: 0;
+    display: flex;
+    flex-direction: column;
+    gap: 0.25rem;
+}
+.bx-analytics-row {
+    position: relative;
+}
+.bx-analytics .bx-analytics-row-btn,
+.bx-analytics-std-platform {
+    position: relative;
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    gap: 0.75rem;
+    width: 100%;
+    min-height: 2.25rem;
+    padding: 0 0.625rem;
+    border-radius: 0.5rem;
+    isolation: isolate;
+}
+.bx-analytics .bx-analytics-row-btn:hover {
+    background: var(--bx-analytics-hover);
+}
+.bx-analytics-row-bar {
+    position: absolute;
+    top: 0;
+    bottom: 0;
+    left: 0;
+    z-index: -1;
+    border-radius: 0.5rem;
+    background: var(--bx-analytics-bar);
+}
+.bx-analytics-row-label {
+    min-width: 0;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+    font-size: 0.9375rem;
+}
+.bx-analytics-row-value {
+    flex: none;
+    font-variant-numeric: tabular-nums;
+    font-weight: 600;
+    font-size: 0.9375rem;
+}
+.bx-analytics-row-share {
+    display: inline-block;
+    min-width: 3.25rem;
+    text-align: right;
+    font-weight: 400;
+    font-size: 0.8125rem;
+    color: var(--bx-analytics-muted);
+}
+.bx-analytics-empty {
+    padding: 1.5rem 0.625rem;
+    text-align: center;
+    color: var(--bx-analytics-muted);
+}
+
+/* Studio dashboard: traffic */
+.bx-analytics-std {
+    display: flex;
+    flex-direction: column;
+    gap: 0.75rem;
+    height: 100%;
+}
+.bx-analytics-std-online-title {
+    font-weight: 400;
+    color: var(--bx-analytics-muted);
+    font-size: 0.875rem;
+}
+.bx-analytics-std-kpis {
+    display: grid;
+    grid-template-columns: 1fr 1fr;
+    gap: 0.75rem;
+}
+.bx-analytics-std-kpi {
+    display: flex;
+    flex-direction: column;
+    gap: 0.25rem;
+}
+.bx-analytics-std-spark {
+    width: 100%;
+    height: 3.5rem;
+}
+.bx-analytics-std-spark polyline {
+    fill: none;
+    stroke: var(--bx-analytics-accent);
+    stroke-width: 2;
+    stroke-linejoin: round;
+    stroke-linecap: round;
+}
+.bx-analytics-std-platforms {
+    list-style: none;
+    margin: 0;
+    padding: 0;
+    display: flex;
+    flex-direction: column;
+    gap: 0.25rem;
+}
+.bx-analytics-std-link {
+    margin-top: auto;
+    font-size: 0.875rem;
+}
+
+/* Studio: a section's blocks; with the Studio block's own 8px, content sits 16px from its edge */
+.bx-analytics-studio {
+    padding: 0.5rem;
+    display: grid;
+    /* one column when the section is narrower than a card (phones) */
+    grid-template-columns: repeat(auto-fit, minmax(min(22rem, 100%), 1fr));
+    gap: 1.5rem 2rem;
+}
+/* wide tables and code scroll inside their card instead of widening it past the padding */
+.bx-analytics-studio-card {
+    min-width: 0;
+}
+.bx-analytics-studio-wide {
+    grid-column: 1 / -1;
+}
+.bx-analytics-studio-title {
+    margin: 0 0 0.5rem;
+    font-size: 1rem;
+    font-weight: 600;
+}
+.bx-analytics-std-kpi .bx-analytics-kpi-figures {
+    justify-content: flex-start;
+}
+
+/* tables */
+.bx-analytics-table-wrap {
+    overflow-x: auto;
+    border-radius: 0.5rem;
+}
+.bx-analytics-table-wrap:focus-visible {
+    outline: 2px solid var(--bx-analytics-accent);
+    outline-offset: 2px;
+}
+.bx-analytics-table {
+    width: 100%;
+    border-collapse: separate;
+    border-spacing: 0 0.25rem;
+    font-variant-numeric: tabular-nums;
+}
+.bx-analytics-table th {
+    padding: 0 0.625rem 0.25rem;
+    font-size: 0.8125rem;
+    font-weight: 500;
+    color: var(--bx-analytics-muted);
+    text-align: right;
+    white-space: nowrap;
+}
+.bx-analytics-table td {
+    padding: 0 0.625rem;
+    height: 2.25rem;
+    font-size: 0.9375rem;
+    text-align: right;
+    white-space: nowrap;
+}
+.bx-analytics-table th.bx-analytics-col-label,
+.bx-analytics-table td.bx-analytics-col-label,
+.bx-analytics-table th.bx-analytics-col-text,
+.bx-analytics-table td.bx-analytics-col-text {
+    text-align: left;
+}
+.bx-analytics-table td.bx-analytics-col-label {
+    width: 100%;
+    padding: 0;
+}
+.bx-analytics-table tbody tr:hover td {
+    background: var(--bx-analytics-hover);
+}
+.bx-analytics-table tbody td:first-child {
+    border-radius: 0.5rem 0 0 0.5rem;
+}
+.bx-analytics-table tbody td:last-child {
+    border-radius: 0 0.5rem 0.5rem 0;
+}
+.bx-analytics-cell-label {
+    position: relative;
+    display: flex;
+    align-items: center;
+    gap: 0.5rem;
+    min-height: 2.25rem;
+    padding: 0 0.625rem;
+    isolation: isolate;
+}
+.bx-analytics .bx-analytics-cell-btn,
+.bx-analytics-cell-link,
+.bx-analytics-cell-text {
+    min-width: 0;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+}
+.bx-analytics .bx-analytics-cell-btn:hover,
+.bx-analytics-cell-link:hover {
+    text-decoration: underline;
+}
+.bx-analytics-cell-sub {
+    flex: none;
+    font-size: 0.8125rem;
+    color: var(--bx-analytics-muted);
+}
+.bx-analytics-pager {
+    display: flex;
+    align-items: center;
+    justify-content: flex-end;
+    gap: 0.75rem;
+    margin-top: 0.75rem;
+    font-size: 0.875rem;
+}
+.bx-analytics .bx-analytics-pager-btn {
+    min-height: 2.25rem;
+    padding: 0 0.875rem;
+    border-radius: 0.5rem;
+    box-shadow: inset 0 0 0 1px var(--bx-analytics-border);
+}
+.bx-analytics .bx-analytics-pager-btn:hover:not([disabled]) {
+    background: var(--bx-analytics-hover);
+}
+.bx-analytics .bx-analytics-pager-btn[disabled] {
+    opacity: 0.45;
+    cursor: default;
+}
+.bx-analytics-pager-info {
+    color: var(--bx-analytics-muted);
+}
+
+/* realtime */
+.bx-analytics-realtime-head {
+    display: grid;
+    grid-template-columns: minmax(12rem, auto) 1fr;
+    gap: 1rem 2rem;
+    align-items: start;
+}
+@media (max-width: 40rem) {
+    .bx-analytics-realtime-head {
+        grid-template-columns: 1fr;
+    }
+}
+.bx-analytics-realtime-online {
+    display: flex;
+    align-items: center;
+    flex-wrap: wrap;
+    gap: 0.5rem 0.75rem;
+}
+.bx-analytics-realtime-count {
+    font-size: 2.5rem;
+    font-weight: 600;
+    line-height: 1;
+    font-variant-numeric: tabular-nums;
+}
+.bx-analytics-realtime-label {
+    flex-basis: 100%;
+    font-size: 0.875rem;
+    color: var(--bx-analytics-muted);
+}
+.bx-analytics-realtime-platforms {
+    min-width: 0;
+}
+.bx-analytics-minutes {
+    display: flex;
+    align-items: flex-end;
+    gap: 2px;
+    height: 4.5rem;
+    margin-top: 1.25rem;
+    box-shadow: inset 0 -1px 0 var(--bx-analytics-border);
+}
+.bx-analytics-minute {
+    flex: 1 1 0;
+    min-height: 0;
+    border-radius: 2px 2px 0 0;
+    background: var(--bx-analytics-accent);
+    opacity: 0.85;
+}
+.bx-analytics-minutes-axis {
+    display: flex;
+    justify-content: space-between;
+    margin-top: 0.25rem;
+    font-size: 0.75rem;
+    color: var(--bx-analytics-muted);
+}
+.bx-analytics-subtitle {
+    margin: 1.25rem 0 0.5rem;
+    font-size: 0.9375rem;
+    font-weight: 600;
+}
+.bx-analytics-feed {
+    list-style: none;
+    margin: 0;
+    padding: 0;
+}
+.bx-analytics-feed-item {
+    display: grid;
+    grid-template-columns: 8.5rem minmax(0, 1fr) auto;
+    gap: 0.25rem 1rem;
+    align-items: baseline;
+    padding: 0.5rem 0;
+    box-shadow: inset 0 -1px 0 var(--bx-analytics-border);
+    font-size: 0.875rem;
+}
+@media (max-width: 40rem) {
+    .bx-analytics-feed-item {
+        grid-template-columns: 1fr;
+    }
+}
+.bx-analytics-feed-time,
+.bx-analytics-feed-meta {
+    color: var(--bx-analytics-muted);
+    white-space: nowrap;
+}
+.bx-analytics-feed-page {
+    display: flex;
+    flex-direction: column;
+    min-width: 0;
+}
+.bx-analytics-feed-title,
+.bx-analytics-feed-path {
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+}
+.bx-analytics-feed-path {
+    font-size: 0.8125rem;
+    color: var(--bx-analytics-muted);
+}
+
+/* map */
+.bx-analytics-map-layout {
+    display: grid;
+    grid-template-columns: minmax(0, 2fr) minmax(16rem, 1fr);
+    gap: 1.5rem;
+}
+@media (max-width: 64rem) {
+    .bx-analytics-map-layout {
+        grid-template-columns: minmax(0, 1fr);
+    }
+}
+.bx-analytics-map {
+    height: 26rem;
+    border-radius: 0.75rem;
+    overflow: hidden;
+    z-index: 0;
+    background: var(--bx-analytics-hover);
+}
+html.dark .bx-analytics-map .leaflet-tile-pane {
+    filter: invert(1) hue-rotate(180deg) brightness(0.85) contrast(0.9);
+}
+.bx-analytics-note {
+    margin: 0.75rem 0 0;
+    font-size: 0.8125rem;
+    color: var(--bx-analytics-muted);
+}
+
+/* tiles: key figures that aren't chart switches */
+.bx-analytics-tiles {
+    display: grid;
+    grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr));
+    border: 1px solid var(--bx-analytics-border);
+    border-radius: 0.75rem;
+    overflow: hidden;
+}
+.bx-analytics-tile {
+    display: flex;
+    flex-direction: column;
+    gap: 0.25rem;
+    padding: 0.75rem 1rem;
+    box-shadow: inset -1px 0 0 var(--bx-analytics-border), inset 0 -1px 0 var(--bx-analytics-border);
+}
+.bx-analytics-tile-sub {
+    font-size: 0.8125rem;
+    color: var(--bx-analytics-muted);
+}
+.bx-analytics-chart-sm {
+    height: 13rem;
+}
+.bx-analytics-columns {
+    display: grid;
+    grid-template-columns: minmax(0, 3fr) minmax(0, 2fr);
+    gap: 1.5rem 2rem;
+    margin-top: 1.25rem;
+}
+@media (max-width: 64rem) {
+    .bx-analytics-columns {
+        grid-template-columns: minmax(0, 1fr);
+    }
+}
+.bx-analytics-columns > div > .bx-analytics-subtitle:first-child {
+    margin-top: 0;
+}
+.bx-analytics-table .bx-analytics-cell-label {
+    min-width: 14rem;
+    max-width: 36rem;
+}
+
+/* detail views: an event, a visit, a member */
+.bx-analytics .bx-analytics-back {
+    display: inline-flex;
+    align-items: center;
+    min-height: 2.25rem;
+    margin-bottom: 0.75rem;
+    padding: 0 0.75rem 0 0.5rem;
+    border-radius: 0.5rem;
+    font-size: 0.875rem;
+    color: var(--bx-analytics-muted);
+}
+.bx-analytics .bx-analytics-back::before {
+    content: '\2190';
+    margin-right: 0.375rem;
+}
+.bx-analytics .bx-analytics-back:hover {
+    background: var(--bx-analytics-hover);
+    color: inherit;
+}
+.bx-analytics-detail-title {
+    margin: 0 0 0.75rem;
+    font-size: 1.125rem;
+    font-weight: 600;
+}
+.bx-analytics-member {
+    display: flex;
+    align-items: center;
+    gap: 0.75rem;
+    margin-bottom: 1rem;
+}
+.bx-analytics-member-thumb {
+    flex: none;
+    width: 2.5rem;
+    height: 2.5rem;
+    border-radius: 50%;
+    object-fit: cover;
+}
+.bx-analytics-member-guest {
+    display: inline-flex;
+    align-items: center;
+    justify-content: center;
+    background: var(--bx-analytics-hover);
+    color: var(--bx-analytics-muted);
+    font-weight: 600;
+}
+.bx-analytics-member-name {
+    font-size: 1.125rem;
+    font-weight: 600;
+}
+.bx-analytics-facts {
+    display: grid;
+    grid-template-columns: repeat(auto-fill, minmax(12rem, 1fr));
+    gap: 0.75rem 1.5rem;
+    margin: 0 0 0.5rem;
+}
+.bx-analytics-fact dt {
+    font-size: 0.8125rem;
+    color: var(--bx-analytics-muted);
+}
+.bx-analytics-fact dd {
+    margin: 0.125rem 0 0;
+    overflow-wrap: anywhere;
+}
+.bx-analytics-timeline {
+    list-style: none;
+    margin: 0;
+    padding: 0;
+}
+.bx-analytics-timeline-item {
+    position: relative;
+    display: grid;
+    grid-template-columns: 3.5rem 1rem minmax(0, 1fr) auto;
+    gap: 0.5rem;
+    align-items: start;
+    padding: 0.375rem 0;
+    font-size: 0.875rem;
+}
+.bx-analytics-timeline-item::before {
+    content: '';
+    position: absolute;
+    left: calc(4.5rem - 1px);
+    top: 0;
+    bottom: 0;
+    width: 2px;
+    background: var(--bx-analytics-border);
+}
+.bx-analytics-timeline-time {
+    font-variant-numeric: tabular-nums;
+    color: var(--bx-analytics-muted);
+    text-align: right;
+}
+.bx-analytics-timeline-dot {
+    position: relative;
+    z-index: 1;
+    width: 0.625rem;
+    height: 0.625rem;
+    margin: 0.3rem auto 0;
+    border-radius: 50%;
+    background: var(--bx-analytics-c1);
+}
+.bx-analytics-timeline-event .bx-analytics-timeline-dot {
+    background: var(--bx-analytics-c3);
+}
+.bx-analytics-timeline-una .bx-analytics-timeline-dot {
+    background: var(--bx-analytics-c5);
+}
+.bx-analytics-timeline-body {
+    display: flex;
+    flex-direction: column;
+    min-width: 0;
+}
+.bx-analytics-timeline-kind {
+    font-size: 0.75rem;
+    color: var(--bx-analytics-muted);
+}
+.bx-analytics-timeline-label,
+.bx-analytics-timeline-path {
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+}
+.bx-analytics-timeline-path {
+    font-size: 0.8125rem;
+    color: var(--bx-analytics-muted);
+}
+.bx-analytics-timeline-engaged {
+    font-size: 0.8125rem;
+    color: var(--bx-analytics-muted);
+    white-space: nowrap;
+}
+
+/* journeys */
+.bx-analytics-journeys {
+    display: grid;
+    grid-template-columns: minmax(14rem, 1fr) minmax(0, 3fr);
+    gap: 1.5rem 2rem;
+    margin-bottom: 1rem;
+}
+@media (max-width: 64rem) {
+    .bx-analytics-journeys {
+        grid-template-columns: minmax(0, 1fr);
+    }
+}
+.bx-analytics-journey-start .bx-analytics-subtitle {
+    margin-top: 0;
+}
+.bx-analytics .bx-analytics-row-btn-active {
+    background: var(--bx-analytics-accent-fill);
+    font-weight: 600;
+}
+.bx-analytics-flow {
+    display: grid;
+    grid-template-columns: repeat(3, minmax(0, 1fr));
+    gap: 1rem;
+    align-items: start;
+}
+@media (max-width: 40rem) {
+    .bx-analytics-flow {
+        grid-template-columns: minmax(0, 1fr);
+    }
+}
+.bx-analytics-flow-step {
+    display: flex;
+    flex-direction: column;
+    gap: 0.375rem;
+    min-width: 0;
+}
+.bx-analytics-flow-head {
+    font-size: 0.8125rem;
+    color: var(--bx-analytics-muted);
+}
+.bx-analytics .bx-analytics-flow-node,
+.bx-analytics-flow-node {
+    position: relative;
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    gap: 0.5rem;
+    min-height: 2.5rem;
+    padding: 0 0.625rem;
+    border-radius: 0.5rem;
+    box-shadow: inset 0 0 0 1px var(--bx-analytics-border);
+    font-size: 0.875rem;
+    isolation: isolate;
+    overflow: hidden;
+}
+.bx-analytics .bx-analytics-flow-node:not([disabled]):hover {
+    background: var(--bx-analytics-hover);
+}
+.bx-analytics .bx-analytics-flow-node[disabled] {
+    cursor: default;
+}
+.bx-analytics-flow-node-main {
+    background: var(--bx-analytics-accent-fill);
+    box-shadow: inset 0 0 0 1px var(--bx-analytics-accent);
+    font-weight: 600;
+}
+.bx-analytics .bx-analytics-flow-node-active {
+    box-shadow: inset 0 0 0 2px var(--bx-analytics-accent);
+    font-weight: 600;
+}
+.bx-analytics-flow-exit {
+    color: var(--bx-analytics-muted);
+    font-style: italic;
+}
+.bx-analytics-flow-page {
+    min-width: 0;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+}
+.bx-analytics-flow-count {
+    flex: none;
+    font-variant-numeric: tabular-nums;
+    color: var(--bx-analytics-muted);
+}
+
+/* retention cohorts: each cell tinted by its share */
+.bx-analytics-cohorts th[scope="row"] {
+    font-weight: 500;
+    white-space: nowrap;
+}
+.bx-analytics-cohorts td.bx-analytics-cohort-cell {
+    min-width: 3.5rem;
+    text-align: center;
+    background: color-mix(in srgb, var(--bx-analytics-accent) calc(var(--bx-analytics-heat, 0) * 85%), transparent);
+}
+.bx-analytics-cohorts tbody tr:hover td.bx-analytics-cohort-cell {
+    background: color-mix(in srgb, var(--bx-analytics-accent) calc(var(--bx-analytics-heat, 0) * 85%), transparent);
+}
+.bx-analytics-cohorts td.bx-analytics-cohort-strong {
+    color: #fff;
+}
+html.dark .bx-analytics-cohorts td.bx-analytics-cohort-strong {
+    color: #111827;
+}
+
+/* goals: one card per goal with its conversion rate */
+.bx-analytics-cards {
+    display: grid;
+    grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr));
+    gap: 0.75rem;
+    list-style: none;
+    margin: 0 0 1rem;
+    padding: 0;
+}
+.bx-analytics-card {
+    display: flex;
+    flex-direction: column;
+    gap: 0.375rem;
+    min-width: 0;
+    padding: 1rem;
+    border: 1px solid var(--bx-analytics-border);
+    border-radius: 0.75rem;
+}
+.bx-analytics-card-head {
+    display: flex;
+    align-items: flex-start;
+    justify-content: space-between;
+    gap: 0.5rem;
+}
+.bx-analytics-card-title {
+    min-width: 0;
+    font-weight: 600;
+    overflow-wrap: anywhere;
+}
+.bx-analytics-card-sub {
+    font-size: 0.8125rem;
+    color: var(--bx-analytics-muted);
+    overflow-wrap: anywhere;
+}
+.bx-analytics-card-figures {
+    display: flex;
+    align-items: baseline;
+    gap: 0.5rem;
+    margin-top: 0.25rem;
+}
+.bx-analytics-card-actions {
+    display: flex;
+    flex-wrap: wrap;
+    align-items: center;
+    gap: 0.25rem 0.75rem;
+    margin-top: auto;
+    padding-top: 0.25rem;
+}
+.bx-analytics-badge {
+    flex: none;
+    padding: 0.125rem 0.5rem;
+    border-radius: 999px;
+    background: var(--bx-analytics-hover);
+    font-size: 0.75rem;
+    color: var(--bx-analytics-muted);
+    white-space: nowrap;
+}
+.bx-analytics-meter {
+    height: 0.375rem;
+    border-radius: 999px;
+    background: var(--bx-analytics-bar);
+    overflow: hidden;
+}
+.bx-analytics-meter > span {
+    display: block;
+    height: 100%;
+    border-radius: inherit;
+    background: var(--bx-analytics-accent);
+}
+.bx-analytics .bx-analytics-link-btn {
+    min-height: 2.25rem;
+    padding: 0 0.25rem;
+    color: var(--bx-analytics-accent);
+    font-size: 0.875rem;
+    text-decoration: underline;
+    text-underline-offset: 2px;
+}
+.bx-analytics .bx-analytics-danger {
+    color: var(--bx-analytics-bad);
+}
+.bx-analytics .bx-analytics-add-btn {
+    display: inline-flex;
+    align-items: center;
+    min-height: 2.25rem;
+    padding: 0 0.875rem;
+    border: 1px dashed var(--bx-analytics-accent);
+    border-radius: 0.5rem;
+    color: var(--bx-analytics-accent);
+    font-size: 0.875rem;
+    font-weight: 500;
+}
+.bx-analytics .bx-analytics-add-btn:hover,
+.bx-analytics .bx-analytics-link-btn:hover {
+    background: var(--bx-analytics-hover);
+}
+
+/* funnels: steps as bars of the first step's visits, with the drop between them */
+.bx-analytics-funnel-bar {
+    display: flex;
+    flex-wrap: wrap;
+    align-items: center;
+    justify-content: space-between;
+    gap: 0.75rem;
+    margin-bottom: 1rem;
+}
+.bx-analytics .bx-analytics-chip-active {
+    box-shadow: inset 0 0 0 2px var(--bx-analytics-accent);
+    font-weight: 600;
+}
+.bx-analytics-funnel {
+    display: flex;
+    flex-direction: column;
+    gap: 0.25rem;
+    list-style: none;
+    margin: 0;
+    padding: 0;
+}
+.bx-analytics-funnel-step {
+    display: flex;
+    flex-direction: column;
+    gap: 0.375rem;
+}
+.bx-analytics-funnel-label {
+    display: flex;
+    align-items: center;
+    gap: 0.5rem;
+    min-width: 0;
+}
+.bx-analytics-funnel-number {
+    display: inline-flex;
+    flex: none;
+    align-items: center;
+    justify-content: center;
+    width: 1.5rem;
+    height: 1.5rem;
+    border-radius: 999px;
+    background: var(--bx-analytics-accent-fill);
+    color: var(--bx-analytics-accent);
+    font-size: 0.75rem;
+    font-weight: 600;
+}
+.bx-analytics-funnel-pattern {
+    min-width: 0;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+    font-weight: 500;
+}
+.bx-analytics-funnel-bar-track {
+    position: relative;
+    display: flex;
+    align-items: center;
+    min-height: 2.25rem;
+    border-radius: 0.5rem;
+    background: var(--bx-analytics-hover);
+    overflow: hidden;
+}
+.bx-analytics-funnel-fill {
+    position: absolute;
+    inset: 0 auto 0 0;
+    background: color-mix(in srgb, var(--bx-analytics-accent) 32%, transparent);
+}
+.bx-analytics-funnel-figures {
+    position: relative;
+    padding: 0 0.75rem;
+    font-size: 0.875rem;
+    font-variant-numeric: tabular-nums;
+}
+.bx-analytics-funnel-drop {
+    align-self: flex-start;
+    margin: 0 0 0.5rem 2rem;
+    font-size: 0.8125rem;
+    color: var(--bx-analytics-bad);
+}
+
+/* goal and funnel forms */
+.bx-analytics-form {
+    display: flex;
+    flex-wrap: wrap;
+    align-items: flex-start;
+    gap: 0.75rem 1rem;
+    margin-top: 0.5rem;
+    padding: 1rem;
+    border: 1px solid var(--bx-analytics-border);
+    border-radius: 0.75rem;
+}
+.bx-analytics-form .bx-analytics-subtitle {
+    flex: 1 0 100%;
+    margin: 0;
+}
+.bx-analytics-field {
+    display: flex;
+    flex-direction: column;
+    gap: 0.25rem;
+    min-width: 0;
+}
+.bx-analytics-field-wide {
+    flex: 1 0 100%;
+}
+.bx-analytics-field-grow {
+    flex: 1 1 12rem;
+}
+.bx-analytics-field-row {
+    display: flex;
+    flex: 1 0 100%;
+    align-items: center;
+    gap: 0.5rem;
+}
+.bx-analytics-field-row .bx-analytics-funnel-number {
+    margin-right: 0.25rem;
+}
+.bx-analytics-hint {
+    flex: 1 0 100%;
+    font-size: 0.8125rem;
+    color: var(--bx-analytics-muted);
+}
+.bx-analytics-form-error {
+    flex: 1 0 100%;
+    margin: 0;
+    font-size: 0.875rem;
+    color: var(--bx-analytics-bad);
+}
+.bx-analytics-form-actions {
+    display: flex;
+    flex: 1 0 100%;
+    gap: 0.5rem;
+}
+
+/* revenue: splits, notes under a report, the import of past payments */
+.bx-analytics-revenue-split {
+    margin-top: 1.25rem;
+}
+.bx-analytics-notes {
+    margin-top: 1rem;
+}
+.bx-analytics-notes .bx-analytics-note {
+    margin: 0.25rem 0 0;
+}
+.bx-analytics-import {
+    display: flex;
+    flex-wrap: wrap;
+    align-items: center;
+    justify-content: space-between;
+    gap: 0.75rem 1.5rem;
+    margin-top: 1.25rem;
+    padding: 1rem;
+    border: 1px solid var(--bx-analytics-border);
+    border-radius: 0.75rem;
+}
+.bx-analytics-import-text {
+    flex: 1 1 20rem;
+    min-width: 0;
+}
+.bx-analytics-import .bx-analytics-subtitle {
+    margin-top: 0;
+}
+.bx-analytics-import-status {
+    margin: 0 0 0.25rem;
+}
+.bx-analytics .bx-analytics-import-btn {
+    display: inline-flex;
+    align-items: center;
+    min-height: 2.75rem;
+    padding: 0 1rem;
+    border: 1px solid var(--bx-analytics-accent);
+    border-radius: 0.5rem;
+    color: var(--bx-analytics-accent);
+    font-size: 0.875rem;
+    font-weight: 500;
+}
+.bx-analytics .bx-analytics-import-btn:hover {
+    background: var(--bx-analytics-hover);
+}
+.bx-analytics .bx-analytics-import-btn:disabled {
+    opacity: 0.6;
+    cursor: progress;
+}
+
+/* health: errors with a trend line per row, an error's stack, vitals cards */
+.bx-analytics-spark {
+    display: block;
+    width: 6rem;
+    height: 1.5rem;
+    color: var(--bx-analytics-bad);
+}
+.bx-analytics-col-spark {
+    width: 6.5rem;
+}
+.bx-analytics-error-title {
+    overflow-wrap: anywhere;
+}
+.bx-analytics-error-actions {
+    display: flex;
+    flex-wrap: wrap;
+    gap: 0.5rem;
+    margin: 0 0 1rem;
+}
+.bx-analytics .bx-analytics-action-btn {
+    display: inline-flex;
+    align-items: center;
+    min-height: 2.75rem;
+    padding: 0 1rem;
+    border: 1px solid var(--bx-analytics-accent);
+    border-radius: 0.5rem;
+    color: var(--bx-analytics-accent);
+    font-size: 0.875rem;
+    font-weight: 500;
+}
+.bx-analytics .bx-analytics-action-btn:hover {
+    background: var(--bx-analytics-hover);
+}
+.bx-analytics .bx-analytics-action-btn:disabled {
+    opacity: 0.6;
+    cursor: progress;
+}
+.bx-analytics-stack {
+    max-height: 24rem;
+    margin: 0 0 1rem;
+    padding: 0.75rem 1rem;
+    overflow: auto;
+    border: 1px solid var(--bx-analytics-border);
+    border-radius: 0.5rem;
+    background: var(--bx-analytics-hover);
+    font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+    font-size: 0.8125rem;
+    line-height: 1.5;
+    white-space: pre;
+}
+.bx-analytics-stack:focus-visible {
+    outline: 2px solid var(--bx-analytics-accent);
+    outline-offset: 2px;
+}
+.bx-analytics-error-breakdowns {
+    display: grid;
+    grid-template-columns: repeat(auto-fill, minmax(18rem, 1fr));
+    gap: 0 2rem;
+}
+.bx-analytics-error-breakdowns .bx-analytics-table .bx-analytics-cell-label {
+    min-width: 8rem;
+}
+.bx-analytics-rating {
+    font-weight: 500;
+    white-space: nowrap;
+}
+.bx-analytics-rating-good {
+    color: var(--bx-analytics-good);
+}
+.bx-analytics-rating-needs-improvement {
+    color: var(--bx-analytics-warn);
+}
+.bx-analytics-rating-poor {
+    color: var(--bx-analytics-bad);
+}
+.bx-analytics-performance .bx-analytics-controls {
+    margin-bottom: 1rem;
+}
+.bx-analytics-vitals {
+    display: grid;
+    grid-template-columns: repeat(auto-fill, minmax(12rem, 1fr));
+    gap: 0.75rem;
+    list-style: none;
+    margin: 0 0 1.25rem;
+    padding: 0;
+}
+.bx-analytics .bx-analytics-vital {
+    display: flex;
+    flex-direction: column;
+    gap: 0.25rem;
+    width: 100%;
+    height: 100%;
+    padding: 0.875rem 1rem;
+    border: 1px solid var(--bx-analytics-border);
+    border-radius: 0.75rem;
+}
+.bx-analytics .bx-analytics-vital:hover {
+    background: var(--bx-analytics-hover);
+}
+.bx-analytics .bx-analytics-vital[aria-pressed="true"] {
+    border-color: var(--bx-analytics-accent);
+    box-shadow: inset 0 0 0 1px var(--bx-analytics-accent);
+}
+.bx-analytics-vital-title {
+    font-size: 0.8125rem;
+    color: var(--bx-analytics-muted);
+}
+.bx-analytics-vital-value {
+    font-size: 1.5rem;
+    font-weight: 600;
+    font-variant-numeric: tabular-nums;
+}
+.bx-analytics-vital-is-none .bx-analytics-vital-value {
+    font-size: 1rem;
+    font-weight: 500;
+    color: var(--bx-analytics-muted);
+}
+.bx-analytics-vital-rating {
+    font-size: 0.8125rem;
+    font-weight: 500;
+}
+.bx-analytics-vital-is-good .bx-analytics-vital-rating {
+    color: var(--bx-analytics-good);
+}
+.bx-analytics-vital-is-needs-improvement .bx-analytics-vital-rating {
+    color: var(--bx-analytics-warn);
+}
+.bx-analytics-vital-is-poor .bx-analytics-vital-rating {
+    color: var(--bx-analytics-bad);
+}
+.bx-analytics-vital-dist {
+    display: flex;
+    height: 0.375rem;
+    margin: 0.25rem 0;
+    overflow: hidden;
+    border-radius: 999px;
+    background: var(--bx-analytics-border);
+}
+.bx-analytics-vital-dist > span {
+    display: block;
+    height: 100%;
+}
+.bx-analytics-vital-dist .bx-analytics-vital-good {
+    background: var(--bx-analytics-good);
+}
+.bx-analytics-vital-dist .bx-analytics-vital-ni {
+    background: var(--bx-analytics-warn);
+}
+.bx-analytics-vital-dist .bx-analytics-vital-poor {
+    background: var(--bx-analytics-bad);
+}
+.bx-analytics-vital-sub {
+    font-size: 0.75rem;
+    color: var(--bx-analytics-muted);
+}
+
+/* query: what to break down and sort by, and the CSV download */
+.bx-analytics-query .bx-analytics-download {
+    display: inline-flex;
+    align-items: center;
+    gap: 0.375rem;
+    min-height: 2.25rem;
+    margin-left: auto;
+    padding: 0 0.875rem;
+    border: 1px solid var(--bx-analytics-border);
+    border-radius: 0.5rem;
+    color: var(--bx-analytics-accent);
+    font-size: 0.875rem;
+    font-weight: 500;
+    text-decoration: none;
+}
+.bx-analytics-query .bx-analytics-download::before {
+    content: '\2193';
+    content: '\2193' / '';
+}
+.bx-analytics-query .bx-analytics-download:hover {
+    background: var(--bx-analytics-hover);
+}
+.bx-analytics-query .bx-analytics-download:focus-visible {
+    outline: 2px solid var(--bx-analytics-accent);
+    outline-offset: 2px;
+}
+.bx-analytics-query .bx-analytics-chart {
+    margin-bottom: 1rem;
+}
+
+/* Studio tools: API playground, dashboards, setup */
+.bx-analytics-tool {
+    --bx-analytics-on-accent: #ffffff;
+    min-width: 0;
+}
+html.dark .bx-analytics-tool {
+    --bx-analytics-on-accent: #111827;
+}
+.bx-analytics-tool p {
+    margin: 0 0 0.75rem;
+}
+.bx-analytics-tool-intro {
+    max-width: 48rem;
+    color: var(--bx-analytics-muted);
+}
+.bx-analytics-subtitle-first {
+    margin-top: 0;
+}
+.bx-analytics .bx-analytics-tool-link {
+    color: var(--bx-analytics-accent);
+    text-decoration: underline;
+    text-underline-offset: 2px;
+}
+.bx-analytics .bx-analytics-tool-link:focus-visible {
+    outline: 2px solid var(--bx-analytics-accent);
+    outline-offset: 2px;
+}
+.bx-analytics-notices {
+    display: flex;
+    flex-direction: column;
+    gap: 0.5rem;
+    margin-bottom: 1rem;
+}
+.bx-analytics .bx-analytics-notice {
+    flex: 1 0 100%;
+    margin: 0;
+    padding: 0.625rem 0.875rem;
+    border: 1px solid var(--bx-analytics-border);
+    border-left: 3px solid var(--bx-analytics-warn);
+    border-radius: 0.5rem;
+    font-size: 0.875rem;
+}
+.bx-analytics .bx-analytics-tool-btn {
+    display: inline-flex;
+    align-items: center;
+    justify-content: center;
+    min-height: 2.75rem;
+    padding: 0 1rem;
+    border: 1px solid var(--bx-analytics-accent);
+    border-radius: 0.5rem;
+    color: var(--bx-analytics-accent);
+    font-size: 0.875rem;
+    font-weight: 500;
+    text-align: center;
+}
+.bx-analytics .bx-analytics-tool-btn:hover {
+    background: var(--bx-analytics-hover);
+}
+.bx-analytics .bx-analytics-tool-btn-primary,
+.bx-analytics .bx-analytics-tool-btn-primary:hover {
+    background: var(--bx-analytics-accent);
+    color: var(--bx-analytics-on-accent);
+}
+.bx-analytics .bx-analytics-tool-btn-primary:hover {
+    filter: brightness(1.08);
+}
+.bx-analytics .bx-analytics-tool-btn:disabled {
+    opacity: 0.6;
+    cursor: progress;
+}
+.bx-analytics .bx-analytics-copy-btn {
+    flex: none;
+    min-height: 2.25rem;
+    padding: 0 0.625rem;
+    border-radius: 0.375rem;
+    color: var(--bx-analytics-accent);
+    font-size: 0.8125rem;
+    font-weight: 500;
+}
+.bx-analytics .bx-analytics-copy-btn:hover {
+    background: var(--bx-analytics-hover);
+}
+.bx-analytics-tool .bx-analytics-form-actions {
+    flex-wrap: wrap;
+    margin-bottom: 0.75rem;
+}
+
+/* code with a copy button */
+.bx-analytics-code-block {
+    min-width: 0;
+    margin-bottom: 0.75rem;
+}
+.bx-analytics-code-head {
+    display: flex;
+    flex-wrap: wrap;
+    align-items: center;
+    gap: 0.25rem 0.75rem;
+}
+.bx-analytics-code-label {
+    font-size: 0.8125rem;
+    font-weight: 500;
+    color: var(--bx-analytics-muted);
+}
+.bx-analytics-code-head .bx-analytics-copy-btn {
+    margin-left: auto;
+}
+.bx-analytics-code {
+    max-height: 24rem;
+    margin: 0.25rem 0 0;
+    padding: 0.625rem 0.875rem;
+    overflow: auto;
+    border: 1px solid var(--bx-analytics-border);
+    border-radius: 0.5rem;
+    background: var(--bx-analytics-hover);
+    font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+    font-size: 0.8125rem;
+    line-height: 1.5;
+    white-space: pre;
+}
+.bx-analytics-code code,
+.bx-analytics-tool td code,
+.bx-analytics-facts code,
+.bx-analytics-tags code {
+    font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+    font-size: 0.8125rem;
+}
+.bx-analytics-code:focus-visible {
+    outline: 2px solid var(--bx-analytics-accent);
+    outline-offset: 2px;
+}
+.bx-analytics-api-output {
+    max-height: 36rem;
+}
+
+/* tables of the tools: text columns wrap where they must */
+.bx-analytics-tool-table .bx-analytics-col-wrap {
+    white-space: normal;
+    overflow-wrap: anywhere;
+}
+.bx-analytics-tool-table .bx-analytics-col-strong {
+    font-weight: 500;
+}
+
+/* API playground */
+.bx-analytics-api-fields {
+    display: flex;
+    flex-wrap: wrap;
+    align-items: flex-start;
+    gap: 0.75rem 1rem;
+    margin-bottom: 0.75rem;
+}
+.bx-analytics-api-fields .bx-analytics-field-grow {
+    flex: 1 1 20rem;
+}
+.bx-analytics .bx-analytics-code-input {
+    width: 100%;
+    min-height: 6rem;
+    box-sizing: border-box;
+    font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+    font-size: 0.8125rem;
+    line-height: 1.5;
+    resize: vertical;
+}
+.bx-analytics .bx-analytics-code-input[aria-invalid="true"] {
+    outline: 2px solid var(--bx-analytics-bad);
+    outline-offset: 1px;
+}
+.bx-analytics-api-docs {
+    margin-bottom: 1rem;
+}
+.bx-analytics-api-docs > summary {
+    display: list-item;
+    padding: 0.5rem 0;
+    color: var(--bx-analytics-accent);
+    font-size: 0.875rem;
+    font-weight: 500;
+    cursor: pointer;
+}
+.bx-analytics-api-docs > summary:focus-visible {
+    outline: 2px solid var(--bx-analytics-accent);
+    outline-offset: 2px;
+}
+.bx-analytics-api-doc {
+    margin-bottom: 0.75rem;
+}
+.bx-analytics-api-doc[hidden] {
+    display: none;
+}
+.bx-analytics-api-calls {
+    display: flex;
+    flex-direction: column;
+}
+.bx-analytics-api-meta {
+    font-size: 0.8125rem;
+    color: var(--bx-analytics-muted);
+    font-variant-numeric: tabular-nums;
+}
+
+/* states with an icon: tracker status, checks */
+.bx-analytics-icon {
+    flex: none;
+    width: 1rem;
+    height: 1rem;
+}
+.bx-analytics .bx-analytics-state {
+    display: inline-flex;
+    align-items: center;
+    gap: 0.375rem;
+}
+.bx-analytics-state-receiving,
+.bx-analytics-state-yes {
+    color: var(--bx-analytics-good);
+}
+.bx-analytics-state-quiet {
+    color: var(--bx-analytics-warn);
+}
+.bx-analytics-state-nodata,
+.bx-analytics-state-off,
+.bx-analytics-state-note {
+    color: var(--bx-analytics-muted);
+}
+.bx-analytics-state-no {
+    color: var(--bx-analytics-bad);
+}
+.bx-analytics-checklist {
+    display: flex;
+    flex-direction: column;
+    gap: 0.375rem;
+    margin: 0 0 0.75rem;
+    padding: 0;
+    list-style: none;
+}
+.bx-analytics-checklist .bx-analytics-state {
+    align-items: flex-start;
+}
+.bx-analytics-checklist .bx-analytics-icon {
+    margin-top: 0.125rem;
+}
+
+/* setup */
+.bx-analytics-setup-result:not(:empty) {
+    margin-bottom: 1rem;
+    padding: 0.875rem 1rem;
+    border: 1px solid var(--bx-analytics-border);
+    border-radius: 0.75rem;
+}
+.bx-analytics-setup-verdict {
+    font-weight: 600;
+}
+.bx-analytics-facts {
+    display: grid;
+    grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr));
+    gap: 0.75rem 1.5rem;
+    margin: 0;
+}
+.bx-analytics-fact {
+    min-width: 0;
+    margin: 0;
+}
+.bx-analytics-fact dt {
+    font-size: 0.75rem;
+    color: var(--bx-analytics-muted);
+}
+.bx-analytics-fact dd {
+    margin: 0.125rem 0 0;
+    overflow-wrap: anywhere;
+}
+.bx-analytics-setup-clients {
+    display: grid;
+    grid-template-columns: repeat(auto-fit, minmax(min(100%, 22rem), 1fr));
+    gap: 1rem;
+}
+.bx-analytics-setup-client {
+    min-width: 0;
+    padding: 1rem;
+    border: 1px solid var(--bx-analytics-border);
+    border-radius: 0.75rem;
+}
+.bx-analytics-setup-client-title {
+    margin: 0 0 0.5rem;
+    font-size: 0.9375rem;
+    font-weight: 600;
+}
+.bx-analytics-setup-client .bx-analytics-hint {
+    display: block;
+    margin: 0;
+}
+.bx-analytics-tags {
+    display: flex;
+    flex-wrap: wrap;
+    gap: 0.375rem;
+    margin: 0.25rem 0 0;
+    padding: 0;
+    list-style: none;
+}
+.bx-analytics-tags li {
+    padding: 0.125rem 0.5rem;
+    border-radius: 999px;
+    background: var(--bx-analytics-hover);
+}
+.bx-analytics-setup-events {
+    display: flex;
+    flex-direction: column;
+}

+ 45 - 0
modules/boonex/analytics/updates/15.0.1_15.0.2/source/template/css/studio.css

@@ -0,0 +1,45 @@
+/**
+ * Studio: the analytics side menu in sections. Items keep Studio's own markup and styles;
+ * this adds the section headings. On phones the menu is one row of tabs, so the sections
+ * dissolve into it and the headings hide.
+ */
+.bx-analytics-std-menu-section + .bx-analytics-std-menu-section {
+    margin-top: 1rem;
+}
+.bx-analytics-std-menu-title {
+    padding: 0 0.75rem 0.375rem;
+    font-size: 0.75rem;
+    font-weight: 600;
+    line-height: 1rem;
+    letter-spacing: 0.02em;
+    text-transform: uppercase;
+    color: #4b5563;
+}
+html.dark .bx-analytics-std-menu-title {
+    color: #9ca3af;
+}
+.bx-analytics-std-menu-section > .bx-clear {
+    display: none;
+}
+
+@media (max-width: 1023px) {
+    .bx-analytics-std-menu-title {
+        padding: 0 0.25rem 0.25rem;
+        font-size: 0.625rem;
+        text-align: center;
+        overflow-wrap: anywhere;
+    }
+}
+@media (max-width: 767px) {
+    .bx-analytics-std-menu-section {
+        display: contents;
+    }
+    .bx-analytics-std-menu-title {
+        display: none;
+    }
+}
+
+/* Lucide glyphs on the menu tiles follow the tile, never the iconset's blue */
+div.bx-std-pmen-item svg.bx-analytics-std-menu-icon {
+    color: inherit;
+}

+ 43 - 0
modules/boonex/analytics/updates/15.0.1_15.0.2/source/template/error.html

@@ -0,0 +1,43 @@
+<div class="bx-analytics-detail bx-analytics-error">
+    <button type="button" class="bx-analytics-back" data-bx-analytics-open="error" data-bx-analytics-value="">__back__</button>
+    <h3 class="bx-analytics-detail-title bx-analytics-error-title">__title__</h3>
+    <bx_if:actions>
+        <div class="bx-analytics-error-actions" role="group" aria-label="__actions_label__">
+            <bx_repeat:actions>
+                <button type="button" class="bx-analytics-action-btn" data-bx-analytics-action="error_status" data-bx-analytics-op="__action_op__" data-bx-analytics-id="__action_id__" data-bx-analytics-csrf="__action_csrf__">__action_title__</button>
+            </bx_repeat:actions>
+        </div>
+    </bx_if:actions>
+    <div class="bx-analytics-facts">
+        <bx_repeat:facts>
+            <dl class="bx-analytics-fact">
+                <dt>__fact_title__</dt>
+                <dd>__fact_value__</dd>
+            </dl>
+        </bx_repeat:facts>
+    </div>
+    __tiles__
+    <div class="bx-analytics-chart bx-analytics-chart-sm">
+        <canvas role="img" aria-label="__chart_label__"></canvas>
+    </div>
+    <script type="application/json" class="bx-analytics-chart-data">__chart_json__</script>
+    <h3 class="bx-analytics-subtitle">__stack_title__</h3>
+    <bx_if:stack>
+        <pre class="bx-analytics-stack" tabindex="0" role="region" aria-label="__stack_label__">__stack__</pre>
+    </bx_if:stack>
+    <bx_if:no_stack>
+        <div class="bx-analytics-empty">__no_stack__</div>
+    </bx_if:no_stack>
+    <bx_if:breakdowns>
+        <div class="bx-analytics-error-breakdowns">
+            <bx_repeat:breakdowns>
+                <div>
+                    <h3 class="bx-analytics-subtitle">__breakdown_title__</h3>
+                    __breakdown__
+                </div>
+            </bx_repeat:breakdowns>
+        </div>
+    </bx_if:breakdowns>
+    <h3 class="bx-analytics-subtitle">__latest_title__</h3>
+    __latest__
+</div>

+ 4 - 0
modules/boonex/analytics/updates/15.0.1_15.0.2/source/template/error_missing.html

@@ -0,0 +1,4 @@
+<div class="bx-analytics-detail bx-analytics-error">
+    <button type="button" class="bx-analytics-back" data-bx-analytics-open="error" data-bx-analytics-value="">__back__</button>
+    <div class="bx-analytics-empty">__text__</div>
+</div>

+ 2 - 0
modules/boonex/analytics/updates/15.0.1_15.0.2/source/template/errors.html

@@ -0,0 +1,2 @@
+__tiles__
+<div class="bx-analytics-errors">__table__</div>

+ 25 - 0
modules/boonex/analytics/updates/15.0.1_15.0.2/source/template/event.html

@@ -0,0 +1,25 @@
+<div class="bx-analytics-detail">
+    <button type="button" class="bx-analytics-back" data-bx-analytics-open="event" data-bx-analytics-value="">__back__</button>
+    <h3 class="bx-analytics-detail-title">__title__</h3>
+    __tiles__
+    <div class="bx-analytics-chart bx-analytics-chart-sm">
+        <canvas role="img" aria-label="__chart_label__"></canvas>
+    </div>
+    <script type="application/json" class="bx-analytics-chart-data">__chart_json__</script>
+    <div class="bx-analytics-columns">
+        <div>
+            <h3 class="bx-analytics-subtitle">__platforms_title__</h3>
+            __platforms__
+        </div>
+        <div>
+            <bx_repeat:props>
+                <h3 class="bx-analytics-subtitle">__title__</h3>
+                __table__
+            </bx_repeat:props>
+            <bx_if:no_props>
+                <h3 class="bx-analytics-subtitle">__props_title__</h3>
+                <div class="bx-analytics-empty">__no_props__</div>
+            </bx_if:no_props>
+        </div>
+    </div>
+</div>

+ 35 - 0
modules/boonex/analytics/updates/15.0.1_15.0.2/source/template/filters.html

@@ -0,0 +1,35 @@
+<div class="bx-analytics-toolbar">
+    <div class="bx-analytics-controls">
+        <label class="bx-analytics-control">
+            <span class="bx-analytics-control-label">__range_label__</span>
+            <select class="bx-def-font-inputs bx-form-input-select bx-analytics-select" data-bx-analytics-param="range">
+                <bx_repeat:ranges>
+                    <option value="__value__" __selected__>__title__</option>
+                </bx_repeat:ranges>
+            </select>
+        </label>
+        <label class="bx-analytics-control">
+            <span class="bx-analytics-control-label">__platform_label__</span>
+            <select class="bx-def-font-inputs bx-form-input-select bx-analytics-select" data-bx-analytics-param="platform">
+                <bx_repeat:platforms>
+                    <option value="__value__" __selected__>__title__</option>
+                </bx_repeat:platforms>
+            </select>
+        </label>
+        <bx_if:chips>
+            <div class="bx-analytics-chips">
+                <bx_repeat:chips>
+                    <button type="button" class="bx-analytics-chip" data-bx-analytics-unfilter="__name__" aria-label="__remove__">
+                        <span class="bx-analytics-chip-name">__title__:</span> <span class="bx-analytics-chip-value">__value__</span>
+                        <span class="bx-analytics-chip-x" aria-hidden="true">&times;</span>
+                    </button>
+                </bx_repeat:chips>
+            </div>
+        </bx_if:chips>
+    </div>
+    <div class="bx-analytics-online" title="__online_title__">
+        <span class="bx-analytics-online-dot" aria-hidden="true"></span>
+        <span class="bx-analytics-online-count">__online__</span>
+        <span class="bx-analytics-sr">__online_title__</span>
+    </div>
+</div>

+ 76 - 0
modules/boonex/analytics/updates/15.0.1_15.0.2/source/template/funnels.html

@@ -0,0 +1,76 @@
+<div class="bx-analytics-funnels">
+    <div class="bx-analytics-funnel-bar">
+        <bx_if:list>
+            <div class="bx-analytics-chips" role="group" aria-label="__list_label__">
+                <bx_repeat:list>
+                    <button type="button" class="bx-analytics-chip __active__" data-bx-analytics-open="funnel" data-bx-analytics-value="__id__" aria-pressed="__pressed__">__name__</button>
+                </bx_repeat:list>
+            </div>
+        </bx_if:list>
+        <bx_if:manage>
+            <div class="bx-analytics-card-actions">
+                <button type="button" class="bx-analytics-add-btn" data-bx-analytics-open="edit_funnel" data-bx-analytics-value="new">__add__</button>
+                <bx_if:current>
+                    <button type="button" class="bx-analytics-link-btn" data-bx-analytics-open="edit_funnel" data-bx-analytics-value="__current_id__">__edit__</button>
+                    <button type="button" class="bx-analytics-link-btn bx-analytics-danger" data-bx-analytics-action="funnel" data-bx-analytics-op="delete" data-bx-analytics-id="__current_id__" data-bx-analytics-confirm="__confirm__" data-bx-analytics-csrf="__csrf__">__delete__</button>
+                </bx_if:current>
+            </div>
+        </bx_if:manage>
+    </div>
+    <bx_if:steps>
+        <ol class="bx-analytics-funnel">
+            <bx_repeat:steps>
+                <li class="bx-analytics-funnel-step">
+                    <div class="bx-analytics-funnel-label">
+                        <span class="bx-analytics-funnel-number">__number__</span>
+                        <span class="bx-analytics-funnel-pattern">__pattern__</span>
+                        <span class="bx-analytics-badge">__type__</span>
+                    </div>
+                    <div class="bx-analytics-funnel-bar-track">
+                        <span class="bx-analytics-funnel-fill" style="width: __rate_bar__%"></span>
+                        <span class="bx-analytics-funnel-figures">__sessions__ · __rate__</span>
+                    </div>
+                    <bx_if:drop>
+                        <span class="bx-analytics-funnel-drop">__drop__</span>
+                    </bx_if:drop>
+                </li>
+            </bx_repeat:steps>
+        </ol>
+    </bx_if:steps>
+    <bx_if:empty>
+        <div class="bx-analytics-empty">__empty__</div>
+    </bx_if:empty>
+    <bx_if:form>
+        <form class="bx-analytics-form" data-bx-analytics-form="funnel" novalidate>
+            <h3 class="bx-analytics-subtitle">__form_title__</h3>
+            <input type="hidden" name="id" value="__form_id__" />
+            <input type="hidden" name="csrf_token" value="__form_csrf__" />
+            <label class="bx-analytics-field bx-analytics-field-wide">
+                <span class="bx-analytics-control-label">__label_name__</span>
+                <input type="text" name="name" value="__form_name__" maxlength="128" required class="bx-def-font-inputs bx-form-input-text" />
+            </label>
+            <bx_repeat:rows>
+                <div class="bx-analytics-field-row">
+                    <span class="bx-analytics-funnel-number" aria-hidden="true">__row_number__</span>
+                    <label class="bx-analytics-field">
+                        <span class="bx-analytics-sr">__row_type_label__</span>
+                        <select name="step_type[]" class="bx-def-font-inputs bx-form-input-select">
+                            <option value="page" __row_page__>__row_type_page__</option>
+                            <option value="event" __row_event__>__row_type_event__</option>
+                        </select>
+                    </label>
+                    <label class="bx-analytics-field bx-analytics-field-grow">
+                        <span class="bx-analytics-sr">__row_pattern_label__</span>
+                        <input type="text" name="step_pattern[]" value="__row_pattern__" maxlength="255" class="bx-def-font-inputs bx-form-input-text" />
+                    </label>
+                </div>
+            </bx_repeat:rows>
+            <span class="bx-analytics-hint">__hint__</span>
+            <p class="bx-analytics-form-error" role="alert" hidden></p>
+            <div class="bx-analytics-form-actions">
+                <button type="submit" class="bx-btn bx-btn-primary bx-btn-small">__save__</button>
+                <button type="button" class="bx-btn bx-btn-small" data-bx-analytics-open="edit_funnel" data-bx-analytics-value="">__cancel__</button>
+            </div>
+        </form>
+    </bx_if:form>
+</div>

+ 61 - 0
modules/boonex/analytics/updates/15.0.1_15.0.2/source/template/goals.html

@@ -0,0 +1,61 @@
+<div class="bx-analytics-goals">
+    <bx_if:goals>
+        <ul class="bx-analytics-cards">
+            <bx_repeat:goals>
+                <li class="bx-analytics-card">
+                    <div class="bx-analytics-card-head">
+                        <span class="bx-analytics-card-title">__name__</span>
+                        <span class="bx-analytics-badge">__type__</span>
+                    </div>
+                    <span class="bx-analytics-card-sub">__pattern__</span>
+                    <div class="bx-analytics-card-figures">
+                        <span class="bx-analytics-kpi-value">__rate__</span>
+                        <span class="bx-analytics-kpi-change bx-analytics-trend-__trend__">__change__</span>
+                    </div>
+                    <span class="bx-analytics-card-sub">__conversions__</span>
+                    <div class="bx-analytics-meter" aria-hidden="true"><span style="width: __bar__%"></span></div>
+                    <bx_if:actions>
+                        <div class="bx-analytics-card-actions">
+                            <button type="button" class="bx-analytics-link-btn" data-bx-analytics-open="edit_goal" data-bx-analytics-value="__id__">__edit__</button>
+                            <button type="button" class="bx-analytics-link-btn bx-analytics-danger" data-bx-analytics-action="goal" data-bx-analytics-op="delete" data-bx-analytics-id="__id__" data-bx-analytics-confirm="__confirm__" data-bx-analytics-csrf="__csrf__">__delete__</button>
+                        </div>
+                    </bx_if:actions>
+                </li>
+            </bx_repeat:goals>
+        </ul>
+    </bx_if:goals>
+    <bx_if:empty>
+        <div class="bx-analytics-empty">__empty__</div>
+    </bx_if:empty>
+    <bx_if:add>
+        <button type="button" class="bx-analytics-add-btn" data-bx-analytics-open="edit_goal" data-bx-analytics-value="new">__add__</button>
+    </bx_if:add>
+    <bx_if:form>
+        <form class="bx-analytics-form" data-bx-analytics-form="goal" novalidate>
+            <h3 class="bx-analytics-subtitle">__form_title__</h3>
+            <input type="hidden" name="id" value="__form_id__" />
+            <input type="hidden" name="csrf_token" value="__form_csrf__" />
+            <label class="bx-analytics-field">
+                <span class="bx-analytics-control-label">__label_name__</span>
+                <input type="text" name="name" value="__form_name__" maxlength="128" required class="bx-def-font-inputs bx-form-input-text" />
+            </label>
+            <label class="bx-analytics-field">
+                <span class="bx-analytics-control-label">__label_type__</span>
+                <select name="type" class="bx-def-font-inputs bx-form-input-select">
+                    <option value="page" __page_selected__>__type_page__</option>
+                    <option value="event" __event_selected__>__type_event__</option>
+                </select>
+            </label>
+            <label class="bx-analytics-field bx-analytics-field-wide">
+                <span class="bx-analytics-control-label">__label_pattern__</span>
+                <input type="text" name="pattern" value="__form_pattern__" maxlength="255" required class="bx-def-font-inputs bx-form-input-text" />
+                <span class="bx-analytics-hint">__hint__</span>
+            </label>
+            <p class="bx-analytics-form-error" role="alert" hidden></p>
+            <div class="bx-analytics-form-actions">
+                <button type="submit" class="bx-btn bx-btn-primary bx-btn-small">__save__</button>
+                <button type="button" class="bx-btn bx-btn-small" data-bx-analytics-open="edit_goal" data-bx-analytics-value="">__cancel__</button>
+            </div>
+        </form>
+    </bx_if:form>
+</div>

+ 1 - 0
modules/boonex/analytics/updates/15.0.1_15.0.2/source/template/icon.html

@@ -0,0 +1 @@
+<svg class="bx-analytics-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><bx_if:check><circle cx="12" cy="12" r="10"/><path d="m9 12 2 2 4-4"/></bx_if:check><bx_if:clock><circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/></bx_if:clock><bx_if:minus><circle cx="12" cy="12" r="10"/><path d="M8 12h8"/></bx_if:minus><bx_if:power><path d="M12 2v10"/><path d="M18.4 6.6a9 9 0 1 1-12.77.04"/></bx_if:power><bx_if:x><circle cx="12" cy="12" r="10"/><path d="m15 9-6 6"/><path d="m9 9 6 6"/></bx_if:x><bx_if:info><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></bx_if:info></svg>

+ 8 - 0
modules/boonex/analytics/updates/15.0.1_15.0.2/source/template/import.html

@@ -0,0 +1,8 @@
+<div class="bx-analytics-import">
+    <div class="bx-analytics-import-text">
+        <h3 class="bx-analytics-subtitle">__title__</h3>
+        <p class="bx-analytics-import-status" role="status">__text__</p>
+        <p class="bx-analytics-hint">__hint__</p>
+    </div>
+    <button type="button" class="bx-analytics-import-btn" data-bx-analytics-action="backfill" data-bx-analytics-op="__op__" data-bx-analytics-id="0" data-bx-analytics-confirm="__confirm__" data-bx-analytics-csrf="__csrf__">__button__</button>
+</div>

+ 59 - 0
modules/boonex/analytics/updates/15.0.1_15.0.2/source/template/journeys.html

@@ -0,0 +1,59 @@
+<div class="bx-analytics-journeys">
+    <div class="bx-analytics-journey-start">
+        <h3 class="bx-analytics-subtitle">__entries_title__</h3>
+        <bx_if:entries>
+            <ul class="bx-analytics-rows">
+                <bx_repeat:entries>
+                    <li class="bx-analytics-row">
+                        <button type="button" class="bx-analytics-row-btn __active__" data-bx-analytics-open="jfrom" data-bx-analytics-value="__page__" aria-pressed="__pressed__">
+                            <span class="bx-analytics-row-label">__label__</span>
+                            <span class="bx-analytics-row-value">__sessions__</span>
+                        </button>
+                    </li>
+                </bx_repeat:entries>
+            </ul>
+        </bx_if:entries>
+        <bx_if:no_entries>
+            <div class="bx-analytics-empty">__empty__</div>
+        </bx_if:no_entries>
+    </div>
+    <bx_if:flow>
+        <div class="bx-analytics-flow" aria-label="__flow_label__" role="group">
+            <div class="bx-analytics-flow-step">
+                <div class="bx-analytics-flow-head">__step1__</div>
+                <div class="bx-analytics-flow-node bx-analytics-flow-node-main">
+                    <span class="bx-analytics-flow-page">__from__</span>
+                    <span class="bx-analytics-flow-count">__from_sessions__</span>
+                </div>
+            </div>
+            <div class="bx-analytics-flow-step">
+                <div class="bx-analytics-flow-head">__step2__</div>
+                <bx_repeat:next>
+                    <button type="button" class="bx-analytics-flow-node __class__" __attrs__>
+                        <span class="bx-analytics-row-bar" style="width: __share__%" aria-hidden="true"></span>
+                        <span class="bx-analytics-flow-page">__label__</span>
+                        <span class="bx-analytics-flow-count">__share__%</span>
+                    </button>
+                </bx_repeat:next>
+            </div>
+            <div class="bx-analytics-flow-step">
+                <div class="bx-analytics-flow-head">__step3__</div>
+                <bx_repeat:after>
+                    <div class="bx-analytics-flow-node __class__">
+                        <span class="bx-analytics-row-bar" style="width: __share__%" aria-hidden="true"></span>
+                        <span class="bx-analytics-flow-page">__label__</span>
+                        <span class="bx-analytics-flow-count">__share__%</span>
+                    </div>
+                </bx_repeat:after>
+                <bx_if:no_after>
+                    <p class="bx-analytics-note">__no_after__</p>
+                </bx_if:no_after>
+            </div>
+        </div>
+    </bx_if:flow>
+</div>
+<h3 class="bx-analytics-subtitle">__paths_title__</h3>
+__paths__
+<bx_if:truncated>
+    <p class="bx-analytics-note">__truncated__</p>
+</bx_if:truncated>

+ 30 - 0
modules/boonex/analytics/updates/15.0.1_15.0.2/source/template/map.html

@@ -0,0 +1,30 @@
+__tabs__
+<div class="bx-analytics-panel bx-analytics-map-layout" role="tabpanel" id="bx-analytics-panel-map" __refresh__>
+    <div class="bx-analytics-map" role="img" aria-label="__map_label__"></div>
+    <script type="application/json" class="bx-analytics-map-data">__map_json__</script>
+    <div class="bx-analytics-map-side">
+        <div class="bx-analytics-list-head" aria-hidden="true">
+            <span>__column__</span>
+            <span>__visitors__</span>
+        </div>
+        <bx_if:countries>
+            <ul class="bx-analytics-rows">
+                <bx_repeat:countries>
+                    <li class="bx-analytics-row">
+                        <button type="button" class="bx-analytics-row-btn" data-bx-analytics-filter="country" data-bx-analytics-value="__value__" title="__filter_title__">
+                            <span class="bx-analytics-row-bar" style="width: __bar__%" aria-hidden="true"></span>
+                            <span class="bx-analytics-row-label">__label__</span>
+                            <span class="bx-analytics-row-value">__visitors__ <span class="bx-analytics-row-share">__share__%</span></span>
+                        </button>
+                    </li>
+                </bx_repeat:countries>
+            </ul>
+        </bx_if:countries>
+        <bx_if:empty>
+            <div class="bx-analytics-empty">__empty__</div>
+        </bx_if:empty>
+        <bx_if:unknown>
+            <p class="bx-analytics-note">__unknown__</p>
+        </bx_if:unknown>
+    </div>
+</div>

+ 8 - 0
modules/boonex/analytics/updates/15.0.1_15.0.2/source/template/member.html

@@ -0,0 +1,8 @@
+<div class="bx-analytics-member">
+    <bx_if:thumb><img class="bx-analytics-member-thumb" src="__thumb__" alt="" /></bx_if:thumb>
+    <bx_if:no_thumb><span class="bx-analytics-member-thumb bx-analytics-member-guest" aria-hidden="true">?</span></bx_if:no_thumb>
+    <span class="bx-analytics-member-name">
+        <bx_if:link><a href="__url__">__name__</a></bx_if:link>
+        <bx_if:text>__name__</bx_if:text>
+    </span>
+</div>

+ 5 - 0
modules/boonex/analytics/updates/15.0.1_15.0.2/source/template/notes.html

@@ -0,0 +1,5 @@
+<div class="bx-analytics-notes">
+    <bx_repeat:notes>
+        <p class="bx-analytics-note">__note__</p>
+    </bx_repeat:notes>
+</div>

+ 15 - 0
modules/boonex/analytics/updates/15.0.1_15.0.2/source/template/overview.html

@@ -0,0 +1,15 @@
+<div class="bx-analytics-kpis" role="group" aria-label="__kpis_label__">
+    <bx_repeat:kpis>
+        <button type="button" class="bx-analytics-kpi" data-bx-analytics-metric="__metric__" aria-pressed="__pressed__">
+            <span class="bx-analytics-kpi-title">__title__</span>
+            <span class="bx-analytics-kpi-figures">
+                <span class="bx-analytics-kpi-value">__value__</span>
+                <span class="bx-analytics-kpi-change bx-analytics-trend-__trend__" title="__change_title__">__change__</span>
+            </span>
+        </button>
+    </bx_repeat:kpis>
+</div>
+<div class="bx-analytics-chart">
+    <canvas role="img" aria-label="__chart_label__"></canvas>
+</div>
+<script type="application/json" class="bx-analytics-chart-data">__chart_json__</script>

+ 38 - 0
modules/boonex/analytics/updates/15.0.1_15.0.2/source/template/performance.html

@@ -0,0 +1,38 @@
+<div class="bx-analytics-performance">
+    <div class="bx-analytics-controls">
+        <label class="bx-analytics-control">
+            <span class="bx-analytics-control-label">__device_label__</span>
+            <select class="bx-def-font-inputs bx-form-input-select bx-analytics-select" data-bx-analytics-param="vital_device">
+                <bx_repeat:devices>
+                    <option value="__device_value__" __device_selected__>__device_title__</option>
+                </bx_repeat:devices>
+            </select>
+        </label>
+    </div>
+    <ul class="bx-analytics-vitals" aria-label="__cards_label__">
+        <bx_repeat:cards>
+            <li>
+                <button type="button" class="bx-analytics-vital bx-analytics-vital-is-__card_rating__" data-bx-analytics-open="vital" data-bx-analytics-value="__card_metric__" aria-pressed="__card_pressed__">
+                    <span class="bx-analytics-vital-title">__card_title__</span>
+                    <span class="bx-analytics-vital-value">__card_value__</span>
+                    <span class="bx-analytics-vital-rating">__card_rating_title__</span>
+                    <span class="bx-analytics-vital-dist" aria-hidden="true">
+                        <span class="bx-analytics-vital-good" style="width: __card_good__%"></span><span class="bx-analytics-vital-ni" style="width: __card_ni__%"></span><span class="bx-analytics-vital-poor" style="width: __card_poor__%"></span>
+                    </span>
+                    <span class="bx-analytics-vital-sub">__card_dist__</span>
+                    <span class="bx-analytics-vital-sub">__card_samples__</span>
+                    <span class="bx-analytics-vital-sub">__card_thresholds__</span>
+                </button>
+            </li>
+        </bx_repeat:cards>
+    </ul>
+    <h3 class="bx-analytics-subtitle">__chart_title__</h3>
+    <div class="bx-analytics-chart bx-analytics-chart-sm">
+        <canvas role="img" aria-label="__chart_label__"></canvas>
+    </div>
+    <script type="application/json" class="bx-analytics-chart-data">__chart_json__</script>
+    <h3 class="bx-analytics-subtitle">__pages_title__</h3>
+    __pages__
+    <h3 class="bx-analytics-subtitle">__platforms_title__</h3>
+    __platforms__
+</div>

+ 48 - 0
modules/boonex/analytics/updates/15.0.1_15.0.2/source/template/query.html

@@ -0,0 +1,48 @@
+<div class="bx-analytics-query">
+    <div class="bx-analytics-controls bx-analytics-query-controls">
+        <label class="bx-analytics-control">
+            <span class="bx-analytics-control-label">__dimension_label__</span>
+            <select class="bx-def-font-inputs bx-form-input-select bx-analytics-select" data-bx-analytics-param="q_dim">
+                <optgroup label="__event_dims_label__">
+                    <bx_repeat:event_dims>
+                        <option value="__dim_value__" __dim_selected__>__dim_title__</option>
+                    </bx_repeat:event_dims>
+                </optgroup>
+                <optgroup label="__session_dims_label__">
+                    <bx_repeat:session_dims>
+                        <option value="__dim_value__" __dim_selected__>__dim_title__</option>
+                    </bx_repeat:session_dims>
+                </optgroup>
+            </select>
+        </label>
+        <label class="bx-analytics-control">
+            <span class="bx-analytics-control-label">__metric_label__</span>
+            <select class="bx-def-font-inputs bx-form-input-select bx-analytics-select" data-bx-analytics-param="q_metric">
+                <bx_repeat:metrics>
+                    <option value="__metric_value__" __metric_selected__>__metric_title__</option>
+                </bx_repeat:metrics>
+            </select>
+        </label>
+        <label class="bx-analytics-control">
+            <span class="bx-analytics-control-label">__dir_label__</span>
+            <select class="bx-def-font-inputs bx-form-input-select bx-analytics-select" data-bx-analytics-param="q_dir">
+                <bx_repeat:dirs>
+                    <option value="__dir_value__" __dir_selected__>__dir_title__</option>
+                </bx_repeat:dirs>
+            </select>
+        </label>
+        <bx_if:download>
+            <a class="bx-analytics-download" href="__export_url__" download>__download__</a>
+        </bx_if:download>
+    </div>
+    <bx_if:chart>
+        <div class="bx-analytics-chart">
+            <canvas role="img" aria-label="__chart_label__"></canvas>
+        </div>
+        <script type="application/json" class="bx-analytics-chart-data">__chart_json__</script>
+    </bx_if:chart>
+    __table__
+    <bx_if:truncated>
+        <p class="bx-analytics-note">__truncated__</p>
+    </bx_if:truncated>
+</div>

+ 1 - 0
modules/boonex/analytics/updates/15.0.1_15.0.2/source/template/rating.html

@@ -0,0 +1 @@
+<span class="bx-analytics-rating bx-analytics-rating-__rating__">__title__</span>

+ 44 - 0
modules/boonex/analytics/updates/15.0.1_15.0.2/source/template/realtime.html

@@ -0,0 +1,44 @@
+<div class="bx-analytics-realtime" data-bx-analytics-refresh="15">
+    <div class="bx-analytics-realtime-head">
+        <div class="bx-analytics-realtime-online" role="status">
+            <span class="bx-analytics-online-dot" aria-hidden="true"></span>
+            <span class="bx-analytics-realtime-count">__online__</span>
+            <span class="bx-analytics-realtime-label">__online_title__</span>
+        </div>
+        <bx_if:platforms>
+            <ul class="bx-analytics-rows bx-analytics-realtime-platforms">
+                <bx_repeat:platforms>
+                    <li class="bx-analytics-row bx-analytics-std-platform">
+                        <span class="bx-analytics-row-bar" style="width: __share__%" aria-hidden="true"></span>
+                        <span class="bx-analytics-row-label">__label__</span>
+                        <span class="bx-analytics-row-value">__visitors__</span>
+                    </li>
+                </bx_repeat:platforms>
+            </ul>
+        </bx_if:platforms>
+    </div>
+    <div class="bx-analytics-minutes" role="img" aria-label="__minutes_label__">
+        <bx_repeat:minutes>
+            <span class="bx-analytics-minute" style="height: __height__%" title="__title__"></span>
+        </bx_repeat:minutes>
+    </div>
+    <div class="bx-analytics-minutes-axis" aria-hidden="true"><span>__axis_start__</span><span>__axis_end__</span></div>
+    <h3 class="bx-analytics-subtitle">__recent_title__</h3>
+    <bx_if:recent>
+        <ul class="bx-analytics-feed">
+            <bx_repeat:recent>
+                <li class="bx-analytics-feed-item">
+                    <span class="bx-analytics-feed-time">__time__</span>
+                    <span class="bx-analytics-feed-page">
+                        <span class="bx-analytics-feed-title">__title__</span>
+                        <span class="bx-analytics-feed-path">__path__</span>
+                    </span>
+                    <span class="bx-analytics-feed-meta">__meta__</span>
+                </li>
+            </bx_repeat:recent>
+        </ul>
+    </bx_if:recent>
+    <bx_if:empty>
+        <div class="bx-analytics-empty">__empty__</div>
+    </bx_if:empty>
+</div>

+ 30 - 0
modules/boonex/analytics/updates/15.0.1_15.0.2/source/template/retention.html

@@ -0,0 +1,30 @@
+__tabs__
+<div class="bx-analytics-panel" role="tabpanel" id="bx-analytics-panel-retention">
+    <bx_if:cookieless>
+        <p class="bx-analytics-note">__cookieless__</p>
+    </bx_if:cookieless>
+    <div class="bx-analytics-table-wrap" role="region" aria-label="__caption__" tabindex="0">
+        <table class="bx-analytics-table bx-analytics-cohorts">
+            <thead>
+                <tr>
+                    <th scope="col" class="bx-analytics-col-text">__cohort_title__</th>
+                    <th scope="col">__size_title__</th>
+                    <bx_repeat:weeks>
+                        <th scope="col">__week__</th>
+                    </bx_repeat:weeks>
+                </tr>
+            </thead>
+            <tbody>
+                <bx_repeat:rows>
+                    <tr>
+                        <th scope="row" class="bx-analytics-col-text">__start__</th>
+                        <td>__size__</td>
+                        <bx_repeat:cells>
+                            <td class="bx-analytics-cohort-cell __strong__" style="--bx-analytics-heat: __heat__">__percent__</td>
+                        </bx_repeat:cells>
+                    </tr>
+                </bx_repeat:rows>
+            </tbody>
+        </table>
+    </div>
+</div>

+ 8 - 0
modules/boonex/analytics/updates/15.0.1_15.0.2/source/template/revenue.html

@@ -0,0 +1,8 @@
+__tiles__
+<div class="bx-analytics-chart bx-analytics-chart-sm">
+    <canvas role="img" aria-label="__chart_label__"></canvas>
+</div>
+<script type="application/json" class="bx-analytics-chart-data">__chart_json__</script>
+<div class="bx-analytics-revenue-split">__split__</div>
+__notes__
+__import__

+ 16 - 0
modules/boonex/analytics/updates/15.0.1_15.0.2/source/template/sales.html

@@ -0,0 +1,16 @@
+__tiles__
+<div class="bx-analytics-columns">
+    <div>
+        <h3 class="bx-analytics-subtitle">__items_title__</h3>
+        __items__
+    </div>
+    <div>
+        <h3 class="bx-analytics-subtitle">__sellers_title__</h3>
+        __sellers__
+    </div>
+</div>
+<bx_if:invoices>
+    <h3 class="bx-analytics-subtitle">__invoices_title__</h3>
+    __invoices__
+</bx_if:invoices>
+__notes__

+ 32 - 0
modules/boonex/analytics/updates/15.0.1_15.0.2/source/template/session.html

@@ -0,0 +1,32 @@
+<div class="bx-analytics-detail">
+    <button type="button" class="bx-analytics-back" data-bx-analytics-open="session" data-bx-analytics-value="">__back__</button>
+    __member__
+    <div class="bx-analytics-facts">
+        <bx_repeat:facts>
+            <dl class="bx-analytics-fact">
+                <dt>__title__</dt>
+                <dd>__value__</dd>
+            </dl>
+        </bx_repeat:facts>
+    </div>
+    <h3 class="bx-analytics-subtitle">__timeline_title__</h3>
+    <bx_if:timeline>
+        <ol class="bx-analytics-timeline">
+            <bx_repeat:timeline>
+                <li class="bx-analytics-timeline-item bx-analytics-timeline-__type__">
+                    <span class="bx-analytics-timeline-time">__offset__</span>
+                    <span class="bx-analytics-timeline-dot" aria-hidden="true"></span>
+                    <span class="bx-analytics-timeline-body">
+                        <span class="bx-analytics-timeline-kind">__kind__</span>
+                        <span class="bx-analytics-timeline-label">__label__</span>
+                        <bx_if:path><span class="bx-analytics-timeline-path">__path__</span></bx_if:path>
+                    </span>
+                    <span class="bx-analytics-timeline-engaged">__engaged__</span>
+                </li>
+            </bx_repeat:timeline>
+        </ol>
+    </bx_if:timeline>
+    <bx_if:empty>
+        <div class="bx-analytics-empty">__empty__</div>
+    </bx_if:empty>
+</div>

+ 28 - 0
modules/boonex/analytics/updates/15.0.1_15.0.2/source/template/setup_check.html

@@ -0,0 +1,28 @@
+<div class="bx-analytics-setup-check">
+    <p class="bx-analytics-state bx-analytics-state-__verdict_state__ bx-analytics-setup-verdict">__verdict_icon__<span>__verdict__</span></p>
+    <bx_if:reasons>
+        <ul class="bx-analytics-checklist">
+            <bx_repeat:reasons>
+                <li class="bx-analytics-state bx-analytics-state-__reason_kind__">__reason_icon__<span>__reason__</span></li>
+            </bx_repeat:reasons>
+        </ul>
+    </bx_if:reasons>
+    <dl class="bx-analytics-facts">
+        <div class="bx-analytics-fact">
+            <dt>__ip_label__</dt>
+            <dd><code>__ip__</code></dd>
+        </div>
+        <div class="bx-analytics-fact">
+            <dt>__remote_label__</dt>
+            <dd><code>__remote__</code></dd>
+        </div>
+        <div class="bx-analytics-fact">
+            <dt>__geo_label__</dt>
+            <dd>__geo__</dd>
+        </div>
+        <div class="bx-analytics-fact">
+            <dt>__identity_label__</dt>
+            <dd>__identity__</dd>
+        </div>
+    </dl>
+</div>

+ 29 - 0
modules/boonex/analytics/updates/15.0.1_15.0.2/source/template/setup_status.html

@@ -0,0 +1,29 @@
+<div class="bx-analytics-table-wrap" role="region" aria-label="__caption__" tabindex="0">
+    <table class="bx-analytics-table bx-analytics-tool-table">
+        <caption class="bx-analytics-sr">__caption__</caption>
+        <thead>
+            <tr>
+                <th scope="col" class="bx-analytics-col-text">__col_platform__</th>
+                <th scope="col" class="bx-analytics-col-text">__col_status__</th>
+                <th scope="col" class="bx-analytics-col-text">__col_last__</th>
+                <th scope="col">__col_hits__</th>
+                <th scope="col">__col_visitors__</th>
+                <th scope="col">__col_sessions__</th>
+                <th scope="col" class="bx-analytics-col-text">__col_version__</th>
+            </tr>
+        </thead>
+        <tbody>
+            <bx_repeat:rows>
+                <tr>
+                    <td class="bx-analytics-col-text bx-analytics-col-strong">__platform_title__</td>
+                    <td class="bx-analytics-col-text"><span class="bx-analytics-state bx-analytics-state-__status__">__status_icon__<span>__status_title__</span></span></td>
+                    <td class="bx-analytics-col-text">__last__</td>
+                    <td>__hits__</td>
+                    <td>__visitors__</td>
+                    <td>__sessions__</td>
+                    <td class="bx-analytics-col-text">__version__</td>
+                </tr>
+            </bx_repeat:rows>
+        </tbody>
+    </table>
+</div>

+ 1 - 0
modules/boonex/analytics/updates/15.0.1_15.0.2/source/template/spark.html

@@ -0,0 +1 @@
+<svg class="bx-analytics-spark" viewBox="0 0 100 24" preserveAspectRatio="none" aria-hidden="true" focusable="false"><polyline points="__points__" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round" vector-effect="non-scaling-stroke" /></svg>

+ 55 - 0
modules/boonex/analytics/updates/15.0.1_15.0.2/source/template/studio_api.html

@@ -0,0 +1,55 @@
+<div class="bx-analytics bx-analytics-tool bx-analytics-api">
+    <bx_if:notices>
+        <div class="bx-analytics-notices">
+            <bx_repeat:notices>
+                <p class="bx-analytics-notice">__notice__</p>
+            </bx_repeat:notices>
+        </div>
+    </bx_if:notices>
+    <p class="bx-analytics-tool-intro">__intro__</p>
+    <form class="bx-analytics-api-form" data-bx-analytics-api-form novalidate>
+        <input type="hidden" name="csrf_token" value="__csrf__" />
+        <div class="bx-analytics-api-fields">
+            <label class="bx-analytics-field">
+                <span class="bx-analytics-control-label">__report_label__</span>
+                <select name="report" class="bx-def-font-inputs bx-form-input-select bx-analytics-select" data-bx-analytics-api-report>
+                    <bx_repeat:reports>
+                        <option value="__report_value__" __report_selected__>__report_title__</option>
+                    </bx_repeat:reports>
+                </select>
+            </label>
+            <label class="bx-analytics-field bx-analytics-field-grow">
+                <span class="bx-analytics-control-label">__params_label__</span>
+                <textarea name="params" rows="4" spellcheck="false" autocapitalize="off" autocomplete="off" class="bx-def-font-inputs bx-form-input-textarea bx-analytics-code-input" data-bx-analytics-api-params aria-describedby="bx-analytics-api-params-hint">__params_default__</textarea>
+                <span class="bx-analytics-hint" id="bx-analytics-api-params-hint">__params_hint__</span>
+            </label>
+        </div>
+        <details class="bx-analytics-api-docs">
+            <summary>__docs_label__</summary>
+            <bx_repeat:docs>
+                <div class="bx-analytics-api-doc" data-bx-analytics-api-doc="__doc_report__" __doc_hidden__>__doc_table__</div>
+            </bx_repeat:docs>
+            __common_table__
+        </details>
+        <div class="bx-analytics-api-calls">
+            __code_srv__
+            __code_url__
+            __code_neo__
+        </div>
+        <p class="bx-analytics-form-error" role="alert" hidden></p>
+        <div class="bx-analytics-form-actions">
+            <button type="submit" class="bx-analytics-tool-btn bx-analytics-tool-btn-primary">__run__</button>
+        </div>
+    </form>
+    <div class="bx-analytics-api-result" data-bx-analytics-api-result hidden>
+        <div class="bx-analytics-code-block">
+            <div class="bx-analytics-code-head">
+                <span class="bx-analytics-code-label" id="bx-analytics-api-output-label">__response_label__</span>
+                <span class="bx-analytics-api-meta" data-bx-analytics-api-meta role="status"></span>
+                <button type="button" class="bx-analytics-copy-btn" data-bx-analytics-copy="bx-analytics-api-output" data-bx-analytics-copied="__copied__" aria-describedby="bx-analytics-api-output-label">__copy__</button>
+            </div>
+            <pre class="bx-analytics-code bx-analytics-api-output" id="bx-analytics-api-output" tabindex="0" aria-labelledby="bx-analytics-api-output-label"><code data-bx-analytics-api-output></code></pre>
+        </div>
+    </div>
+    <script type="application/json" data-bx-analytics-api-catalog>__catalog_json__</script>
+</div>

+ 46 - 0
modules/boonex/analytics/updates/15.0.1_15.0.2/source/template/studio_dashboards.html

@@ -0,0 +1,46 @@
+<div class="bx-analytics bx-analytics-tool bx-analytics-dashboards">
+    <p class="bx-analytics-tool-intro">__intro__</p>
+    <bx_if:pages>
+        <div class="bx-analytics-table-wrap" role="region" aria-label="__caption__" tabindex="0">
+            <table class="bx-analytics-table bx-analytics-tool-table">
+                <caption class="bx-analytics-sr">__caption__</caption>
+                <thead>
+                    <tr>
+                        <th scope="col" class="bx-analytics-col-text">__col_page__</th>
+                        <th scope="col">__col_blocks__</th>
+                        <th scope="col">__col_neo__</th>
+                        <th scope="col" class="bx-analytics-col-text"><span class="bx-analytics-sr">__col_actions__</span></th>
+                    </tr>
+                </thead>
+                <tbody>
+                    <bx_repeat:pages>
+                        <tr>
+                            <td class="bx-analytics-col-text bx-analytics-col-wrap"><a class="bx-analytics-tool-link" href="__view_url__">__page_title__</a></td>
+                            <td>__blocks__</td>
+                            <td>__api_blocks__</td>
+                            <td class="bx-analytics-col-text"><a class="bx-analytics-tool-link" href="__edit_url__" aria-label="__edit_label__">__edit__</a></td>
+                        </tr>
+                    </bx_repeat:pages>
+                </tbody>
+            </table>
+        </div>
+    </bx_if:pages>
+    <bx_if:empty>
+        <div class="bx-analytics-empty">__empty__</div>
+    </bx_if:empty>
+    <p class="bx-analytics-note">__neo_note__</p>
+    <form class="bx-analytics-form" data-bx-analytics-dashboard-form novalidate>
+        <h3 class="bx-analytics-subtitle">__create_title__</h3>
+        <input type="hidden" name="csrf_token" value="__csrf__" />
+        <label class="bx-analytics-field bx-analytics-field-grow">
+            <span class="bx-analytics-control-label">__label_title__</span>
+            <input type="text" name="title" maxlength="100" required class="bx-def-font-inputs bx-form-input-text" aria-describedby="bx-analytics-dash-hint bx-analytics-dash-warning" />
+        </label>
+        <p class="bx-analytics-hint" id="bx-analytics-dash-hint">__create_hint__</p>
+        <p class="bx-analytics-notice" id="bx-analytics-dash-warning">__disable_warning__</p>
+        <p class="bx-analytics-form-error" role="alert" hidden></p>
+        <div class="bx-analytics-form-actions">
+            <button type="submit" class="bx-analytics-tool-btn bx-analytics-tool-btn-primary">__create__</button>
+        </div>
+    </form>
+</div>

+ 8 - 0
modules/boonex/analytics/updates/15.0.1_15.0.2/source/template/studio_menu.html

@@ -0,0 +1,8 @@
+<bx_repeat:sections>
+    <div class="bx-analytics-std-menu-section bx-analytics-std-menu-__name__" __attrs__>
+        <bx_if:title>
+            <div class="bx-analytics-std-menu-title" id="bx-analytics-std-menu-__name__">__title__</div>
+        </bx_if:title>
+        __menu__
+    </div>
+</bx_repeat:sections>

+ 10 - 0
modules/boonex/analytics/updates/15.0.1_15.0.2/source/template/studio_section.html

@@ -0,0 +1,10 @@
+<div class="bx-analytics-studio">
+    <bx_repeat:cards>
+        <section class="bx-analytics-studio-card __class__">
+            <bx_if:title>
+                <h2 class="bx-analytics-studio-title">__title__</h2>
+            </bx_if:title>
+            __content__
+        </section>
+    </bx_repeat:cards>
+</div>

+ 60 - 0
modules/boonex/analytics/updates/15.0.1_15.0.2/source/template/studio_setup.html

@@ -0,0 +1,60 @@
+<div class="bx-analytics bx-analytics-tool bx-analytics-setup">
+    <bx_if:tracking_off>
+        <p class="bx-analytics-notice">__tracking_off__ <a class="bx-analytics-tool-link" href="__settings_url__">__settings__</a></p>
+    </bx_if:tracking_off>
+    <h3 class="bx-analytics-subtitle bx-analytics-subtitle-first">__status_title__</h3>
+    <div data-bx-analytics-setup-status>__status__</div>
+
+    <h3 class="bx-analytics-subtitle">__check_title__</h3>
+    <p class="bx-analytics-tool-intro">__check_hint__</p>
+    <div class="bx-analytics-form-actions">
+        <button type="button" class="bx-analytics-tool-btn bx-analytics-tool-btn-primary" data-bx-analytics-setup="check" data-bx-analytics-csrf="__csrf__">__check__</button>
+        <button type="button" class="bx-analytics-tool-btn" data-bx-analytics-setup="test_event" data-bx-analytics-csrf="__csrf__" aria-describedby="bx-analytics-setup-test-warning">__test__</button>
+    </div>
+    <p class="bx-analytics-note" id="bx-analytics-setup-test-warning">__test_warning__</p>
+    <div class="bx-analytics-setup-result" data-bx-analytics-setup-result role="status"></div>
+
+    <h3 class="bx-analytics-subtitle">__install_title__</h3>
+    <div class="bx-analytics-setup-clients">
+        <section class="bx-analytics-setup-client">
+            <h4 class="bx-analytics-setup-client-title">__core_web_title__</h4>
+            <p>__core_web_text__</p>
+            <p class="bx-analytics-state bx-analytics-state-__injection_state__">__injection_icon__<span>__injection__</span></p>
+            <p class="bx-analytics-hint">__core_web_exclusions__</p>
+        </section>
+        <section class="bx-analytics-setup-client">
+            <h4 class="bx-analytics-setup-client-title">__neo_title__</h4>
+            <p>__neo_text__</p>
+            <ul class="bx-analytics-checklist">
+                <bx_repeat:neo_checks>
+                    <li class="bx-analytics-state bx-analytics-state-__check_state__">__check_icon__<span>__check_text__</span></li>
+                </bx_repeat:neo_checks>
+            </ul>
+        </section>
+        <section class="bx-analytics-setup-client">
+            <h4 class="bx-analytics-setup-client-title">__api_title__</h4>
+            <p>__api_text__</p>
+            __code_curl__
+        </section>
+        <section class="bx-analytics-setup-client">
+            <h4 class="bx-analytics-setup-client-title">__core_title__</h4>
+            <p>__core_text__</p>
+            <p class="bx-analytics-state bx-analytics-state-__actions_state__">__actions_icon__<span>__actions__</span></p>
+            <p class="bx-analytics-control-label">__alerts_label__</p>
+            <ul class="bx-analytics-tags">
+                <bx_repeat:alerts>
+                    <li><code>__alert__</code></li>
+                </bx_repeat:alerts>
+            </ul>
+        </section>
+    </div>
+
+    <h3 class="bx-analytics-subtitle">__events_title__</h3>
+    <p class="bx-analytics-tool-intro">__events_hint__</p>
+    <div class="bx-analytics-setup-events">
+        __code_classic__
+        __code_neo__
+        __code_server__
+    </div>
+    <p class="bx-analytics-note">__events_warning__</p>
+</div>

+ 36 - 0
modules/boonex/analytics/updates/15.0.1_15.0.2/source/template/studio_traffic.html

@@ -0,0 +1,36 @@
+<div class="bx-analytics-std">
+    <div class="bx-analytics-online" title="__online_title__">
+        <span class="bx-analytics-online-dot" aria-hidden="true"></span>
+        <span class="bx-analytics-online-count">__online__</span>
+        <span class="bx-analytics-std-online-title">__online_title__</span>
+    </div>
+    <div class="bx-analytics-std-kpis">
+        <bx_repeat:kpis>
+            <div class="bx-analytics-std-kpi">
+                <span class="bx-analytics-kpi-title">__title__</span>
+                <span class="bx-analytics-kpi-figures">
+                    <span class="bx-analytics-kpi-value">__value__</span>
+                    <span class="bx-analytics-kpi-change bx-analytics-trend-__trend__">__change__</span>
+                </span>
+            </div>
+        </bx_repeat:kpis>
+    </div>
+    <svg class="bx-analytics-std-spark" viewBox="0 0 100 40" preserveAspectRatio="none" role="img" aria-label="__chart_label__">
+        <polyline points="__points__" vector-effect="non-scaling-stroke" />
+    </svg>
+    <bx_if:platforms>
+        <ul class="bx-analytics-rows bx-analytics-std-platforms">
+            <bx_repeat:platforms>
+                <li class="bx-analytics-row bx-analytics-std-platform">
+                    <span class="bx-analytics-row-bar" style="width: __bar__%" aria-hidden="true"></span>
+                    <span class="bx-analytics-row-label">__label__</span>
+                    <span class="bx-analytics-row-value">__share__%</span>
+                </li>
+            </bx_repeat:platforms>
+        </ul>
+    </bx_if:platforms>
+    <bx_if:empty>
+        <div class="bx-analytics-empty">__empty__</div>
+    </bx_if:empty>
+    <a class="bx-analytics-std-link" href="__link__">__link_title__</a>
+</div>

+ 21 - 0
modules/boonex/analytics/updates/15.0.1_15.0.2/source/template/subscriptions.html

@@ -0,0 +1,21 @@
+__tiles__
+<div class="bx-analytics-chart bx-analytics-chart-sm">
+    <canvas role="img" aria-label="__chart_label__"></canvas>
+</div>
+<script type="application/json" class="bx-analytics-chart-data">__chart_json__</script>
+<h3 class="bx-analytics-subtitle">__movement_title__</h3>
+<div class="bx-analytics-chart bx-analytics-chart-sm">
+    <canvas role="img" aria-label="__movement_label__"></canvas>
+</div>
+<script type="application/json" class="bx-analytics-chart-data">__movement_json__</script>
+<div class="bx-analytics-columns">
+    <div>
+        <h3 class="bx-analytics-subtitle">__plans_title__</h3>
+        __plans__
+    </div>
+    <div>
+        <h3 class="bx-analytics-subtitle">__reasons_title__</h3>
+        __reasons__
+    </div>
+</div>
+__notes__

+ 57 - 0
modules/boonex/analytics/updates/15.0.1_15.0.2/source/template/table.html

@@ -0,0 +1,57 @@
+__tabs__
+<div class="bx-analytics-panel" __panel_attrs__>
+    <bx_if:rows>
+        <div class="bx-analytics-table-wrap" role="region" aria-label="__caption__" tabindex="0">
+            <table class="bx-analytics-table">
+                <thead>
+                    <tr>
+                        <bx_repeat:columns>
+                            <th scope="col" class="bx-analytics-col-__type__" __sort__>__title__</th>
+                        </bx_repeat:columns>
+                    </tr>
+                </thead>
+                <tbody>
+                    <bx_repeat:rows>
+                        <tr>
+                            <bx_repeat:cells>
+                                <td class="bx-analytics-col-__type__">
+                                    <bx_if:label>
+                                        <div class="bx-analytics-cell-label">
+                                            <span class="bx-analytics-row-bar" style="width: __bar__%" aria-hidden="true"></span>
+                                            <bx_if:filter>
+                                                <button type="button" class="bx-analytics-cell-btn" data-bx-analytics-filter="__filter_dim__" data-bx-analytics-value="__filter_value__" title="__filter_title__">__label__</button>
+                                            </bx_if:filter>
+                                            <bx_if:link>
+                                                <a class="bx-analytics-cell-link" href="__link_href__">__label__</a>
+                                            </bx_if:link>
+                                            <bx_if:open>
+                                                <button type="button" class="bx-analytics-cell-btn" data-bx-analytics-open="__open_param__" data-bx-analytics-value="__open_value__">__label__</button>
+                                            </bx_if:open>
+                                            <bx_if:text>
+                                                <span class="bx-analytics-cell-text">__label__</span>
+                                            </bx_if:text>
+                                            <bx_if:sub>
+                                                <span class="bx-analytics-cell-sub">__sub__</span>
+                                            </bx_if:sub>
+                                        </div>
+                                    </bx_if:label>
+                                    <bx_if:value>__cell_value__</bx_if:value>
+                                </td>
+                            </bx_repeat:cells>
+                        </tr>
+                    </bx_repeat:rows>
+                </tbody>
+            </table>
+        </div>
+    </bx_if:rows>
+    <bx_if:empty>
+        <div class="bx-analytics-empty">__empty__</div>
+    </bx_if:empty>
+    <bx_if:pager>
+        <div class="bx-analytics-pager">
+            <button type="button" class="bx-analytics-pager-btn" data-bx-analytics-page="__prev__" data-bx-analytics-pager="__name__" __prev_disabled__>__txt_prev__</button>
+            <span class="bx-analytics-pager-info">__txt_page__</span>
+            <button type="button" class="bx-analytics-pager-btn" data-bx-analytics-page="__next__" data-bx-analytics-pager="__name__" __next_disabled__>__txt_next__</button>
+        </div>
+    </bx_if:pager>
+</div>

+ 5 - 0
modules/boonex/analytics/updates/15.0.1_15.0.2/source/template/tabs.html

@@ -0,0 +1,5 @@
+<div class="bx-analytics-tabs" role="tablist" aria-label="__label__">
+    <bx_repeat:tabs>
+        <button type="button" role="tab" class="bx-analytics-tab" id="bx-analytics-tab-__group__-__name__" aria-selected="__selected__" aria-controls="bx-analytics-panel-__group__" tabindex="__tabindex__" data-bx-analytics-tab="__name__">__title__</button>
+    </bx_repeat:tabs>
+</div>

+ 16 - 0
modules/boonex/analytics/updates/15.0.1_15.0.2/source/template/tiles.html

@@ -0,0 +1,16 @@
+<div class="bx-analytics-tiles">
+    <bx_repeat:tiles>
+        <div class="bx-analytics-tile">
+            <span class="bx-analytics-kpi-title">__title__</span>
+            <span class="bx-analytics-kpi-figures">
+                <span class="bx-analytics-kpi-value">__value__</span>
+                <bx_if:change>
+                    <span class="bx-analytics-kpi-change bx-analytics-trend-__trend__">__change__</span>
+                </bx_if:change>
+            </span>
+            <bx_if:sub>
+                <span class="bx-analytics-tile-sub">__sub__</span>
+            </bx_if:sub>
+        </div>
+    </bx_repeat:tiles>
+</div>

+ 23 - 0
modules/boonex/analytics/updates/15.0.1_15.0.2/source/template/top.html

@@ -0,0 +1,23 @@
+__tabs__
+<div class="bx-analytics-panel" role="tabpanel" id="bx-analytics-panel-__group__">
+    <div class="bx-analytics-list-head" aria-hidden="true">
+        <span>__column__</span>
+        <span>__visitors__</span>
+    </div>
+    <bx_if:items>
+        <ul class="bx-analytics-rows">
+            <bx_repeat:items>
+                <li class="bx-analytics-row">
+                    <button type="button" class="bx-analytics-row-btn" data-bx-analytics-filter="__dimension__" data-bx-analytics-value="__value__" title="__filter_title__">
+                        <span class="bx-analytics-row-bar" style="width: __bar__%" aria-hidden="true"></span>
+                        <span class="bx-analytics-row-label">__label__</span>
+                        <span class="bx-analytics-row-value">__visitors__ <span class="bx-analytics-row-share">__share__%</span></span>
+                    </button>
+                </li>
+            </bx_repeat:items>
+        </ul>
+    </bx_if:items>
+    <bx_if:empty>
+        <div class="bx-analytics-empty">__empty__</div>
+    </bx_if:empty>
+</div>

+ 1 - 0
modules/boonex/analytics/updates/15.0.1_15.0.2/source/template/tracker.html

@@ -0,0 +1 @@
+<script src="__src__" defer data-bx-analytics-endpoint="__endpoint__" data-bx-analytics-platform="__platform__" data-bx-analytics-identity="__identity__" data-bx-analytics-dnt="__dnt__" data-bx-analytics-errors="__errors__" data-bx-analytics-vitals="__vitals__"></script>

Alguns arquivos não foram mostrados porque muitos arquivos mudaram nesse diff