RomanL 8 лет назад
Родитель
Сommit
8a566e793d
24 измененных файлов с 3010 добавлено и 166 удалено
  1. 29 0
      modules/boonex/massmailer/classes/BxMassMailerAlertsResponse.php
  2. 17 4
      modules/boonex/massmailer/classes/BxMassMailerConfig.php
  3. 127 9
      modules/boonex/massmailer/classes/BxMassMailerDb.php
  4. 2 3
      modules/boonex/massmailer/classes/BxMassMailerFormEntry.php
  5. 1 0
      modules/boonex/massmailer/classes/BxMassMailerFormsEntryHelper.php
  6. 3 3
      modules/boonex/massmailer/classes/BxMassMailerGridCampaigns.php
  7. 434 54
      modules/boonex/massmailer/classes/BxMassMailerModule.php
  8. 123 5
      modules/boonex/massmailer/classes/BxMassMailerTemplate.php
  9. 53 11
      modules/boonex/massmailer/install/langs/en.xml
  10. 4 5
      modules/boonex/massmailer/install/sql/disable.sql
  11. 29 64
      modules/boonex/massmailer/install/sql/enable.sql
  12. 72 2
      modules/boonex/massmailer/install/sql/install.sql
  13. 8 2
      modules/boonex/massmailer/install/sql/uninstall.sql
  14. 90 0
      modules/boonex/massmailer/js/chart.js
  15. 0 0
      modules/boonex/massmailer/plugins/datatables/datatables.min.css
  16. 392 0
      modules/boonex/massmailer/plugins/daterangepicker/daterangepicker.css
  17. 1525 0
      modules/boonex/massmailer/plugins/daterangepicker/daterangepicker.js
  18. 3 0
      modules/boonex/massmailer/template/attributes.html
  19. 1 0
      modules/boonex/massmailer/template/attributes_part.html
  20. 1 0
      modules/boonex/massmailer/template/breadcrumb.html
  21. 25 0
      modules/boonex/massmailer/template/campaign_clicks.html
  22. 20 0
      modules/boonex/massmailer/template/campaign_info.html
  23. 5 3
      modules/boonex/massmailer/template/campaign_subscribers.html
  24. 46 1
      modules/boonex/massmailer/template/css/main.css

+ 29 - 0
modules/boonex/massmailer/classes/BxMassMailerAlertsResponse.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    Mass mailer
+ * @ingroup     UnaModules
+ *
+ * @{
+ */
+
+class BxMassMailerAlertsResponse extends BxDolAlertsResponse
+{
+    public function __construct()
+    {
+        parent::__construct();
+    }
+    
+    public function response($oAlert)
+    {
+        if ($oAlert->sUnit == 'account' && $oAlert->sAction == 'change_receive_news'){
+            $oModule = BxDolModule::getInstance('bx_massmailer');
+            if ($oAlert->aExtras['account_id'] != '' && $oAlert->aExtras['old_value'] != $oAlert->aExtras['new_value'])
+                $oModule->_oDb->updateUnsubscribe($oAlert->aExtras['account_id'], $oAlert->aExtras['new_value']);
+        }
+    }
+}
+
+/** @} */

+ 17 - 4
modules/boonex/massmailer/classes/BxMassMailerConfig.php

@@ -15,10 +15,15 @@ class BxMassMailerConfig extends BxBaseModGeneralConfig
     {
         parent::__construct($aModule);
         $this->CNF = array (
+             // module icon
+            'ICON' => 'far envelope col-red2',
+            
             // database tables
             'TABLE_CAMPAIGNS' => $aModule['db_prefix'] . 'campaigns',
             'TABLE_SEGMENTS' => $aModule['db_prefix'] . 'segments',
             'TABLE_LETTERS' => $aModule['db_prefix'] . 'letters',
+            'TABLE_LINKS' => $aModule['db_prefix'] . 'links',
+            'TABLE_UNSUBSCRIBE' => $aModule['db_prefix'] . 'unsubscribe',
             
              // database fields
             'FIELD_ID' => 'id',
@@ -30,12 +35,18 @@ class BxMassMailerConfig extends BxBaseModGeneralConfig
             'FIELD_BODY' => 'body',
             'FIELD_EMAIL_LIST' => 'email_list',
             'FIELD_SEGMENTS' => 'segments',
-            'FIELD_DATE_CREATED' => 'date_created',
+            'FIELD_ADDED' => 'added',
+            'FIELD_CHANGED' => 'changed',
             'FIELD_DATE_SENT' => 'date_sent',
             'FIELD_CAMPAIGN_ID' => 'campaign_id',
             'FIELD_EMAIL' => 'email',
-            'FIELD_CODE' => 'code',
+            'FIELD_HASH' => 'hash',
             'FIELD_DATE_SEEN' => 'date_seen',
+            'FIELD_DATE_CLICK' => 'date_click',
+            'FIELD_LETTER_HASH' => 'letter_hash',
+            'FIELD_LINK' => 'link',
+            'FIELD_DATE_UNSUBSCRIBED' => 'unsubscribed',
+            'FIELD_ACCOUNT_ID' => 'account_id',
             
             // page URIs
             'URI_MANAGE_COMMON' => 'massmailer-campaigns',
@@ -56,11 +67,13 @@ class BxMassMailerConfig extends BxBaseModGeneralConfig
         );
         
         $this->_aJsClasses = array(
-            'manage_tools' => 'BxMassMailerManageTools'
+            'manage_tools' => 'BxMassMailerManageTools',
+            'chart' => 'BxMassMailerChart'
         );
 
         $this->_aJsObjects = array(
-            'manage_tools' => 'oBxMassMailerManageTools'
+            'manage_tools' => 'oBxMassMailerManageTools',
+            'chart' => 'oBxMassMailerChart',
         );
 
         $this->_aGridObjects = array(

+ 127 - 9
modules/boonex/massmailer/classes/BxMassMailerDb.php

@@ -23,10 +23,10 @@ class BxMassMailerDb extends BxBaseModGeneralDb
             'campaign_id' => $iCampaignId,
             'date_created' => time()
         );
-        $this->query("INSERT INTO `" . $CNF['TABLE_CAMPAIGNS'] . "` (`" . $CNF['FIELD_AUTHOR'] . "`, `" . $CNF['FIELD_TITLE'] . "`, `" . $CNF['FIELD_BODY'] . "`, `" . $CNF['FIELD_SEGMENTS'] . "`, `" . $CNF['FIELD_DATE_CREATED'] . "`, `" . $CNF['FIELD_SUBJECT'] . "`, `" . $CNF['FIELD_FROM_NAME'] . "`, `" . $CNF['FIELD_REPLY_TO'] . "`) SELECT `" . $CNF['FIELD_AUTHOR'] . "`, CONCAT(`" . $CNF['FIELD_TITLE'] . "`, '" . _t('_bx_massmailer_txt_copy_title') . "'), `" . $CNF['FIELD_BODY'] . "`, `" . $CNF['FIELD_SEGMENTS'] . "`, :date_created, `" . $CNF['FIELD_SUBJECT'] . "`, `" . $CNF['FIELD_FROM_NAME'] . "`, `" . $CNF['FIELD_REPLY_TO'] . "`  FROM `" . $CNF['TABLE_CAMPAIGNS'] . "` WHERE `" . $CNF['FIELD_ID'] . "` = :campaign_id", $aBindings);
+        $this->query("INSERT INTO `" . $CNF['TABLE_CAMPAIGNS'] . "` (`" . $CNF['FIELD_AUTHOR'] . "`, `" . $CNF['FIELD_TITLE'] . "`, `" . $CNF['FIELD_BODY'] . "`, `" . $CNF['FIELD_SEGMENTS'] . "`, `" . $CNF['FIELD_ADDED'] . "`, `" . $CNF['FIELD_SUBJECT'] . "`, `" . $CNF['FIELD_FROM_NAME'] . "`, `" . $CNF['FIELD_REPLY_TO'] . "`) SELECT `" . $CNF['FIELD_AUTHOR'] . "`, CONCAT(`" . $CNF['FIELD_TITLE'] . "`, '" . _t('_bx_massmailer_txt_copy_title') . "'), `" . $CNF['FIELD_BODY'] . "`, `" . $CNF['FIELD_SEGMENTS'] . "`, :date_created, `" . $CNF['FIELD_SUBJECT'] . "`, `" . $CNF['FIELD_FROM_NAME'] . "`, `" . $CNF['FIELD_REPLY_TO'] . "`  FROM `" . $CNF['TABLE_CAMPAIGNS'] . "` WHERE `" . $CNF['FIELD_ID'] . "` = :campaign_id", $aBindings);
         return $this->lastId();
     }
-    
+        
     public function sendCampaign($iCampaignId, $sEmailList)
     {
         $CNF = &$this->_oConfig->CNF;
@@ -61,11 +61,51 @@ class BxMassMailerDb extends BxBaseModGeneralDb
     
     public function getAccountsByTerms($sTerms = "")
     {
-        $sSql = "SELECT `ta`.* FROM  `sys_accounts` `ta` INNER JOIN `sys_profiles` AS `tp`  ON `tp`.`account_id`=`ta`.`id` " . $sTerms . " WHERE `ta`.`receive_news` <> 0";
+        $sSql = "SELECT `ta`.`email`, `tp`.`id` AS `profile_id` FROM `sys_accounts` `ta` INNER JOIN `sys_profiles` AS `tp`  ON `tp`.`account_id`=`ta`.`id` " . $sTerms . " WHERE `ta`.`receive_news` <> 0";
         return $this->getAll($sSql);
     }
     
-    public function addLetter($iCampaignId,$sEmail)
+    public function getAccountsByTermsStat($iDateFrom, $iDateTo, $sTerms = "")
+    {
+        $aBindings = array(
+           'datefrom' => $iDateFrom,
+           'dateto' => $iDateTo
+       );
+        $sSql = "SELECT DATE(FROM_UNIXTIME(`ta`.`added`))  AS `period`, YEAR(FROM_UNIXTIME(`ta`.`added`))  AS `year`,  COUNT(*) AS `count` FROM `sys_accounts` `ta` INNER JOIN `sys_profiles` AS `tp`  ON `tp`.`account_id`=`ta`.`id` " . $sTerms . " WHERE `ta`.`receive_news` <> 0 AND `ta`.`added` >= :datefrom AND `ta`.`added` <= :dateto GROUP BY `period`, `year` ORDER BY `year`, `period` ASC";
+        return $this->getAll($sSql, $aBindings);
+    }
+    
+    public function getAccountsByTermsStatUnsubscribe($iDateFrom, $iDateTo, $sTerms = "")
+    {
+        $CNF = &$this->_oConfig->CNF;
+        $aBindings = array(
+           'datefrom' => $iDateFrom,
+           'dateto' => $iDateTo
+       );
+        $sSql = "SELECT DATE(FROM_UNIXTIME(`tu`.`unsubscribed`))  AS `period`, YEAR(FROM_UNIXTIME(`tu`.`unsubscribed`))  AS `year`,  COUNT(*) AS `count` FROM `sys_accounts` `ta` INNER JOIN `" . $CNF['TABLE_UNSUBSCRIBE'] . "` AS `tu`  ON `tu`.`account_id`=`ta`.`id` INNER JOIN `sys_profiles` AS `tp`  ON `tp`.`account_id`=`ta`.`id` " . $sTerms . " WHERE `tu`.`unsubscribed` >= :datefrom AND `tu`.`unsubscribed` <= :dateto GROUP BY `period`, `year` ORDER BY `year`, `period` ASC";
+        return $this->getAll($sSql, $aBindings);
+    }
+    
+    public function getAccountsByTermsStatInitValue($iDateFrom, $sTerms)
+    {
+        $aBindings = array(
+            'datefrom' => $iDateFrom
+        );
+        $sQuery = "SELECT  COUNT(`ta`.`id`) AS `count` FROM `sys_accounts` `ta` INNER JOIN `sys_profiles` AS `tp`  ON `tp`.`account_id`=`ta`.`id` " . $sTerms . " WHERE `ta`.`added` < :datefrom ";
+        return $this->getOne($sQuery, $aBindings);
+    }
+    
+    public function getAccountsByTermsStatInitValueUnsubscribe($iDateFrom, $sTerms)
+    {
+        $CNF = &$this->_oConfig->CNF;
+        $aBindings = array(
+            'datefrom' => $iDateFrom
+        );
+        $sQuery = "SELECT  COUNT(`tu`.`id`) AS `count` FROM `sys_accounts` `ta` INNER JOIN `" . $CNF['TABLE_UNSUBSCRIBE'] . "` AS `tu`  ON `tu`.`account_id`=`ta`.`id` INNER JOIN `sys_profiles` AS `tp`  ON `tp`.`account_id`=`ta`.`id` " . $sTerms . " WHERE `tu`.`unsubscribed` < :datefrom ";
+        return $this->getOne($sQuery, $aBindings);
+    }
+    
+    public function addLetter($iCampaignId, $sEmail)
     {
         $iTime = time();
         $sCode = md5($iCampaignId . $sEmail . $iTime);
@@ -76,7 +116,7 @@ class BxMassMailerDb extends BxBaseModGeneralDb
             'date_sent' => $iTime,
             'code' => $sCode,
         );
-        $this->query("INSERT INTO `" . $CNF['TABLE_LETTERS'] . "` (`" . $CNF['FIELD_CAMPAIGN_ID'] . "`, `" . $CNF['FIELD_EMAIL'] . "`, `" . $CNF['FIELD_DATE_SENT'] . "`,  `" . $CNF['FIELD_CODE'] . "`) VALUES (:campaign_id, :email, :date_sent, :code)", $aBindings);
+        $this->query("INSERT INTO `" . $CNF['TABLE_LETTERS'] . "` (`" . $CNF['FIELD_CAMPAIGN_ID'] . "`, `" . $CNF['FIELD_EMAIL'] . "`, `" . $CNF['FIELD_DATE_SENT'] . "`,  `" . $CNF['FIELD_HASH'] . "`) VALUES (:campaign_id, :email, :date_sent, :code)", $aBindings);
         return $sCode;
     }
     
@@ -87,17 +127,18 @@ class BxMassMailerDb extends BxBaseModGeneralDb
             'code' => $sCode,
             'date_seen' => time()
         );
-        $this->query("UPDATE `" . $CNF['TABLE_LETTERS'] . "` SET `" . $CNF['FIELD_DATE_SEEN'] . "` = :date_seen WHERE `" . $CNF['FIELD_CODE'] . "` = :code", $aBindings);
-    } 
+        $this->query("UPDATE `" . $CNF['TABLE_LETTERS'] . "` SET `" . $CNF['FIELD_DATE_SEEN'] . "` = :date_seen WHERE `" . $CNF['FIELD_HASH'] . "` = :code", $aBindings);
+    }
     
-    public function deleteLettersForCampaign($iCampaignId)
+    public function deleteCampaignData($iCampaignId)
     {
         $CNF = &$this->_oConfig->CNF;
         $aBindings = array(
             'campaign_id' => $iCampaignId
         );
         $this->query("DELETE FROM `" . $CNF['TABLE_LETTERS'] . "` WHERE `" . $CNF['FIELD_CAMPAIGN_ID'] . "` = :campaign_id", $aBindings);
-    } 
+        $this->query("DELETE FROM `" . $CNF['TABLE_LINKS'] . "` WHERE `" . $CNF['FIELD_CAMPAIGN_ID'] . "` = :campaign_id", $aBindings);
+    }
     
     public function getLettersByCampaignId ($iCampaignId)
     {
@@ -105,6 +146,83 @@ class BxMassMailerDb extends BxBaseModGeneralDb
         $sQuery = $this->prepare ("SELECT * FROM `" . $CNF['TABLE_LETTERS'] . "` WHERE `" . $CNF['FIELD_CAMPAIGN_ID'] . "` = ?", $iCampaignId);
         return $this->getAll($sQuery);
     }
+    
+    public function getClicksByCampaignId ($iCampaignId)
+    {
+        $CNF = &$this->_oConfig->CNF;
+        $sQuery = $this->prepare ("SELECT `" . $CNF['FIELD_LINK'] . "`, `" . $CNF['FIELD_TITLE'] . "`, COUNT( `" . $CNF['FIELD_ID'] . "`) AS `click_count`, MAX( `" . $CNF['FIELD_DATE_CLICK'] . "`) AS `last_click`  FROM `" . $CNF['TABLE_LINKS'] . "` WHERE `" . $CNF['FIELD_CAMPAIGN_ID'] . "` = ? AND `" . $CNF['FIELD_DATE_CLICK'] . "` IS NOT NULL GROUP BY `" . $CNF['FIELD_LINK'] . "`, `" . $CNF['FIELD_TITLE'] . "` ORDER BY COUNT( `" . $CNF['FIELD_ID'] . "`)  DESC", $iCampaignId);
+        return $this->getAll($sQuery);
+    }
+    
+    public function getStatByCampaignId ($iCampaignId)
+    {
+        $CNF = &$this->_oConfig->CNF;
+         $aBindings = array(
+            'campaign_id' => $iCampaignId
+        );
+        $q = "
+        SELECT COUNT(*) AS `count`, 'total' AS `title` FROM `" . $CNF['TABLE_LETTERS'] . "` WHERE `" . $CNF['FIELD_CAMPAIGN_ID'] . "` = :campaign_id UNION
+        SELECT COUNT(*), 'seen' FROM `" . $CNF['TABLE_LETTERS'] . "`  WHERE `" . $CNF['FIELD_CAMPAIGN_ID'] . "` = :campaign_id AND `" . $CNF['FIELD_DATE_SEEN'] . "` > 0 UNION
+        SELECT COUNT(*), 'clicked' FROM `" . $CNF['TABLE_LETTERS'] . "` WHERE `" . $CNF['FIELD_CAMPAIGN_ID'] . "` = :campaign_id AND `" . $CNF['FIELD_DATE_CLICK'] . "` > 0";
+        return $this->getPairs($q, 'title', 'count', $aBindings);
+    }
+    
+    public function getLetterByCode ($sCode)
+    {
+        $CNF = &$this->_oConfig->CNF;
+        $sQuery = $this->prepare ("SELECT * FROM `" . $CNF['TABLE_LETTERS'] . "` WHERE `" . $CNF['FIELD_HASH'] . "` = ?", $sCode);
+        return $this->getRow($sQuery);
+    }
+    
+    public function addLink($sLetterHash, $sLink, $sTitle, $iCampaignId)
+    {
+        $CNF = &$this->_oConfig->CNF;
+        $sCode = md5($sLetterHash . $sLink . $sTitle);
+        $aBindings = array(
+            'letter_hash' => $sLetterHash,
+            'hash' => $sCode,
+            'link' => $sLink,
+            'title' => $sTitle,
+            'campaign_id' => $iCampaignId,
+        );
+        $this->query("INSERT INTO `" . $CNF['TABLE_LINKS'] . "` (`" . $CNF['FIELD_LETTER_HASH'] . "`, `" . $CNF['FIELD_HASH'] . "`, `" . $CNF['FIELD_LINK'] . "`,  `" . $CNF['FIELD_TITLE'] . "`,  `" . $CNF['FIELD_CAMPAIGN_ID'] . "`) VALUES (:letter_hash, :hash, :link, :title, :campaign_id)", $aBindings);
+        return $sCode;
+    }
+    
+    public function updateDateClickForLink($sCode)
+    {
+        $CNF = &$this->_oConfig->CNF;
+        $aBindings = array(
+            'code' => $sCode,
+            'date_click' => time()
+        );
+        $this->query("UPDATE `" . $CNF['TABLE_LINKS'] . "` SET `" . $CNF['FIELD_DATE_CLICK'] . "` = :date_click WHERE `" . $CNF['FIELD_HASH'] . "` = :code", $aBindings);
+        $aData = $this->getRow("SELECT * FROM `" . $CNF['TABLE_LINKS'] . "` WHERE `" . $CNF['FIELD_HASH'] . "` = :code", array('code' => $sCode));
+        $aBindings['code'] = $aData[$CNF['FIELD_LETTER_HASH']];
+        if ($aData[$CNF['FIELD_LETTER_HASH']] != '')
+            $this->query("UPDATE `" . $CNF['TABLE_LETTERS'] . "` SET `" . $CNF['FIELD_DATE_CLICK'] . "` = :date_click WHERE `" . $CNF['FIELD_HASH'] . "` = :code", $aBindings);
+        
+        return $aData[$CNF['FIELD_LINK']];
+    }
+    
+    
+    public function updateUnsubscribe($iAccountId, $iValue)
+    {
+        $CNF = &$this->_oConfig->CNF;
+        if ($iValue != 1){
+            $aBindings = array(
+                'account_id' => $iAccountId,
+                'date_unsubscribed' => time()
+            );
+            $this->query("INSERT INTO `" . $CNF['TABLE_UNSUBSCRIBE'] . "` (`" . $CNF['FIELD_DATE_UNSUBSCRIBED'] . "`, `" . $CNF['FIELD_ACCOUNT_ID'] . "`) VALUES (:date_unsubscribed, :account_id)", $aBindings);
+        }
+        else{
+            $aBindings = array(
+                'account_id' => $iAccountId
+            );
+            $this->query("DELETE FROM `" . $CNF['TABLE_UNSUBSCRIBE'] . "` WHERE `" . $CNF['FIELD_ACCOUNT_ID'] . "` = :account_id", $aBindings);
+        }
+    }
 }
 
 /** @} */

+ 2 - 3
modules/boonex/massmailer/classes/BxMassMailerFormEntry.php

@@ -16,7 +16,7 @@ class BxMassMailerFormCheckerHelper extends BxDolFormCheckerHelper
         if (trim($s) == '')
             return true;
         
-        if (!$this->checkEmail($s))
+        if (!self::checkEmail($s))
             return false;
 
         return true;
@@ -26,7 +26,7 @@ class BxMassMailerFormCheckerHelper extends BxDolFormCheckerHelper
 /**
  * Create/Edit entry form
  */
-class BxMassMailerFormEntry extends BxTemplFormView
+class BxMassMailerFormEntry extends BxBaseModTextFormEntry
 {
     public function __construct($aInfo, $oTemplate = false)
     {
@@ -42,7 +42,6 @@ class BxMassMailerFormEntry extends BxTemplFormView
     public function insert ($aValsToAdd = array(), $isIgnore = false)
     {
         $CNF = &$this->_oModule->_oConfig->CNF;
-        $aValsToAdd[$CNF['FIELD_DATE_CREATED']] = time();
         $aValsToAdd[$CNF['FIELD_AUTHOR']] = bx_get_logged_profile_id();
         return parent::insert ($aValsToAdd, $isIgnore);
     }

+ 1 - 0
modules/boonex/massmailer/classes/BxMassMailerFormsEntryHelper.php

@@ -30,6 +30,7 @@ class BxMassMailerFormsEntryHelper extends BxBaseModTextFormsEntryHelper
     {
         $CNF = &$this->_oModule->_oConfig->CNF;
         $this->_redirectAndExit('page.php?i=' . $CNF['URI_MANAGE_CAMPAIGNS']);
+        return parent::onDataEditAfter($iContentId, $aContentInfo, $aTrackTextFieldsChanges, $oProfile, $oForm);
     }
     
     public function onDataAddAfter($iAccountId, $iContentId)

+ 3 - 3
modules/boonex/massmailer/classes/BxMassMailerGridCampaigns.php

@@ -22,7 +22,7 @@ class BxMassMailerGridCampaigns extends BxTemplGrid
     {
         $aIds = bx_get('ids');
         foreach ($aIds as $iId){
-            $this->_oModule->_oDb->deleteLettersForCampaign($iId);
+            $this->_oModule->_oDb->deleteCampaignData($iId);
         }
         parent::performActionDelete();
     }
@@ -68,7 +68,7 @@ class BxMassMailerGridCampaigns extends BxTemplGrid
             return '';
         $oForm->aFormAttrs['action'] = BX_DOL_URL_ROOT . 'grid.php?' . bx_encode_url_params($_GET, array('_r'));
         $oForm->initChecker(array(), array());
-        $oForm->aInputs['campaign_info']['value'] =  _t($oForm->aInputs['campaign_info']['value'] , $this->_oModule->getEmailCountInSegment($iId));
+        $oForm->aInputs['campaign_info']['value'] = _t($oForm->aInputs['campaign_info']['value'], $this->_oModule->getEmailCountInSegment($iId));
         if($oForm->isSubmittedAndValid()) {
             $mixedResult = $this->_oModule->sendAll($iId);
             if($mixedResult !== false)
@@ -191,7 +191,7 @@ class BxMassMailerGridCampaigns extends BxTemplGrid
         if (!$oProfile)
             $oProfile = BxDolProfileUndefined::getInstance();
         return parent::_getCellDefault($oProfile->getDisplayName(), $sKey, $aField, $aRow);
-    }    
+    }
 }
 
 /** @} */

+ 434 - 54
modules/boonex/massmailer/classes/BxMassMailerModule.php

@@ -1,72 +1,307 @@
 <?php defined('BX_DOL') or die('hack attempt');
 /**
- * Copyright (c) UNA, Inc - https://una.io
- * MIT License - https://opensource.org/licenses/MIT
- *
- * @defgroup    Mass mailer
- * @ingroup     UnaModules
- *
- * @{
- */
+* Copyright (c) UNA, Inc - https://una.io
+* MIT License - https://opensource.org/licenses/MIT
+*
+* @defgroup    Mass mailer
+* @ingroup     UnaModules
+*
+* @{
+*/
 
 class BxMassMailerModule extends BxBaseModGeneralModule
 {
     private $aSegments = array();
-    
+          
     function __construct(&$aModule)
     {
         parent::__construct($aModule);
         $CNF = &$this->_oConfig->CNF;
         $aRv = array('lvl:0' => _t('_bx_massmailer_segments_all'));
         $oAccountQuery = BxDolAclQuery::getInstance();
-        
+              
         $aTmp = array();
         $oAccountQuery->getLevels(array('type' => 'all_active_pair'), $aTmp);
         foreach ($aTmp as $sKey => $sTmp){
             if ($sKey != MEMBERSHIP_ID_NON_MEMBER && $sKey != MEMBERSHIP_ID_ACCOUNT)
                 $aRv['lvl:' . $sKey] = _t($sTmp);
         };
-        
+              
         $aTmp = $this->_oDb->getSegments();
         foreach ($aTmp as $sKey => $sTmp){
             $aRv['cst:' . $sKey] = _t($sTmp);
         };
         $this->aSegments = $aRv;
     }
+          
+    /**
+    * ACTION METHODS
+    */
+    public function actionTrack($sActionName, $sHash)
+    {
+        if (isset($sHash) && trim($sHash) != ""){
+            switch ($sActionName) {
+                case 'click':
+                    $sLink = $this->_oDb->updateDateClickForLink($sHash);
+                    if (substr_count($sLink, '://') == 0)
+                        $sLink = BX_DOL_URL_ROOT . $sLink;
+                    header('Location: ' . $sLink);
+                    break;
+                    
+                case 'seen':
+                    header('Content-Type: image/png');
+                    $this->_oDb->updateDateSeenForLetter($sHash);
+                    break;
+            }
+        }
+    }
     
+    public function actionGetReportsData($sReportName, $sReportType, $sDateFrom, $sDateTo, $sSegment)
+    {
+        if(!BxDolAcl::getInstance()->isMemberLevelInSet(128))
+            return '';
+        
+        header('Content-Type: application/json');
+        
+        $aColors = array('#3366CC', '#DC3912', '#FF9900', '#109618', '#990099');
+        $iDateFrom = strtotime($sDateFrom);
+        $iDateTo = strtotime($sDateTo);
+        $sType = "bar";
+        $bIsTimeX = false;
+        $iMinValueY = 0;
+        $iMaxValueY = 0;
+        $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')));
+        
+        switch ($sReportName) {
+            case "SUBSCRIBERS_INFO":
+                $sType = "line";
+                $bIsTimeX = true;
+                $aValues['strings'][0] = _t('_bx_analytics_txt_date');
+                $aValues['strings'][1] = 'ohz';
+                $sSql = $this->getSqlBySegment($sSegment);
+                $aData = $this->_oDb->getAccountsByTermsStat($iDateFrom, $iDateTo, $sSql);
+                $aData2 = $this->_oDb->getAccountsByTermsStatUnsubscribe($iDateFrom, $iDateTo, $sSql);
+                $iValuePrev = $this->_oDb->getAccountsByTermsStatInitValue($iDateFrom, $sSql);
+                $iValuePrev2 = $this->_oDb->getAccountsByTermsStatInitValueUnsubscribe($iDateFrom, $sSql);
+                $aTmpDates = array();
+                $aTmpDates2 = array();
+                $aValues['values'][0]['legend'] = _t('_bx_massmailer_txt_subscribed');
+                $aValues['values'][1] = array('legend' => _t('_bx_massmailer_txt_unsubscribed') , 'data' => array());
+                foreach ($aData as $aValue) {
+                    $aTmpDates[$aValue['period']] = $aValue['count'];
+                }
+                foreach ($aData2 as $aValue) {
+                    $aTmpDates2[$aValue['period']] = $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 == 'content_total' ? $iValuePrev : 0));
+                        
+                    }
+                    else{
+                        array_push($aValues['values'][0]['data'], array('x' => $sX, 'y' => $sReportType == 'content_total' ? $iValuePrev : $aTmpDates[$sX]));
+                        $iValuePrev += $aTmpDates[$sX];
+                    }
+                    
+                    if (!array_key_exists($sX, $aTmpDates2)){
+                        array_push($aValues['values'][1]['data'], array('x' => $sX, 'y' => $sReportType == 'content_total' ? $iValuePrev2 : 0));
+                        
+                    }
+                    else{
+                        array_push($aValues['values'][1]['data'], array('x' => $sX, 'y' => $sReportType == 'content_total' ? $iValuePrev2 : $aTmpDates2[$sX]));
+                        $iValuePrev2 += $aTmpDates2[$sX];
+                    }
+                    
+                    if ($sReportType == 'content_total'){
+                        if ($iValuePrev > $iMaxValueY)
+                            $iMaxValueY = $iValuePrev;
+                        if ($iValuePrev2 > $iMaxValueY)
+                            $iMaxValueY = $iValuePrev2;
+                    }
+                    else{
+                        if (array_key_exists($sX, $aTmpDates) && $aTmpDates[$sX] > $iMaxValueY)
+                            $iMaxValueY = $aTmpDates[$sX];
+                        if (array_key_exists($sX, $aTmpDates2) && $aTmpDates2[$sX] > $iMaxValueY)
+                            $iMaxValueY = $aTmpDates2[$sX];
+                    }
+                }
+                break;
+            
+            case "CAMPAIGN_REPORT":
+                $aDataStat = $this->_oDb->getStatByCampaignId($sReportType);
+                $aValues['values'][0]['legend'] = _t('_bx_massmailer_txt_sent_total_title', $aDataStat['total']);
+                array_push($aValues['values'][0]['data'], $aDataStat['total']);
+                $aValues['values'][1] = array('legend' => _t('_bx_massmailer_txt_opened_total_title', $aDataStat['seen']) , 'data' => array($aDataStat['seen']));
+                $aValues['values'][2] = array('legend' => _t('_bx_massmailer_txt_unopened_total_title', $aDataStat['total'] - $aDataStat['seen']), 'data' => array($aDataStat['total'] - $aDataStat['seen']));
+                $aValues['values'][3] = array('legend' => _t('_bx_massmailer_txt_clicked_total_title', $aDataStat['clicked']), 'data' => array($aDataStat['clicked']));
+                $iMaxValueY = $aDataStat['total'];
+                break;
+        }
+        
+        $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' => false
+                ),
+                'display' => true
+            );
+        }
+        
+        $aDataForChartDatasets = array();
+        for($i = 0; $i < count($aValues['values']); $i++){
+            $aDataForChartDatasets[] = array(
+                'label' => $aValues['values'][$i]['legend'],
+                'fill' => false,
+                'backgroundColor' => $aColors[$i],
+                'borderColor' => $aColors[$i],
+                'borderWidth' => 1,
+                '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
+                ),
+                'scales' => array(
+                    'yAxes' => array(
+                        array(
+                            'ticks' => array(
+                                'max' => $iMaxValueY,
+                                'min' => $iMinValueY,
+                                'stepSize' => $this->getReportStep($iMinValueY, $iMaxValueY),
+                                'autoSkip' => true
+                            )
+                        )
+                    ),
+                    'xAxes' => array($aDataForChartXAxes)
+                )
+            ),
+            'links' => $aValues['links'],
+            'strings' => $aValues['strings'],
+        );
+        
+        echo json_encode($aDataForChart);
+    }
+  
     /**
-     * ACTION METHODS
-     */
-    public function actionSetSeenMark($Code)
+    * SERVICE METHODS
+    */
+          
+    /**
+    * @page service Service Calls
+    * @section bx_massmailer Mass mailer
+    * @subsection bx_massmailer-page_blocks Page Blocks
+     * @subsubsection bx_massmailer-campagn_subscribers campagn_subscribers
+    * 
+     * @code bx_srv('bx_massmailer', 'campagn_subscribers', [...]); @endcode
+    * 
+    * Get page block with the campagn's subscribers list
+    *
+    * @param $aParams an array with search params.
+    * @return HTML string with block content to display on the site. All necessary CSS and JS files are automatically added to the HEAD section of the site HTML.
+    * 
+    * @see BxMassMailerModule::serviceCampagnSubscribers
+    */
+    /** 
+    * @ref bx_massmailer-campagn_subscribers "entity_view"
+    */
+    public function serviceCampagnSubscribers ($iContentId = 0, $sDisplay = false)
     {
-        //header('Content-Type: image/png');
-        if (isset($Code) && trim($Code) != "")
-            $this->_oDb->updateDateSeenForLetter($Code);
+        if(!$iContentId)
+            $iContentId = bx_process_input(bx_get('id'), BX_DATA_INT);
+
+        if(!$iContentId)
+            return false;
+              
+        $aData = $this->_oDb->getLettersByCampaignId($iContentId);
+        if (count($aData) > 0)
+            return $this->_oTemplate->getSubscribers($aData);
+        return $iContentId;
     }
     
     /**
-     * SERVICE METHODS
+     * @page service Service Calls
+     * @section bx_massmailer Mass mailer
+     * @subsection bx_massmailer-page_blocks Page Blocks
+     * @subsubsection bx_massmailer-campagn_info campagn_info
+     * 
+     * @code bx_srv('bx_massmailer', 'campagn_info', [...]); @endcode
+     * 
+     * Get page block with the campagn info
+     *
+     * @param $aParams an array with search params.
+     * @return HTML string with block content to display on the site. All necessary CSS and JS files are automatically added to the HEAD section of the site HTML.
+     * 
+     * @see BxMassMailerModule::serviceCampagnInfo
      */
+    /** 
+     * @ref bx_massmailer-campagn_info "campagn_info"
+     */
+    public function serviceCampagnInfo ($iContentId = 0, $sDisplay = false)
+    {
+        if(!$iContentId)
+            $iContentId = bx_process_input(bx_get('id'), BX_DATA_INT);
+
+        if(!$iContentId)
+            return false;
+        
+        return $this->_oTemplate->getInfo($iContentId);
+    }
     
     /**
      * @page service Service Calls
      * @section bx_massmailer Mass mailer
      * @subsection bx_massmailer-page_blocks Page Blocks
-     * @subsubsection bx_massmailer-entity_view entity_view
+     * @subsubsection bx_massmailer-campagn_links campagn_links
      * 
-     * @code bx_srv('bx_massmailer', 'get_results_search_extended', [...]); @endcode
+     * @code bx_srv('bx_massmailer', 'campagn_links', [...]); @endcode
      * 
-     * Get page block with the campagn info
+     * Get page block with the campagn links list
      *
      * @param $aParams an array with search params.
      * @return HTML string with block content to display on the site. All necessary CSS and JS files are automatically added to the HEAD section of the site HTML.
      * 
-     * @see BxMassMailerModule::serviceEntityView
+     * @see BxMassMailerModule::serviceCampagnLinks
      */
     /** 
-     * @ref bx_massmailer-entity_view "entity_view"
+     * @ref bx_massmailer-campagn_links "campagn_links"
      */
-    public function serviceEntityView ($iContentId = 0, $sDisplay = false)
+    public function serviceCampagnLinks ($iContentId = 0, $sDisplay = false)
     {
         if(!$iContentId)
             $iContentId = bx_process_input(bx_get('id'), BX_DATA_INT);
@@ -74,12 +309,110 @@ class BxMassMailerModule extends BxBaseModGeneralModule
         if(!$iContentId)
             return false;
         
-        $aData = $this->_oDb->getLettersByCampaignId($iContentId);
+        $aData = $this->_oDb->getClicksByCampaignId($iContentId);
         if (count($aData) > 0)
-            return $this->_oTemplate->getCampagnInfo($aData);
+            return $this->_oTemplate->getClicks($aData);
         return $iContentId;
     }
     
+    /**
+     * @page service Service Calls
+     * @section bx_massmailer Mass mailer
+     * @subsection bx_massmailer-page_blocks Page Blocks
+     * @subsubsection bx_massmailer-total_subscribers total_subscribers
+     * 
+     * @code bx_srv('bx_massmailer', 'total_subscribers', [...]); @endcode
+     * 
+     * Get page block with subscribers charts
+     *
+     * @param $aParams an array with search params.
+     * @return HTML string with block content to display on the site. All necessary CSS and JS files are automatically added to the HEAD section of the site HTML.
+     * 
+     * @see BxMassMailerModule::serviceTotalSubscribers
+     */
+    /** 
+     * @ref bx_massmailer-total_subscribers "total_subscribers"
+     */
+    public function serviceTotalSubscribers ()
+    {
+        return $this->_oTemplate->getTotalSubscribers();
+    }
+    
+    /**
+     * @page service Service Calls
+     * @section bx_massmailer Mass mailer
+     * @subsection bx_massmailer-page_blocks Page Blocks
+     * @subsubsection bx_massmailer-entity_breadcrumb entity_breadcrumb
+     * 
+     * @code bx_srv('bx_massmailer', 'entity_breadcrumb', [...]); @endcode
+     * 
+     * Get page block with page's breadcrumbs
+     *
+     * @param $aParams an array with search params.
+     * @return HTML string with block content to display on the site. All necessary CSS and JS files are automatically added to the HEAD section of the site HTML.
+     * 
+     * @see BxMassMailerModule::serviceEntityBreadcrumb
+     */
+    /** 
+     * @ref bx_massmailer-entity_breadcrumb "entity_breadcrumb"
+     */
+    public function serviceEntityBreadcrumb ($iContentId = 0)
+    {
+        $CNF = &$this->_oConfig->CNF;
+        $oPermalink = BxDolPermalinks::getInstance();
+        if (!$iContentId)
+            $iContentId = bx_process_input(bx_get('id'), BX_DATA_INT);
+        if (!$iContentId){
+            $aContentInfo[$CNF['FIELD_TITLE']] = _t('_bx_massmailer_txt_breadcrumb_title_new_campaign');
+            $aContentInfo['url'] = $oPermalink->permalink('page.php?i=' . $CNF['URI_ADD_CAMPAIGN']);
+        }
+        else{
+            $aContentInfo = $this->_oDb->getCampaignInfoById($iContentId);
+            if (!$aContentInfo)
+                return false;
+            $aContentInfo['url'] = $oPermalink->permalink('page.php?i=' . $CNF['URI_VIEW_CAMPAIGN'] . '&id=' . $aContentInfo[$CNF['FIELD_ID']]);
+        }
+        return $this->_oTemplate->entryBreadcrumb($aContentInfo);
+    }
+    
+    /**
+     * @page service Service Calls
+     * @section bx_massmailer Mass mailer
+     * @subsection bx_massmailer-page_blocks Page Blocks
+     * @subsubsection bx_massmailer-attributes attributes
+     * 
+     * @code bx_srv('bx_massmailer', 'attributes', [...]); @endcode
+     * 
+     * Get page block with attributes list
+     *
+     * @param $aParams an array with search params.
+     * @return HTML string with block content to display on the site. All necessary CSS and JS files are automatically added to the HEAD section of the site HTML.
+     * 
+     * @see BxMassMailerModule::serviceAttributes
+     */
+    /** 
+     * @ref bx_massmailer-attributes "attributes"
+     */
+    public function serviceAttributes ()
+    {
+        $aAttributesParts = array();
+        $aAttributesParts['account'] = array(_t('_bx_massmailer_txt_attribute_global'), array('email' => _t('_bx_massmailer_txt_attribute_email'), 'account_name' => _t('_bx_massmailer_txt_attribute_account_name'), 'account_id' => _t('_bx_massmailer_txt_attribute_account_id'), 'unsubscribe_url' => _t('_bx_massmailer_txt_attribute_unsubscribe_url')));
+        $BxDolModuleQuery = BxDolModuleQuery::getInstance();
+        $aModules = $BxDolModuleQuery->getModulesBy(array('type' => 'modules', 'active' => 1));
+        foreach($aModules as $aModule){
+            if(BxDolRequest::serviceExists($aModule['name'], 'act_as_profile') && BxDolService::call($aModule['name'], 'act_as_profile') == true){
+                $oModule = BxDolModule::getInstance($aModule['name']);
+                $aTmp2 = array();
+                $aTmp = $oModule->serviceGetSearchableFieldsExtended('view');
+                foreach($aTmp as $sKey => $aField){
+                    $aTmp2[$sKey] = _t($aField['caption']);
+                }
+                $aAttributesParts[$aModule['name']] = array($aModule['title'], $aTmp2);
+            }
+        }
+        return $this->_oTemplate->getAttributes($aAttributesParts);
+    }
+          
     public function getSegments($sKey = "")
     {
         if ($sKey == ""){
@@ -91,19 +424,19 @@ class BxMassMailerModule extends BxBaseModGeneralModule
             return false;
         }
     }
-    
+          
     public function sendTest($sEmail, $iCampaignId)
     {
         $aTmp = $this->getDataForCampaign($iCampaignId);
         $aTemplate = $aTmp[0];
         $aCustomHeaders = $aTmp[1];
-         
+              
         if (!$aTemplate)
             return false;
         
-        return sendMail($sEmail, $aTemplate['Subject'], $aTemplate['Body'], 0, array(), BX_EMAIL_SYSTEM, 'html', false, $aCustomHeaders, false);
+        return $this->sendLetter($sEmail, $iCampaignId, $aCustomHeaders, bx_get_logged_profile_id(), $aTemplate, false);
     }
-    
+          
     public function sendAll($iCampaignId)
     {
         $aEmails = array();
@@ -111,22 +444,39 @@ class BxMassMailerModule extends BxBaseModGeneralModule
         $aTemplate = $aTmp[0];
         $aCustomHeaders = $aTmp[1];
         $aCampaign = $aTmp[2];
-        
+              
         if (!$aTemplate)
             return false;
-       
+        $this->_oDb->deleteCampaignData($iCampaignId);
         $aAccounts = $this->getEmailsBySegment($aCampaign['segments']);
-        foreach ($aAccounts as $aAccount){
-            array_push($aEmails, $aAccount['email']);
-            $sCode = $this->_oDb->addLetter($iCampaignId, $aAccount['email']);
-            $aAccount['email'] = 'user251@roma1.ru';
-            sendMail($aAccount['email'], $aTemplate['Subject'], $aTemplate['Body'], 0, array('seen_image_url' => $this->getSeenImageUrl($sCode)), BX_EMAIL_MASS, 'html', false, $aCustomHeaders, true);
+        foreach ($aAccounts as $aAccountInfo){
+            array_push($aEmails, $aAccountInfo['email']);
+            $this->sendLetter($aAccountInfo['email'], $iCampaignId, $aCustomHeaders, $aAccountInfo['profile_id'], $aTemplate, true);
         }
-        
+              
         $this->_oDb->sendCampaign($iCampaignId, implode(',', $aEmails));
         return true;
     }
     
+    private function sendLetter($sEmail, $iCampaignId, $aCustomHeaders, $iProfileId, $aTemplate, $bAddToQueue)
+    {
+        $sLetterCode = $this->_oDb->addLetter($iCampaignId, $sEmail);
+       
+        $regexp = "<a\s[^>]*href=(\"??)([^\" >]*?)\\1[^>]*>(.*)<\/a>";
+        $aTemplate['Body'] = preg_replace_callback("/$regexp/siU",
+            function ($aMatch) use ($iCampaignId, $sLetterCode) {
+                if ($aMatch[2] != '{unsubscribe_url}'){
+                    $sLinkCode = $this->_oDb->addLink($sLetterCode, $aMatch[2], $aMatch[3], $iCampaignId);
+                    return str_replace($aMatch[2], BX_DOL_URL_ROOT . $this->_oConfig->getBaseUri() . 'track/click/' . $sLinkCode . "/", $aMatch[0]);
+                }
+                else{
+                    return $aMatch[0];
+                }
+            },
+            $aTemplate['Body']);
+            return sendMail($sEmail, $aTemplate['Subject'], $aTemplate['Body'], 0, $this->addMarkers($iProfileId, $sLetterCode), BX_EMAIL_MASS, 'html', false, $aCustomHeaders, $bAddToQueue);
+    }
+
     public function getEmailCountInSegment($iCampaignId)
     {
         $aTmp = $this->getDataForCampaign($iCampaignId);
@@ -134,55 +484,85 @@ class BxMassMailerModule extends BxBaseModGeneralModule
         $aAccounts = $this->getEmailsBySegment($aCampaign['segments']);
         return count($aAccounts);
     }
-    
+          
     private function getEmailsBySegment($sSegment)
     {
-        $aAccounts = array();
-        if (substr_count($sSegment, 'lvl:') > 0){ 
+        return $this->_oDb->getAccountsByTerms($this->getSqlBySegment($sSegment));
+    }
+    private function getSqlBySegment($sSegment)
+    {
+        $sRv = '';
+        if (substr_count($sSegment, 'lvl:') > 0){
             $sLvl = str_replace('lvl:', '', $sSegment);
             switch ($sLvl) {
                 case 0:
-                    $aAccounts = $this->_oDb->getAccountsByTerms();
+                    $sRv = '';
                     break;
                 case MEMBERSHIP_ID_UNCONFIRMED:
-                    $aAccounts = $this->_oDb->getAccountsByTerms(" AND `ta`.`email_confirmed` = 0 AND `ta`.`phone_confirmed` = 0 ");
+                    $sRv = " AND `ta`.`email_confirmed` = 0 AND `ta`.`phone_confirmed` = 0 ";
                     break;
                 case MEMBERSHIP_ID_SUSPENDED:
-                    $aAccounts = $this->_oDb->getAccountsByTerms($this->_oDb->prepareAsString(" AND `tp`.`status`=?", 'suspended'));
+                    $sRv = $this->_oDb->prepareAsString(" AND `tp`.`status`=?", 'suspended');
                     break;
                 case MEMBERSHIP_ID_PENDING:
-                    $aAccounts = $this->_oDb->getAccountsByTerms($this->_oDb->prepareAsString(" AND `tp`.`status`=?", 'pending'));
+                    $sRv = $this->_oDb->prepareAsString(" AND `tp`.`status`=?", 'pending');
                     break;
                 case MEMBERSHIP_ID_STANDARD:
-                    $aAccounts = $this->_oDb->getAccountsByTerms($this->_oDb->prepareAsString("  AND `tp`.`id` NOT IN (SELECT `IDMember` FROM `sys_acl_levels_members`) ", $sLvl));
+                    $sRv = "  AND `tp`.`id` NOT IN (SELECT `IDMember` FROM `sys_acl_levels_members`) ";
                     break;
                 default:
-                    $aAccounts = $this->_oDb->getAccountsByTerms($this->_oDb->prepareAsString(" AND `tp`.`id` IN (SELECT `IDMember` FROM `sys_acl_levels_members` WHERE IDLevel = ?) ", $sLvl));
+                    $sRv = $this->_oDb->prepareAsString(" AND `tp`.`id` IN (SELECT `IDMember` FROM `sys_acl_levels_members` WHERE IDLevel = ?) ", $sLvl);
                     break;
             }
         }
-        return $aAccounts;
+        return $sRv;
     }
-    
+          
     private function getDataForCampaign($iCampaignId)
     {
         $aCampaign = $this->_oDb->getCampaignInfoById($iCampaignId);
         $oEmailTemplates = BxDolEmailTemplates::getInstance();
         $aTemplate = $oEmailTemplates->parseTemplate('bx_massmailer_email', array('content' => $aCampaign['body']));
         $aTemplate['Subject'] = $aCampaign['subject'];
-        
+              
         $aCustomHeaders = array();
         if ($aCampaign['from_name'] != '')
-            $aCustomHeaders['From'] = $aCampaign['from_name'];
+            $aCustomHeaders['From'] = $aCampaign['from_name'] . " <" . getParam('site_email_notify') . ">" ;
         if ($aCampaign['reply_to'] != '')
             $aCustomHeaders['Reply-To'] = $aCampaign['reply_to'];
-        
+              
         return array($aTemplate, $aCustomHeaders, $aCampaign);
     }
+          
+    private function addMarkers($iProfileId, $sLetterCode)
+    {
+        $CNF = &$this->_oConfig->CNF;
+        $aMarkers = array();
+        $oProfile = BxDolProfile::getInstance($iProfileId);
+        if ($oProfile){
+            $oAccount = BxDolAccount::getInstance($oProfile->getAccountId());
+            $aMarkers['email'] = $oAccount->getEmail();
+            $aMarkers['account_name'] = $oAccount->getDisplayName();
+            $aMarkers['account_id'] = $oProfile->getAccountId();
+            $oModule = BxDolModule::getInstance($oProfile->getModule());
+            $oModule->serviceGetInfo($oProfile->getContentId());
+            $aProfileInfo = $oModule->serviceGetInfo($oProfile->getContentId(), false);
+            foreach ($aProfileInfo as $sKey => $sValue){
+                if (!isset($aMarkers[$sKey]))
+                $aMarkers[$sKey] = $sValue;
+            }
+            
+            $aMarkers['seen_image_url'] = BX_DOL_URL_ROOT . $this->_oConfig->getBaseUri() . 'track/seen/' . $sLetterCode . "/";
+            $aMarkers['unsubscribe_url'] = BX_DOL_URL_ROOT . $oAccount->getUnsubscribeLink(BX_EMAIL_MASS);
+            
+        }
+        return $aMarkers;
+    }
     
-    private function getSeenImageUrl($sKey)
+    private function getReportStep($iMin, $iMax)
     {
-        return  BX_DOL_URL_ROOT . $this->_oConfig->getBaseUri() . 'SetSeenMark/' . $sKey . "/";
+        $iCount = 10;
+        return ceil(($iMax - $iMin) / $iCount);
     }
 }
 

+ 123 - 5
modules/boonex/massmailer/classes/BxMassMailerTemplate.php

@@ -16,22 +16,140 @@ class BxMassMailerTemplate extends BxBaseModGeneralTemplate
         parent::__construct($oConfig, $oDb);
     }
     
-    function getCampagnInfo($aData)
+    function getSubscribers($aData)
     {
-        $aList =array();
+        $aList = array();
         foreach($aData as $aItem){
-            $aList[] = array('email' => $aItem['email'], 'date_sent' =>  $aItem['date_sent'] > 0 ? bx_time_js($aItem['date_sent']) : '', 'date_seen' =>  $aItem['date_seen'] > 0 ? bx_time_js($aItem['date_seen']) : '');
+            $aList[] = array('email' => $aItem['email'], 'date_sent' => $aItem['date_sent'] > 0 ? bx_time_js($aItem['date_sent']) : '', 'date_seen' => $aItem['date_seen'] > 0 ? bx_time_js($aItem['date_seen']) : '', 'date_click' => $aItem['date_click'] > 0 ? bx_time_js($aItem['date_click']) : '');
         }
         
         $this->addJs(array(BX_DIRECTORY_PATH_MODULES . 'boonex/massmailer/plugins/datatables/|datatables.min.js'));
-        $this->addCss(array(BX_DIRECTORY_PATH_MODULES . 'boonex/massmailer/plugins/datatables/|datatables.min.css'));
-        return $this->parseHtmlByName('entity_view.html', array(
+        $this->addCss(array('main.css', BX_DIRECTORY_PATH_MODULES . 'boonex/massmailer/plugins/datatables/|datatables.min.css'));
+        return $this->parseHtmlByName('campaign_subscribers.html', array(
              'bx_repeat:items' => $aList,
              'email_title' => _t('_bx_massmailer_txt_title_email'),
              'date_sent_title' => _t('_bx_massmailer_txt_title_date_sent'),
              'date_seen_title' => _t('_bx_massmailer_txt_title_date_seen'),
+             'date_click_title' => _t('_bx_massmailer_txt_title_date_click'),
             ));
     }
+    
+    function getClicks($aData)
+    {
+        $aList = array();
+        foreach($aData as $aItem){
+            $aList[] = array('title' => $aItem['title'], 'link' => $aItem['link'], 'last_click' => $aItem['last_click'] > 0 ? bx_time_js($aItem['last_click']) : '', 'click_count' => $aItem['click_count']);
+        }
+        
+        $this->addJs(array(BX_DIRECTORY_PATH_MODULES . 'boonex/massmailer/plugins/datatables/|datatables.min.js'));
+        $this->addCss(array('main.css', BX_DIRECTORY_PATH_MODULES . 'boonex/massmailer/plugins/datatables/|datatables.min.css'));
+        return $this->parseHtmlByName('campaign_clicks.html', array(
+             'bx_repeat:items' => $aList,
+             'title_title' => _t('_bx_massmailer_txt_title_title'),
+             'link_title' => _t('_bx_massmailer_txt_link_title'),
+             'click_count_title' => _t('_bx_massmailer_txt_click_count_title'),
+             'last_click_title' => _t('_bx_massmailer_txt_last_click_title'),
+            ));
+    }
+    
+    
+    
+    function getInfo($CampaignId)
+    {
+        $CNF = &$this->getModule()->_oConfig->CNF;
+        
+        $aData = $this->_oModule->_oDb->getCampaignInfoById($CampaignId);
+        $aDataStat = $this->_oModule->_oDb->getStatByCampaignId($CampaignId);
+        
+        $this->addJs(array('chart.min.js', 'chart.js', BX_DIRECTORY_PATH_MODULES . 'boonex/analytics/plugins/daterangepicker/|daterangepicker.js'));
+        $this->addCss(array('main.css', BX_DIRECTORY_PATH_MODULES . 'boonex/analytics/plugins/daterangepicker/|daterangepicker.css'));
+        
+        return $this->getJsCode('chart', array('sChartName' => 'CAMPAIGN_REPORT', 'sReportName' => $CampaignId)) . $this->parseHtmlByName('campaign_info.html', array(
+            'created_title' => _t('_bx_massmailer_txt_created_title'),
+            'subject_title' => _t('_bx_massmailer_txt_subject_title'),
+            'body_title' => _t('_bx_massmailer_txt_body_title'),
+            'sent_on_title' => _t('_bx_massmailer_txt_sent_on_title'),
+            'sent_to_title' => _t('_bx_massmailer_txt_sent_to_title'),
+            'sent_total_title' => _t('_bx_massmailer_txt_sent_total_title'),
+            'opened_total_title' => _t('_bx_massmailer_txt_opened_total_title'),
+            'unopened_total_title' => _t('_bx_massmailer_txt_unopened_total_title'),
+            'clicked_total_title' => _t('_bx_massmailer_txt_clicked_total_title'),
+            'reply_title' => _t('_bx_massmailer_txt_reply_title'),
+            'subject' => $aData[$CNF['FIELD_SUBJECT']],
+            'body' => $aData[$CNF['FIELD_BODY']],
+            'sent_on' => bx_time_js($aData[$CNF['FIELD_DATE_SENT']]),
+            'created' => bx_time_js($aData[$CNF['FIELD_ADDED']]),
+            'sent_to' => $this->getModule()->getSegments($aData[$CNF['FIELD_SEGMENTS']]),
+            'sent_total' => $aDataStat['total'],
+            'opened_total' => $aDataStat['seen'],
+            'unopened_total' => $aDataStat['total'] - $aDataStat['seen'],
+            'clicked_total' => $aDataStat['clicked'],
+            'bx_if:from_name' => array(
+                'condition' => $aData[$CNF['FIELD_FROM_NAME']] != '',
+                'content' => array(
+                    'from_name_title' => _t('_bx_massmailer_txt_from_name_title'),
+                    'from_name' => $aData[$CNF['FIELD_FROM_NAME']],
+                )
+            ),
+           'bx_if:reply' => array(
+                'condition' => $aData[$CNF['FIELD_REPLY_TO']] != '',
+                'content' => array(
+                    'reply_title' => _t('_bx_massmailer_txt_reply_title'),
+                    'reply' => $aData[$CNF['FIELD_REPLY_TO']],
+                )
+            ),
+        ));
+    }
+    
+    function getTotalSubscribers()
+    {
+        $CNF = &$this->getModule()->_oConfig->CNF;
+        
+        $this->addJs(array('chart.min.js', 'chart.js', BX_DIRECTORY_PATH_MODULES . 'boonex/analytics/plugins/daterangepicker/|daterangepicker.js'));
+        $this->addCss(array('main.css', BX_DIRECTORY_PATH_MODULES . 'boonex/analytics/plugins/daterangepicker/|daterangepicker.css'));
+        $sDate = date('d/m/Y', time() - 90 * 86400) . ' - ' . date('d/m/Y');
+        
+        $aSegments = $this->_oModule->getSegments();
+        $aTmp = array();
+        foreach($aSegments as $sKey => $aSegment){
+            $aTmp[] = array('value' => $sKey, 'title' => $aSegment);
+        }
+        
+        return $this->getJsCode('chart', array('sChartName' => 'SUBSCRIBERS_INFO')) . $this->parseHtmlByName('subscribers_info.html', array(
+            'interval' => $sDate,
+            'bx_repeat:items' => $aTmp,
+            'title_content_total' => _t('_bx_massmailer_txt_title_content_total'),
+            'title_content_speed' => _t('_bx_massmailer_txt_title_content_speed')
+        ));
+    }
+    
+    public function entryBreadcrumb($aContentInfo, $aTmplVarsItems = array())
+    {
+        $CNF = &$this->getModule()->_oConfig->CNF;
+        $aTmplVarsItems = array(array(
+            'url' => $aContentInfo['url'],
+            'title' => bx_process_output($aContentInfo[$CNF['FIELD_TITLE']])
+        ));
+
+        return $this->parseHtmlByName('breadcrumb.html', array(
+            'url_home' => BX_DOL_URL_ROOT . BxDolPermalinks::getInstance()->permalink($CNF['URL_MANAGE_CAMPAIGNS']),
+            'icon_home' => $CNF['ICON'],
+            'bx_repeat:items' => $aTmplVarsItems
+        ));
+    }
+    
+    public function getAttributes($aAttributesParts)
+    {
+        $aList2 = array();
+        foreach($aAttributesParts as $aPart){
+            $aList = array();
+            foreach($aPart[1] as $sKey => $sItem){
+                $aList[] = array('title' => $sItem, 'attribute' => $sKey);
+            }
+            $aList2[] = array('title' => $aPart[0], 'list' => $this->parseHtmlByName('attributes_part.html', array('bx_repeat:items' => $aList)));
+        }
+        return $this->parseHtmlByName('attributes.html', array('bx_repeat:items' => $aList2));
+    }
 }
 
 /** @} */

+ 53 - 11
modules/boonex/massmailer/install/langs/en.xml

@@ -17,10 +17,20 @@
   <string name="_bx_massmailer_page_title_sys_edit_campaign"><![CDATA[Edit Campaign]]></string>
   <string name="_bx_massmailer_page_title_edit_campaign"><![CDATA[Edit Campaign]]></string>
   <string name="_bx_massmailer_page_block_title_edit_campaign"><![CDATA[Edit Campaign]]></string>
-  <string name="_bx_massmailer_page_title_sys_view_campaign"><![CDATA[View Campaign]]></string>
-  <string name="_bx_massmailer_page_title_view_campaign"><![CDATA[View Campaign]]></string>
-  <string name="_bx_massmailer_page_block_title_view_campaign"><![CDATA[View Campaign]]></string>
-
+  <string name="_bx_massmailer_page_title_sys_view_campaign"><![CDATA[Campaign report]]></string>
+  <string name="_bx_massmailer_page_title_view_campaign"><![CDATA[Campaign report]]></string>
+  <string name="_bx_massmailer_page_block_title_view_campaign_info"><![CDATA[Info]]></string>
+  <string name="_bx_massmailer_page_block_title_view_campaign_subscribers"><![CDATA[Subscribers]]></string>
+  <string name="_bx_massmailer_page_block_title_view_campaign_links"><![CDATA[Link activity]]></string>
+  <string name="_bx_massmailer_page_block_title_entry_breadcrumb"><![CDATA[Breadcrumb]]></string>
+  <string name="_bx_massmailer_page_title_sys_unsubscribe"><![CDATA[Unsubscribe From Site Newsletters]]></string>
+  <string name="_bx_massmailer_page_title_unsubscribe"><![CDATA[Unsubscribe From Site Newsletters]]></string>
+  <string name="_bx_massmailer_page_block_title_system_unsubscribe"><![CDATA[Unsubscribe From Site Newsletters]]></string>
+  <string name="_bx_massmailer_page_block_title_unsubscribe"><![CDATA[Unsubscribe From Site Newsletters]]></string>
+  <string name="_bx_massmailer_page_block_title_system_viewsubscribers"><![CDATA[Subscribers]]></string>
+  <string name="_bx_massmailer_page_block_title_viewsubscribers"><![CDATA[Subscribers]]></string>
+  <string name="_bx_massmailer_page_block_title_attributes"><![CDATA[Attributes]]></string>
+ 
   <string name="_bx_massmailer_menu_item_title_system_admt_mailer"><![CDATA[Mass Mailer]]></string>
   <string name="_bx_massmailer_menu_item_title_admt_mailer"><![CDATA[Mass Mailer]]></string>
   <string name="_bx_massmailer_menu_item_title_manage_campaigns"><![CDATA[Campaigns]]></string>
@@ -41,7 +51,7 @@
   <string name="_bx_massmailer_grid_action_title_adm_edit"><![CDATA[Edit]]></string>
   <string name="_bx_massmailer_grid_action_title_adm_copy"><![CDATA[Copy]]></string>
   <string name="_bx_massmailer_grid_action_title_adm_delete"><![CDATA[Delete]]></string>
-  <string name="_bx_massmailer_grid_action_title_adm_view"><![CDATA[View]]></string>
+  <string name="_bx_massmailer_grid_action_title_adm_view"><![CDATA[Report]]></string>
   <string name="_bx_massmailer_grid_action_title_adm_send_test"><![CDATA[Send test]]></string>
   <string name="_bx_massmailer_grid_action_title_adm_send_all"><![CDATA[Send All]]></string>
   <string name="_bx_massmailer_grid_action_title_adm_add"><![CDATA[Add new campaign]]></string>
@@ -80,21 +90,53 @@
   <string name="_bx_massmailer_form_campaign_input_reply_to_info"><![CDATA[If empty, Site Email is used]]></string>
   <string name="_bx_massmailer_form_campaign_input_reply_to_err"><![CDATA[Email is not valid]]></string>
   <string name="_bx_massmailer_form_campaign_input_from_name_info_value"><![CDATA[The message will be sent to {0} addresses.]]></string>
-  <string name="_bx_massmailer_form_campaign_input_sys_campaign_info"><![CDATA[Campaign info]]></string> 
+  <string name="_bx_massmailer_form_campaign_input_sys_campaign_info"><![CDATA[Campaign info]]></string>
+  <string name="_bx_massmailer_form_campaign_input_sys_receive_news"><![CDATA[Receive Newsletterso]]></string>
+  <string name="_bx_massmailer_form_campaign_input_receive_news"><![CDATA[Receive Newsletters]]></string>
+  <string name="_bx_massmailer_form_campaign_input_sys_code"><![CDATA[Code]]></string>
   <string name="_bx_massmailer_form_entry_input_cancel"><![CDATA[Cancel]]></string>
 
+  <string name="_bx_massmailer_txt_breadcrumb_title_new_campaign"><![CDATA[New campaign]]></string>
   <string name="_bx_massmailer_txt_never_sent"><![CDATA[Never]]></string>
   <string name="_bx_massmailer_txt_title_email"><![CDATA[Email]]></string>
-  <string name="_bx_massmailer_txt_title_date_sent"><![CDATA[Date sent]]></string>
-  <string name="_bx_massmailer_txt_title_date_seen"><![CDATA[Date seen]]></string>
+  <string name="_bx_massmailer_txt_title_title"><![CDATA[Title]]></string>
+  <string name="_bx_massmailer_txt_link_title"><![CDATA[Url]]></string>
+  <string name="_bx_massmailer_txt_click_count_title"><![CDATA[Total clicks]]></string>
+  <string name="_bx_massmailer_txt_last_click_title"><![CDATA[Last click]]></string>
+  <string name="_bx_massmailer_txt_subject_title"><![CDATA[Subject:]]></string>
+  <string name="_bx_massmailer_txt_body_title"><![CDATA[Body:]]></string>
+  <string name="_bx_massmailer_txt_sent_on_title"><![CDATA[Sent on:]]></string>
+  <string name="_bx_massmailer_txt_sent_to_title"><![CDATA[Segment:]]></string>
+  
+  <string name="_bx_massmailer_txt_sent_total_title"><![CDATA[Total sent - {0}]]></string>
+  <string name="_bx_massmailer_txt_opened_total_title"><![CDATA[Opened - {0}]]></string>
+  <string name="_bx_massmailer_txt_unopened_total_title"><![CDATA[Unopened - {0}]]></string>
+  <string name="_bx_massmailer_txt_clicked_total_title"><![CDATA[Clicked - {0}]]></string>
+
+
+  <string name="_bx_massmailer_txt_attribute_global"><![CDATA[Account]]></string>
+  <string name="_bx_massmailer_txt_attribute_email"><![CDATA[Email]]></string>
+  <string name="_bx_massmailer_txt_attribute_account_name"><![CDATA[Account name]]></string>
+  <string name="_bx_massmailer_txt_attribute_account_id"><![CDATA[Account id]]></string>
+  <string name="_bx_massmailer_txt_attribute_unsubscribe_url"><![CDATA[Unsubscribe url]]></string>
+  <string name="_bx_massmailer_txt_created_title"><![CDATA[Created:]]></string>
+  <string name="_bx_massmailer_txt_from_name_title"><![CDATA[From name:]]></string>
+  <string name="_bx_massmailer_txt_reply_title"><![CDATA[Reply:]]></string>
+  <string name="_bx_massmailer_txt_title_content_total"><![CDATA[Total numbers]]></string>
+  <string name="_bx_massmailer_txt_title_content_speed"><![CDATA[Growth speed]]></string>
+  <string name="_bx_massmailer_txt_title_date_sent"><![CDATA[Sent]]></string>
+  <string name="_bx_massmailer_txt_title_date_click"><![CDATA[Last click]]></string>
+  <string name="_bx_massmailer_txt_title_date_seen"><![CDATA[Seen]]></string>
   <string name="_bx_massmailer_txt_copy_title"><![CDATA[ - Copy]]></string>
+  <string name="_bx_massmailer_txt_unsubscribe_error_occured"><![CDATA[Unsubscribe error]]></string>
+  <string name="_bx_massmailer_txt_subscribed"><![CDATA[Subscribed]]></string>
+  <string name="_bx_massmailer_txt_unsubscribed"><![CDATA[Unsubscribed]]></string>
   <string name="_bx_massmailer_segments_all"><![CDATA[All subscribers]]></string>
   <string name="_bx_massmailer_email_name"><![CDATA[Mass Mailer message]]></string>
   <string name="_bx_massmailer_email_subject"><![CDATA[Mass Mailer message]]></string>
   <string name="_bx_massmailer_email_body">
-    <![CDATA[{email_header}
+    <![CDATA[
   <br />{content} <br />
   <img src="{seen_image_url}" width="1" height="1">
-  {email_footer}]]></string>
-
+  ]]></string>
 </resources>

+ 4 - 5
modules/boonex/massmailer/install/sql/disable.sql

@@ -21,8 +21,7 @@ DELETE FROM `sys_acl_actions` WHERE `Module` = @sName;
 -- EMAIL TEMPLATES
 DELETE FROM `sys_email_templates` WHERE `module` = @sName;
 
--- FORMS
-DELETE FROM `sys_objects_form` WHERE `module` = @sName;
-DELETE FROM `sys_form_displays` WHERE `module` = @sName;
-DELETE FROM `sys_form_inputs` WHERE `module` = @sName;
-DELETE FROM `sys_form_display_inputs` WHERE `display_name` IN ('bx_massmailer_campaign_add', 'bx_massmailer_campaign_edit', 'bx_massmailer_campaign_send_test', 'bx_massmailer_campaign_send_all');
+-- 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;

+ 29 - 64
modules/boonex/massmailer/install/sql/enable.sql

@@ -2,52 +2,62 @@ SET @sName = 'bx_massmailer';
 
 -- PAGE: create campaign
 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_massmailer_create_campaign', '_bx_massmailer_page_title_sys_create_campaign', '_bx_massmailer_page_title_create_campaign', 'bx_massmailer', 5, 192, 1, 'create-campaign', 'page.php?i=create-campaign', '', '', '', 0, 1, 0, 'BxMassMailerPageBrowse', 'modules/boonex/massmailer/classes/BxMassMailerPageBrowse.php');
+('bx_massmailer_create_campaign', '_bx_massmailer_page_title_sys_create_campaign', '_bx_massmailer_page_title_create_campaign', 'bx_massmailer', 7, 128, 1, 'create-campaign', 'page.php?i=create-campaign', '', '', '', 0, 1, 0, 'BxMassMailerPageBrowse', 'modules/boonex/massmailer/classes/BxMassMailerPageBrowse.php');
 
 INSERT INTO `sys_pages_blocks` (`object`, `cell_id`, `module`, `title`, `designbox_id`, `visible_for_levels`, `type`, `content`, `deletable`, `copyable`, `order`) VALUES
-('bx_massmailer_create_campaign', 1, 'bx_massmailer', '_bx_massmailer_page_block_title_create_campaign', 11, 192, 'service', 'a:2:{s:6:"module";s:13:"bx_massmailer";s:6:"method";s:13:"entity_create";}', 0, 1, 1);
+('bx_massmailer_create_campaign', 1, @sName, '_bx_massmailer_page_block_title_entry_breadcrumb', 13, 128, 'service', 'a:2:{s:6:"module";s:13:"bx_massmailer";s:6:"method";s:17:"entity_breadcrumb";}', 0, 0, 0),
+('bx_massmailer_create_campaign', 2, @sName, '_bx_massmailer_page_block_title_create_campaign', 11, 128, 'service', 'a:2:{s:6:"module";s:13:"bx_massmailer";s:6:"method";s:13:"entity_create";}', 0, 1, 1),
+('bx_massmailer_create_campaign', 3, @sName, '_bx_massmailer_page_block_title_attributes', 11, 128, 'service', 'a:2:{s:6:"module";s:13:"bx_massmailer";s:6:"method";s:10:"attributes";}', 0, 0, 1);
+
 
 -- PAGE: edit campaign
 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_massmailer_edit_campaign', '_bx_massmailer_page_title_sys_edit_campaign', '_bx_massmailer_page_title_edit_campaign', 'bx_massmailer', 5, 192, 1, 'edit-campaign', '', '', '', '', 0, 1, 0, 'BxMassMailerPageEntry', 'modules/boonex/massmailer/classes/BxMassMailerPageEntry.php');
+('bx_massmailer_edit_campaign', '_bx_massmailer_page_title_sys_edit_campaign', '_bx_massmailer_page_title_edit_campaign', 'bx_massmailer', 7, 128, 1, 'edit-campaign', '', '', '', '', 0, 1, 0, 'BxMassMailerPageEntry', 'modules/boonex/massmailer/classes/BxMassMailerPageEntry.php');
 
 INSERT INTO `sys_pages_blocks` (`object`, `cell_id`, `module`, `title`, `designbox_id`, `visible_for_levels`, `type`, `content`, `deletable`, `copyable`, `order`) VALUES
-('bx_massmailer_edit_campaign', 1, 'bx_massmailer', '_bx_massmailer_page_block_title_edit_campaign', 11, 192, 'service', 'a:2:{s:6:"module";s:13:"bx_massmailer";s:6:"method";s:11:"entity_edit";}', 0, 0, 0);
+('bx_massmailer_edit_campaign', 1, @sName, '_bx_massmailer_page_block_title_entry_breadcrumb', 13, 2147483647, 'service', 'a:2:{s:6:"module";s:13:"bx_massmailer";s:6:"method";s:17:"entity_breadcrumb";}', 0, 0, 0),
+('bx_massmailer_edit_campaign', 2, @sName, '_bx_massmailer_page_block_title_edit_campaign', 11, 128, 'service', 'a:2:{s:6:"module";s:13:"bx_massmailer";s:6:"method";s:11:"entity_edit";}', 0, 0, 1),
+('bx_massmailer_edit_campaign', 3, @sName, '_bx_massmailer_page_block_title_attributes', 11, 128, 'service', 'a:2:{s:6:"module";s:13:"bx_massmailer";s:6:"method";s:10:"attributes";}', 0, 0, 1);
 
 -- PAGE: view campaign
 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_massmailer_view_campaign', '_bx_massmailer_page_title_sys_view_campaign', '_bx_massmailer_page_title_view_campaign', 'bx_massmailer', 5, 192, 1, 'view-campaign', '', '', '', '', 0, 1, 0, 'BxMassMailerPageEntry', 'modules/boonex/massmailer/classes/BxMassMailerPageEntry.php');
+('bx_massmailer_view_campaign', '_bx_massmailer_page_title_sys_view_campaign', '_bx_massmailer_page_title_view_campaign', 'bx_massmailer', 5, 128, 1, 'view-campaign', '', '', '', '', 0, 1, 0, 'BxMassMailerPageEntry', 'modules/boonex/massmailer/classes/BxMassMailerPageEntry.php');
 
 INSERT INTO `sys_pages_blocks` (`object`, `cell_id`, `module`, `title`, `designbox_id`, `visible_for_levels`, `type`, `content`, `deletable`, `copyable`, `order`) VALUES
-('bx_massmailer_view_campaign', 1, 'bx_massmailer', '_bx_massmailer_page_block_title_view_campaign', 11, 192, 'service', 'a:2:{s:6:"module";s:13:"bx_massmailer";s:6:"method";s:11:"entity_view";}', 0, 0, 0);
+('bx_massmailer_view_campaign', 1, @sName, '_bx_massmailer_page_block_title_entry_breadcrumb', 13, 2147483647, 'service', 'a:2:{s:6:"module";s:13:"bx_massmailer";s:6:"method";s:17:"entity_breadcrumb";}', 0, 0, 0),
+('bx_massmailer_view_campaign', 1, @sName, '_bx_massmailer_page_block_title_view_campaign_info', 11, 128, 'service', 'a:2:{s:6:"module";s:13:"bx_massmailer";s:6:"method";s:12:"campagn_info";}', 0, 0, 1),
+('bx_massmailer_view_campaign', 1, @sName, '_bx_massmailer_page_block_title_view_campaign_subscribers', 11, 128, 'service', 'a:2:{s:6:"module";s:13:"bx_massmailer";s:6:"method";s:19:"campagn_subscribers";}', 0, 0, 2),
+('bx_massmailer_view_campaign', 1, @sName, '_bx_massmailer_page_block_title_view_campaign_links', 11, 128, 'service', 'a:2:{s:6:"module";s:13:"bx_massmailer";s:6:"method";s:13:"campagn_links";}', 0, 0, 3);
+
 
 -- PAGE: campaigns administration
 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_massmailer_campaigns', '_bx_massmailer_page_title_sys_manage_campaigns', '_bx_massmailer_page_title_manage_campaigns', @sName, 5, 192, 1, 'massmailer-campaigns', 'page.php?i=massmailer-campaigns', '', '', '', 0, 1, 0, 'BxMassMailerPageBrowse', 'modules/boonex/massmailer/classes/BxMassMailerPageBrowse.php');
+('bx_massmailer_campaigns', '_bx_massmailer_page_title_sys_manage_campaigns', '_bx_massmailer_page_title_manage_campaigns', @sName, 5, 128, 1, 'massmailer-campaigns', 'page.php?i=massmailer-campaigns', '', '', '', 0, 1, 0, 'BxMassMailerPageBrowse', 'modules/boonex/massmailer/classes/BxMassMailerPageBrowse.php');
 
 INSERT INTO `sys_pages_blocks`(`object`, `cell_id`, `module`, `title_system`, `title`, `designbox_id`, `visible_for_levels`, `type`, `content`, `deletable`, `copyable`, `order`) VALUES 
-('bx_massmailer_campaigns', 1, @sName, '_bx_massmailer_page_block_title_system_manage_campaigns', '_bx_massmailer_page_block_title_manage_campaigns', 11, 192, 'service', 'a:3:{s:6:\"module\";s:13:\"bx_massmailer\";s:6:\"method\";s:12:\"manage_tools\";s:6:\"params\";a:1:{i:0;s:9:\"campaigns\";}}', 0, 1, 0);
+('bx_massmailer_campaigns', 1, @sName, '_bx_massmailer_page_block_title_system_manage_campaigns', '_bx_massmailer_page_block_title_manage_campaigns', 11, 128, 'service', 'a:3:{s:6:\"module\";s:13:\"bx_massmailer\";s:6:\"method\";s:12:\"manage_tools\";s:6:\"params\";a:1:{i:0;s:9:\"campaigns\";}}', 0, 1, 0),
+('bx_massmailer_campaigns', 1, @sName, '_bx_massmailer_page_block_title_system_viewsubscribers', '_bx_massmailer_page_block_title_viewsubscribers', 11, 128, 'service', 'a:2:{s:6:"module";s:13:"bx_massmailer";s:6:"method";s:17:"total_subscribers";}', 0, 0, 1);
 
 -- MENU: module sub-menu
 INSERT INTO `sys_objects_menu`(`object`, `title`, `set_name`, `module`, `template_id`, `deletable`, `active`, `override_class_name`, `override_class_file`) VALUES 
 ('bx_massmailer_submenu', '_bx_massmailer_menu_title_submenu', 'bx_massmailer_submenu', 'bx_massmailer', 8, 0, 1, '', '');
 
 INSERT INTO `sys_menu_sets`(`set_name`, `module`, `title`, `deletable`) VALUES 
-('bx_massmailer_submenu', 'bx_massmailer', '_bx_massmailer_menu_set_title_submenu', 0);
+('bx_massmailer_submenu', @sName, '_bx_massmailer_menu_set_title_submenu', 0);
 
 -- MENU: add to "add content" menu
 SET @iAddMenuOrder = (SELECT `order` FROM `sys_menu_items` WHERE `set_name` = 'sys_add_content_links' AND `active` = 1 ORDER BY `order` DESC LIMIT 1);
 INSERT INTO `sys_menu_items` (`set_name`, `module`, `name`, `title_system`, `title`, `link`, `onclick`, `target`, `icon`, `submenu_object`, `visible_for_levels`, `active`, `copyable`, `order`) VALUES 
-('sys_add_content_links', 'bx_massmailer', 'create-campaign', '_bx_massmailer_menu_item_title_system_create_entry', '_bx_massmailer_menu_item_title_create_entry', 'page.php?i=create-campaign', '', '', 'envelope col-red2', '', 2147483647, 1, 1, IFNULL(@iAddMenuOrder, 0) + 1);
+('sys_add_content_links', @sName, 'create-campaign', '_bx_massmailer_menu_item_title_system_create_entry', '_bx_massmailer_menu_item_title_create_entry', 'page.php?i=create-campaign', '', '', 'envelope col-red2', '', 2147483647, 1, 1, IFNULL(@iAddMenuOrder, 0) + 1);
 
 -- MENU: dashboard manage tools
 SET @iManageMenuOrder = (SELECT IFNULL(MAX(`order`), 0) FROM `sys_menu_items` WHERE `set_name`='sys_account_dashboard_manage_tools' LIMIT 1);
 INSERT INTO `sys_menu_items`(`set_name`, `module`, `name`, `title_system`, `title`, `link`, `onclick`, `target`, `icon`, `addon`, `submenu_object`, `visible_for_levels`, `active`, `copyable`, `order`) VALUES 
-('sys_account_dashboard_manage_tools', @sName, 'massmailer-campaigns', '_bx_massmailer_menu_item_title_system_admt_mailer', '_bx_massmailer_menu_item_title_admt_mailer', 'page.php?i=massmailer-campaigns', '', '_self', '', 'a:2:{s:6:"module";s:13:"bx_massmailer";s:6:"method";s:27:"get_menu_addon_manage_tools";}', '', 192, 1, 0, @iManageMenuOrder + 1);
+('sys_account_dashboard_manage_tools', @sName, 'massmailer-campaigns', '_bx_massmailer_menu_item_title_system_admt_mailer', '_bx_massmailer_menu_item_title_admt_mailer', 'page.php?i=massmailer-campaigns', '', '_self', '', 'a:2:{s:6:"module";s:13:"bx_massmailer";s:6:"method";s:27:"get_menu_addon_manage_tools";}', '', 128, 1, 0, @iManageMenuOrder + 1);
 
 -- GRIDS: campaigns administration
 INSERT INTO `sys_objects_grid` (`object`, `source_type`, `source`, `table`, `field_id`, `field_order`, `field_active`, `paginate_url`, `paginate_per_page`, `paginate_simple`, `paginate_get_start`, `paginate_get_per_page`, `filter_fields`, `filter_fields_translatable`, `filter_mode`, `sorting_fields`, `sorting_fields_translatable`, `visible_for_levels`, `override_class_name`, `override_class_file`) VALUES
-('bx_massmailer_campaigns', 'Sql', 'SELECT * FROM `bx_massmailer_campaigns` WHERE 1 ', 'bx_massmailer_campaigns', 'id', 'date_created', '', '', 20, NULL, 'start', '', 'title', '', 'like', '', '', 192, 'BxMassMailerGridCampaigns', 'modules/boonex/massmailer/classes/BxMassMailerGridCampaigns.php');
+('bx_massmailer_campaigns', 'Sql', 'SELECT * FROM `bx_massmailer_campaigns` WHERE 1 ', 'bx_massmailer_campaigns', 'id', 'added', '', '', 20, NULL, 'start', '', 'title', '', 'like', '', '', 128, 'BxMassMailerGridCampaigns', 'modules/boonex/massmailer/classes/BxMassMailerGridCampaigns.php');
 
 INSERT INTO `sys_grid_fields` (`object`, `name`, `title`, `width`, `translatable`, `chars_limit`, `params`, `order`) VALUES
 ('bx_massmailer_campaigns', 'checkbox', '_sys_select', '2%', 0, '', '', 1),
@@ -109,55 +119,10 @@ INSERT INTO `sys_acl_matrix` (`IDLevel`, `IDAction`) VALUES
 (@iModerator, @iIdActionEntryView),
 (@iAdministrator, @iIdActionEntryView);
 
--- FORMS
-INSERT INTO `sys_objects_form` (`object`, `module`, `title`, `action`, `form_attrs`, `submit_name`, `table`, `key`, `uri`, `uri_title`, `params`, `deletable`, `active`, `override_class_name`, `override_class_file`) 
-VALUES(@sName, @sName, '_bx_massmailer_form_campaign', '', 'a:1:{s:7:"enctype";s:19:"multipart/form-data";}', 'a:2:{i:0;s:9:"do_submit";i:1;s:7:"do_send";}', 'bx_massmailer_campaigns', 'id', '', '', 'a:1:{s:14:"checker_helper";s:29:"BxMassMailerFormCheckerHelper";}', 0, 1, 'BxMassMailerFormEntry', 'modules/boonex/massmailer/classes/BxMassMailerFormEntry.php');
-
-INSERT INTO `sys_form_displays` (`display_name`, `module`, `object`, `title`, `view_mode`) VALUES
-('bx_massmailer_campaign_add', @sName, @sName, '_bx_massmailer_form_campaign_add_display', 0),
-('bx_massmailer_campaign_edit', @sName, @sName, '_bx_massmailer_form_campaign_edit_display', 0),
-('bx_massmailer_campaign_send_test', @sName, @sName, '_bx_massmailer_form_campaign_send_test_display', 0),
-('bx_massmailer_campaign_send_all', @sName, @sName, '_bx_massmailer_form_campaign_send_all_display', 0);
-
-INSERT INTO `sys_form_inputs` (`object`, `module`, `name`, `value`, `values`, `checked`, `type`, `caption_system`, `caption`, `info`, `required`, `unique`, `collapsed`, `html`, `attrs`, `attrs_tr`, `attrs_wrapper`, `checker_func`, `checker_params`, `checker_error`, `db_pass`, `db_params`, `editable`, `deletable`) VALUES
-(@sName, @sName, 'do_submit', '_bx_massmailer_form_entry_input_do_submit', '', 0, 'submit', '_bx_massmailer_form_entry_input_sys_do_submit', '', '', 0, 0, 0, 0, '', '', '', '', '', '', '', '', 1, 0),
-(@sName, @sName, 'do_send', '_bx_massmailer_form_entry_input_do_send', '', 0, 'submit', '_bx_massmailer_form_entry_input_sys_do_send', '', '', 0, 0, 0, 0, '', '', '', '', '', '', '', '', 1, 0),
-(@sName, @sName, 'title', '', '', 0, 'text', '_bx_massmailer_form_campaign_input_sys_title', '_bx_massmailer_form_campaign_input_title', '', 1, 0, 0, 2, '', '', '', 'Avail', '', '_bx_massmailer_form_campaign_input_title_err', 'XssHtml', '', 1, 0),
-(@sName, @sName, 'segments', '', '', 0, 'select', '_bx_massmailer_form_campaign_input_sys_segments', '_bx_massmailer_form_campaign_input_segments', '', 1, 0, 0, 2, '', '', '', 'Avail', '', '_bx_massmailer_form_campaign_input_segments_err', 'XssHtml', '', 1, 0),
-(@sName, @sName, 'subject', '', '', 0, 'text', '_bx_massmailer_form_campaign_input_sys_subject', '_bx_massmailer_form_campaign_input_subject', '', 1, 0, 0, 2, '', '', '', 'Avail', '', '_bx_massmailer_form_campaign_input_subject_err', 'XssHtml', '', 1, 0),
-(@sName, @sName, 'from_name', '', '', 0, 'text', '_bx_massmailer_form_campaign_input_sys_from_name', '_bx_massmailer_form_campaign_input_from_name', '_bx_massmailer_form_campaign_input_from_name_info', 1, 0, 0, 2, '', '', '', '', '', '', 'XssHtml', '', 1, 0),
-(@sName, @sName, 'reply_to', '', '', 0, 'text', '_bx_massmailer_form_campaign_input_sys_reply_to', '_bx_massmailer_form_campaign_input_reply_to', '_bx_massmailer_form_campaign_input_reply_to_info', 1, 0, 0, 2, '', '', '', 'EmailOrEmpty', '', '_bx_massmailer_form_campaign_input_reply_to_err', 'XssHtml', '', 1, 0),
-(@sName, @sName, 'body', '', '', 0, 'textarea', '_bx_massmailer_form_campaign_input_sys_body', '_bx_massmailer_form_campaign_input_body', '', 1, 0, 0, 2, '', '', '', 'Avail', '', '_bx_massmailer_form_campaign_input_body_err', 'XssHtml', '', 1, 0),
-(@sName, @sName, 'email', '', '', 0, 'text', '_bx_massmailer_form_campaign_input_sys_test_email', '_bx_massmailer_form_campaign_input_email', '', 1, 0, 0, 2, '', '', '', 'Email', '', '_sys_form_account_input_email_error', 'XssHtml', '', 1, 0),
-(@sName, @sName, 'campaign_info', '_bx_massmailer_form_campaign_input_from_name_info_value', '', 0, 'value', '_bx_massmailer_form_campaign_input_sys_campaign_info', '', '', 0, 0, 0, 2, '', '', '', '', '', '', 'XssHtml', '', 1, 0),
-(@sName, @sName, 'cancel', '_bx_massmailer_form_entry_input_cancel', '', 0, 'button', '_bx_dev_bp_btn_sys_block_cancel', '', '', 0, 0, 0, 0, 'a:2:{s:7:"onclick";s:45:"$(''.bx-popup-applied:visible'').dolPopupHide()";s:5:"class";s:22:"bx-def-margin-sec-left";}', '', '', 'Avail', '', '', '', '', 0, 0),
-(@sName, @sName, 'controls', '', 'do_submit,cancel', 0, 'input_set', '', '', '', 0, 0, 0, 0, '', '', '', '', '', '', '', '', 0, 0),
-(@sName, @sName, 'controls2', '', 'do_send,cancel', 0, 'input_set', '', '', '', 0, 0, 0, 0, '', '', '', '', '', '', '', '', 0, 0);
-
-INSERT INTO `sys_form_display_inputs` (`display_name`, `input_name`, `visible_for_levels`, `active`, `order`) VALUES
-('bx_massmailer_campaign_add', 'title', 2147483647, 1, 1),
-('bx_massmailer_campaign_add', 'segments', 2147483647, 1, 2),
-('bx_massmailer_campaign_add', 'subject', 2147483647, 1, 3),
-('bx_massmailer_campaign_add', 'from_name', 2147483647, 1, 4),
-('bx_massmailer_campaign_add', 'reply_to', 2147483647, 1, 5),
-('bx_massmailer_campaign_add', 'body', 2147483647, 1, 6),
-('bx_massmailer_campaign_add', 'controls', 2147483647, 1, 7),
-('bx_massmailer_campaign_add', 'do_submit', 2147483647, 1, 8),
-('bx_massmailer_campaign_add', 'cancel', 2147483647, 1, 9),
-('bx_massmailer_campaign_edit', 'title', 2147483647, 1, 1),
-('bx_massmailer_campaign_edit', 'segments', 2147483647, 1, 2),
-('bx_massmailer_campaign_edit', 'subject', 2147483647, 1, 3),
-('bx_massmailer_campaign_edit', 'from_name', 2147483647, 1, 4),
-('bx_massmailer_campaign_edit', 'reply_to', 2147483647, 1, 5),
-('bx_massmailer_campaign_edit', 'body', 2147483647, 1, 6),
-('bx_massmailer_campaign_edit', 'controls', 2147483647, 1, 7),
-('bx_massmailer_campaign_edit', 'do_submit', 2147483647, 1, 8),
-('bx_massmailer_campaign_edit', 'cancel', 2147483647, 1, 9),
-('bx_massmailer_campaign_send_test', 'email', 2147483647, 1, 1),
-('bx_massmailer_campaign_send_test', 'controls2', 2147483647, 1, 2),
-('bx_massmailer_campaign_send_test', 'do_send', 2147483647, 1, 3),
-('bx_massmailer_campaign_send_test', 'cancel', 2147483647, 1, 4),
-('bx_massmailer_campaign_send_all', 'campaign_info', 2147483647, 1, 1),
-('bx_massmailer_campaign_send_all', 'controls2', 2147483647, 1, 2),
-('bx_massmailer_campaign_send_all', 'do_send', 2147483647, 1, 3),
-('bx_massmailer_campaign_send_all', 'cancel', 2147483647, 1, 4);
+-- ALERTS
+INSERT INTO `sys_alerts_handlers` (`name`, `class`, `file`, `service_call`) VALUES 
+(@sName, 'BxMassMailerAlertsResponse', 'modules/boonex/massmailer/classes/BxMassMailerAlertsResponse.php', '');
+SET @iHandler := LAST_INSERT_ID();
+
+INSERT INTO `sys_alerts` (`unit`, `action`, `handler_id`) VALUES
+('account', 'change_receive_news', @iHandler);

+ 72 - 2
modules/boonex/massmailer/install/sql/install.sql

@@ -10,7 +10,8 @@ CREATE TABLE `bx_massmailer_campaigns` (
   `body` text DEFAULT NULL,
   `segments` varchar(255) DEFAULT NULL,
   `author` int(11) NOT NULL,
-  `date_created` int(11) NOT NULL default '0',
+  `added` int(11) NOT NULL default '0',
+  `changed` int(11) NOT NULL default '0',
   `date_sent` int(11) NOT NULL default '0',
   `email_list` text DEFAULT NULL,
   PRIMARY KEY (`id`)
@@ -30,7 +31,26 @@ CREATE TABLE `bx_massmailer_letters` (
   `email` varchar(255) NOT NULL,
   `date_sent` int(11) NOT NULL default '0',
   `date_seen` int(11) NOT NULL default '0',
-  `code` varchar(35) NOT NULL,
+  `date_click` int(11) NOT NULL default '0',
+  `hash` varchar(35) NOT NULL,
+  PRIMARY KEY (`id`)
+);
+
+CREATE TABLE `bx_massmailer_links` (
+  `id` int(11) unsigned NOT NULL AUTO_INCREMENT,
+  `letter_hash` varchar(35) DEFAULT NULL,
+  `hash` varchar(35) DEFAULT NULL,
+  `link` varchar(255) DEFAULT NULL,
+  `title` varchar(255) DEFAULT NULL,
+  `campaign_id` int(11) DEFAULT NULL,
+  `date_click` int(11) NOT NULL default '0',
+  PRIMARY KEY (`id`)
+);
+
+CREATE TABLE `bx_massmailer_unsubscribe` (
+  `id` int(11) unsigned NOT NULL AUTO_INCREMENT,
+  `account_id` int(11) DEFAULT NULL,
+  `unsubscribed` int(11) NOT NULL default '0',
   PRIMARY KEY (`id`)
 );
 
@@ -49,5 +69,55 @@ SET @iParentPageOrder = (SELECT IFNULL(MAX(`order`), 0) + 1 FROM `sys_std_pages_
 INSERT INTO `sys_std_pages_widgets` (`page_id`, `widget_id`, `order`) VALUES
 (@iParentPageId, LAST_INSERT_ID(), @iParentPageOrder);
 
+-- FORMS
+INSERT INTO `sys_objects_form` (`object`, `module`, `title`, `action`, `form_attrs`, `submit_name`, `table`, `key`, `uri`, `uri_title`, `params`, `deletable`, `active`, `override_class_name`, `override_class_file`) 
+VALUES(@sName, @sName, '_bx_massmailer_form_campaign', '', 'a:1:{s:7:"enctype";s:19:"multipart/form-data";}', 'a:2:{i:0;s:9:"do_submit";i:1;s:7:"do_send";}', 'bx_massmailer_campaigns', 'id', '', '', 'a:1:{s:14:"checker_helper";s:29:"BxMassMailerFormCheckerHelper";}', 0, 1, 'BxMassMailerFormEntry', 'modules/boonex/massmailer/classes/BxMassMailerFormEntry.php');
+
+INSERT INTO `sys_form_displays` (`display_name`, `module`, `object`, `title`, `view_mode`) VALUES
+('bx_massmailer_campaign_add', @sName, @sName, '_bx_massmailer_form_campaign_add_display', 0),
+('bx_massmailer_campaign_edit', @sName, @sName, '_bx_massmailer_form_campaign_edit_display', 0),
+('bx_massmailer_campaign_send_test', @sName, @sName, '_bx_massmailer_form_campaign_send_test_display', 0),
+('bx_massmailer_campaign_send_all', @sName, @sName, '_bx_massmailer_form_campaign_send_all_display', 0);
 
+INSERT INTO `sys_form_inputs` (`object`, `module`, `name`, `value`, `values`, `checked`, `type`, `caption_system`, `caption`, `info`, `required`, `unique`, `collapsed`, `html`, `attrs`, `attrs_tr`, `attrs_wrapper`, `checker_func`, `checker_params`, `checker_error`, `db_pass`, `db_params`, `editable`, `deletable`) VALUES
+(@sName, @sName, 'do_submit', '_bx_massmailer_form_entry_input_do_submit', '', 0, 'submit', '_bx_massmailer_form_entry_input_sys_do_submit', '', '', 0, 0, 0, 0, '', '', '', '', '', '', '', '', 1, 0),
+(@sName, @sName, 'do_send', '_bx_massmailer_form_entry_input_do_send', '', 0, 'submit', '_bx_massmailer_form_entry_input_sys_do_send', '', '', 0, 0, 0, 0, '', '', '', '', '', '', '', '', 1, 0),
+(@sName, @sName, 'title', '', '', 0, 'text', '_bx_massmailer_form_campaign_input_sys_title', '_bx_massmailer_form_campaign_input_title', '', 1, 0, 0, 2, '', '', '', 'Avail', '', '_bx_massmailer_form_campaign_input_title_err', 'XssHtml', '', 1, 0),
+(@sName, @sName, 'segments', '', '', 0, 'select', '_bx_massmailer_form_campaign_input_sys_segments', '_bx_massmailer_form_campaign_input_segments', '', 1, 0, 0, 2, '', '', '', 'Avail', '', '_bx_massmailer_form_campaign_input_segments_err', 'XssHtml', '', 1, 0),
+(@sName, @sName, 'subject', '', '', 0, 'text', '_bx_massmailer_form_campaign_input_sys_subject', '_bx_massmailer_form_campaign_input_subject', '', 1, 0, 0, 2, '', '', '', 'Avail', '', '_bx_massmailer_form_campaign_input_subject_err', 'XssHtml', '', 1, 0),
+(@sName, @sName, 'from_name', '', '', 0, 'text', '_bx_massmailer_form_campaign_input_sys_from_name', '_bx_massmailer_form_campaign_input_from_name', '_bx_massmailer_form_campaign_input_from_name_info', 1, 0, 0, 2, '', '', '', '', '', '', 'XssHtml', '', 1, 0),
+(@sName, @sName, 'reply_to', '', '', 0, 'text', '_bx_massmailer_form_campaign_input_sys_reply_to', '_bx_massmailer_form_campaign_input_reply_to', '_bx_massmailer_form_campaign_input_reply_to_info', 1, 0, 0, 2, '', '', '', 'EmailOrEmpty', '', '_bx_massmailer_form_campaign_input_reply_to_err', 'XssHtml', '', 1, 0),
+(@sName, @sName, 'body', '', '', 0, 'textarea', '_bx_massmailer_form_campaign_input_sys_body', '_bx_massmailer_form_campaign_input_body', '', 1, 0, 0, 2, '', '', '', 'Avail', '', '_bx_massmailer_form_campaign_input_body_err', 'XssHtml', '', 1, 0),
+(@sName, @sName, 'email', '', '', 0, 'text', '_bx_massmailer_form_campaign_input_sys_test_email', '_bx_massmailer_form_campaign_input_email', '', 1, 0, 0, 2, '', '', '', 'Email', '', '_sys_form_account_input_email_error', 'XssHtml', '', 1, 0),
+(@sName, @sName, 'campaign_info', '_bx_massmailer_form_campaign_input_from_name_info_value', '', 0, 'value', '_bx_massmailer_form_campaign_input_sys_campaign_info', '', '', 0, 0, 0, 2, '', '', '', '', '', '', 'XssHtml', '', 1, 0),
+(@sName, @sName, 'cancel', '_bx_massmailer_form_entry_input_cancel', '', 0, 'button', '_bx_dev_bp_btn_sys_block_cancel', '', '', 0, 0, 0, 0, 'a:2:{s:7:"onclick";s:45:"$(''.bx-popup-applied:visible'').dolPopupHide()";s:5:"class";s:22:"bx-def-margin-sec-left";}', '', '', 'Avail', '', '', '', '', 0, 0),
+(@sName, @sName, 'controls', '', 'do_submit,cancel', 0, 'input_set', '', '', '', 0, 0, 0, 0, '', '', '', '', '', '', '', '', 0, 0),
+(@sName, @sName, 'controls2', '', 'do_send,cancel', 0, 'input_set', '', '', '', 0, 0, 0, 0, '', '', '', '', '', '', '', '', 0, 0);
 
+INSERT INTO `sys_form_display_inputs` (`display_name`, `input_name`, `visible_for_levels`, `active`, `order`) VALUES
+('bx_massmailer_campaign_add', 'title', 2147483647, 1, 1),
+('bx_massmailer_campaign_add', 'segments', 2147483647, 1, 2),
+('bx_massmailer_campaign_add', 'subject', 2147483647, 1, 3),
+('bx_massmailer_campaign_add', 'from_name', 2147483647, 1, 4),
+('bx_massmailer_campaign_add', 'reply_to', 2147483647, 1, 5),
+('bx_massmailer_campaign_add', 'body', 2147483647, 1, 6),
+('bx_massmailer_campaign_add', 'controls', 2147483647, 1, 7),
+('bx_massmailer_campaign_add', 'do_submit', 2147483647, 1, 8),
+('bx_massmailer_campaign_add', 'cancel', 2147483647, 1, 9),
+('bx_massmailer_campaign_edit', 'title', 2147483647, 1, 1),
+('bx_massmailer_campaign_edit', 'segments', 2147483647, 1, 2),
+('bx_massmailer_campaign_edit', 'subject', 2147483647, 1, 3),
+('bx_massmailer_campaign_edit', 'from_name', 2147483647, 1, 4),
+('bx_massmailer_campaign_edit', 'reply_to', 2147483647, 1, 5),
+('bx_massmailer_campaign_edit', 'body', 2147483647, 1, 6),
+('bx_massmailer_campaign_edit', 'controls', 2147483647, 1, 7),
+('bx_massmailer_campaign_edit', 'do_submit', 2147483647, 1, 8),
+('bx_massmailer_campaign_edit', 'cancel', 2147483647, 1, 9),
+('bx_massmailer_campaign_send_test', 'email', 2147483647, 1, 1),
+('bx_massmailer_campaign_send_test', 'controls2', 2147483647, 1, 2),
+('bx_massmailer_campaign_send_test', 'do_send', 2147483647, 1, 3),
+('bx_massmailer_campaign_send_test', 'cancel', 2147483647, 1, 4),
+('bx_massmailer_campaign_send_all', 'campaign_info', 2147483647, 1, 1),
+('bx_massmailer_campaign_send_all', 'controls2', 2147483647, 1, 2),
+('bx_massmailer_campaign_send_all', 'do_send', 2147483647, 1, 3),
+('bx_massmailer_campaign_send_all', 'cancel', 2147483647, 1, 4);

+ 8 - 2
modules/boonex/massmailer/install/sql/uninstall.sql

@@ -1,9 +1,15 @@
 SET @sName = 'bx_massmailer';;
 
 -- TABLES
-DROP TABLE IF EXISTS `bx_massmailer_campaigns`, `bx_massmailer_segments`, `bx_massmailer_letters`;
+DROP TABLE IF EXISTS `bx_massmailer_campaigns`, `bx_massmailer_segments`, `bx_massmailer_letters`, `bx_massmailer_links`, `bx_massmailer_unsubscribe`;
 
 -- STUDIO WIDGET
 DELETE FROM `tp`, `tw`, `tpw`
 USING `sys_std_pages` AS `tp`, `sys_std_widgets` AS `tw`, `sys_std_pages_widgets` AS `tpw`
-WHERE `tp`.`id` = `tw`.`page_id` AND `tw`.`id` = `tpw`.`widget_id` AND `tp`.`name` = @sName;
+WHERE `tp`.`id` = `tw`.`page_id` AND `tw`.`id` = `tpw`.`widget_id` AND `tp`.`name` = @sName;
+
+-- FORMS
+DELETE FROM `sys_objects_form` WHERE `module` = @sName;
+DELETE FROM `sys_form_displays` WHERE `module` = @sName;
+DELETE FROM `sys_form_inputs` WHERE `module` = @sName;
+DELETE FROM `sys_form_display_inputs` WHERE `display_name` IN ('bx_massmailer_campaign_add', 'bx_massmailer_campaign_edit', 'bx_massmailer_campaign_send_test', 'bx_massmailer_campaign_send_all');

+ 90 - 0
modules/boonex/massmailer/js/chart.js

@@ -0,0 +1,90 @@
+/**
+ * Copyright (c) UNA, Inc - https://una.io
+ * MIT License - https://opensource.org/licenses/MIT
+ *
+ * @defgroup    Mass mailer
+ * @ingroup     UnaModules
+ *
+ * @{
+ */
+
+function BxMassMailerChart(oOptions) {
+    this._sContainerId = 'bx_massmailer_chart_container';
+    this._sChartName = oOptions.sChartName;
+    this._sReportName = oOptions.sReportName;
+    this._sReportSelector = '#' + this._sContainerId + " .bx_massmailer_report_selector";
+    this._sSegmentsSelector = '#' + this._sContainerId + " .bx_massmailer_segments_selector";
+    this._sContainerDataSelector = '#' + this._sContainerId + " .bx_analytics_data";
+    this._sCanvasSelector = '#' + this._sContainerId + " .bx_massmailer_chart";
+    this._sDatePickerSelector = '#' + this._sContainerId + " .bx_massmailer_date_picker";
+    this._sActionsUri = oOptions.sActionUri;
+    this._aStrings = oOptions.aStrings;
+    var $this = this;
+    $(document).ready(function () {
+        $this.init(); 
+    });
+}
+
+BxMassMailerChart.prototype.init = function () {
+    var $this = this;
+    if ($($this._sReportSelector).length > 0) {
+        $($this._sReportSelector).change(function () {
+            $this.reloadData();
+        });
+    }
+
+    if ($($this._sSegmentsSelector).length > 0) {
+        $($this._sSegmentsSelector).change(function () {
+            $this.reloadData();
+        });
+    }
+   
+    $($this._sDatePickerSelector).daterangepicker({
+        opens: 'left',
+        locale: {
+            "format": "DD/MM/YYYY"
+        },
+    }, function (start, end, label) {
+        $this.reloadData();
+    });
+    $this.reloadData();
+}
+
+BxMassMailerChart.prototype.reloadData = function () {
+    var $this = this;
+    bx_loading($($this._sContainerDataSelector), true);
+    var sStartDate = $($this._sDatePickerSelector).length > 0 ? $($this._sDatePickerSelector).data('daterangepicker').startDate.format('YYYY-MM-DD') : '-';
+    var sEndDate = $($this._sDatePickerSelector).length > 0 ? $($this._sDatePickerSelector).data('daterangepicker').endDate.format('YYYY-MM-DD') : '-';
+    var sReport = $($this._sReportSelector).length > 0 ? $($this._sReportSelector).val() : $this._sReportName;
+    var sSegment = $($this._sSegmentsSelector).length > 0 ? $($this._sSegmentsSelector).val() : '-';
+    $.getJSON($this._sActionsUri + 'GetReportsData/' + $this._sChartName + '/' + sReport + '/' + sStartDate + '/' + sEndDate + '/' + sSegment + '/', function (oData) {
+
+        Chart.defaults.global.layout = {
+            padding: {
+                left: 40,
+                right: 40,
+                top: 40,
+                bottom: 20
+            }
+        }
+
+        Chart.defaults.scale.ticks.callback = function (label, index, labels) {
+            if (label.length > 10)
+                return label.slice(0, 10) + '...';
+            else
+                return label;
+        };
+        
+        bx_loading($($this._sContainerDataSelector), false);
+        var oDataForChart = oData.data || false;
+        var oChartOptions = oData.options || oOptionsDefault;
+        if ($this._oChart)
+            $this._oChart.destroy();
+        $this._oChart = new Chart($($this._sCanvasSelector), {
+            type: oData.type != undefined ? oData.type : 'line',
+            data: oDataForChart,
+            options: $.extend({}, $this._oChartOptionsDefault, oChartOptions)
+        });
+    });
+};
+/** @} */

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
modules/boonex/massmailer/plugins/datatables/datatables.min.css


+ 392 - 0
modules/boonex/massmailer/plugins/daterangepicker/daterangepicker.css

@@ -0,0 +1,392 @@
+body
+{
+	background:red;
+}
+.daterangepicker {
+  position: absolute;
+  color: inherit;
+  background-color: #fff;
+  border-radius: 4px;
+  border: 1px solid #ddd;
+  width: 278px;
+  max-width: none;
+  padding: 0;
+  margin-top: 7px;
+  top: 100px;
+  left: 20px;
+  z-index: 3001;
+  display: none;
+  font-family: arial;
+  font-size: 15px;
+  line-height: 1em;
+}
+
+.daterangepicker:before, .daterangepicker:after {
+  position: absolute;
+  display: inline-block;
+  border-bottom-color: rgba(0, 0, 0, 0.2);
+  content: '';
+}
+
+.daterangepicker:before {
+  top: -7px;
+  border-right: 7px solid transparent;
+  border-left: 7px solid transparent;
+  border-bottom: 7px solid #ccc;
+}
+
+.daterangepicker:after {
+  top: -6px;
+  border-right: 6px solid transparent;
+  border-bottom: 6px solid #fff;
+  border-left: 6px solid transparent;
+}
+
+.daterangepicker.opensleft:before {
+  right: 9px;
+}
+
+.daterangepicker.opensleft:after {
+  right: 10px;
+}
+
+.daterangepicker.openscenter:before {
+  left: 0;
+  right: 0;
+  width: 0;
+  margin-left: auto;
+  margin-right: auto;
+}
+
+.daterangepicker.openscenter:after {
+  left: 0;
+  right: 0;
+  width: 0;
+  margin-left: auto;
+  margin-right: auto;
+}
+
+.daterangepicker.opensright:before {
+  left: 9px;
+}
+
+.daterangepicker.opensright:after {
+  left: 10px;
+}
+
+.daterangepicker.drop-up {
+  margin-top: -7px;
+}
+
+.daterangepicker.drop-up:before {
+  top: initial;
+  bottom: -7px;
+  border-bottom: initial;
+  border-top: 7px solid #ccc;
+}
+
+.daterangepicker.drop-up:after {
+  top: initial;
+  bottom: -6px;
+  border-bottom: initial;
+  border-top: 6px solid #fff;
+}
+
+.daterangepicker.single .daterangepicker .ranges, .daterangepicker.single .drp-calendar {
+  float: none;
+}
+
+.daterangepicker.single .drp-selected {
+  display: none;
+}
+
+.daterangepicker.show-calendar .drp-calendar {
+  display: block;
+}
+
+.daterangepicker.show-calendar .drp-buttons {
+  display: block;
+}
+
+.daterangepicker.auto-apply .drp-buttons {
+  display: none;
+}
+
+.daterangepicker .drp-calendar {
+  display: none;
+  max-width: 270px;
+}
+
+.daterangepicker .drp-calendar.left {
+  padding: 8px 0 8px 8px;
+}
+
+.daterangepicker .drp-calendar.right {
+  padding: 8px;
+}
+
+.daterangepicker .drp-calendar.single .calendar-table {
+  border: none;
+}
+
+.daterangepicker .calendar-table .next span, .daterangepicker .calendar-table .prev span {
+  color: #fff;
+  border: solid black;
+  border-width: 0 2px 2px 0;
+  border-radius: 0;
+  display: inline-block;
+  padding: 3px;
+}
+
+.daterangepicker .calendar-table .next span {
+  transform: rotate(-45deg);
+  -webkit-transform: rotate(-45deg);
+}
+
+.daterangepicker .calendar-table .prev span {
+  transform: rotate(135deg);
+  -webkit-transform: rotate(135deg);
+}
+
+.daterangepicker .calendar-table th, .daterangepicker .calendar-table td {
+  white-space: nowrap;
+  text-align: center;
+  vertical-align: middle;
+  min-width: 32px;
+  width: 32px;
+  height: 24px;
+  line-height: 24px;
+  font-size: 12px;
+  border-radius: 4px;
+  border: 1px solid transparent;
+  white-space: nowrap;
+  cursor: pointer;
+}
+
+.daterangepicker .calendar-table {
+  border: 1px solid #fff;
+  border-radius: 4px;
+  background-color: #fff;
+}
+
+.daterangepicker .calendar-table table {
+  width: 100%;
+  margin: 0;
+  border-spacing: 0;
+  border-collapse: collapse;
+}
+
+.daterangepicker td.available:hover, .daterangepicker th.available:hover {
+  background-color: #eee;
+  border-color: transparent;
+  color: inherit;
+}
+
+.daterangepicker td.week, .daterangepicker th.week {
+  font-size: 80%;
+  color: #ccc;
+}
+
+.daterangepicker td.off, .daterangepicker td.off.in-range, .daterangepicker td.off.start-date, .daterangepicker td.off.end-date {
+  background-color: #fff;
+  border-color: transparent;
+  color: #999;
+}
+
+.daterangepicker td.in-range {
+  background-color: #ebf4f8;
+  border-color: transparent;
+  color: #000;
+  border-radius: 0;
+}
+
+.daterangepicker td.start-date {
+  border-radius: 4px 0 0 4px;
+}
+
+.daterangepicker td.end-date {
+  border-radius: 0 4px 4px 0;
+}
+
+.daterangepicker td.start-date.end-date {
+  border-radius: 4px;
+}
+
+.daterangepicker td.active, .daterangepicker td.active:hover {
+  background-color: #357ebd;
+  border-color: transparent;
+  color: #fff;
+}
+
+.daterangepicker th.month {
+  width: auto;
+}
+
+.daterangepicker td.disabled, .daterangepicker option.disabled {
+  color: #999;
+  cursor: not-allowed;
+  text-decoration: line-through;
+}
+
+.daterangepicker select.monthselect, .daterangepicker select.yearselect {
+  font-size: 12px;
+  padding: 1px;
+  height: auto;
+  margin: 0;
+  cursor: default;
+}
+
+.daterangepicker select.monthselect {
+  margin-right: 2%;
+  width: 56%;
+}
+
+.daterangepicker select.yearselect {
+  width: 40%;
+}
+
+.daterangepicker select.hourselect, .daterangepicker select.minuteselect, .daterangepicker select.secondselect, .daterangepicker select.ampmselect {
+  width: 50px;
+  margin: 0 auto;
+  background: #eee;
+  border: 1px solid #eee;
+  padding: 2px;
+  outline: 0;
+  font-size: 12px;
+}
+
+.daterangepicker .calendar-time {
+  text-align: center;
+  margin: 4px auto 0 auto;
+  line-height: 30px;
+  position: relative;
+}
+
+.daterangepicker .calendar-time select.disabled {
+  color: #ccc;
+  cursor: not-allowed;
+}
+
+.daterangepicker .drp-buttons {
+  clear: both;
+  text-align: right;
+  padding: 8px;
+  border-top: 1px solid #ddd;
+  display: none;
+  line-height: 12px;
+  vertical-align: middle;
+}
+
+.daterangepicker .drp-selected {
+  display: inline-block;
+  font-size: 12px;
+  padding-right: 8px;
+}
+
+.daterangepicker .drp-buttons .btn {
+  margin-left: 8px;
+  font-size: 12px;
+  font-weight: bold;
+  padding: 4px 8px;
+}
+
+.daterangepicker.show-ranges .drp-calendar.left {
+  border-left: 1px solid #ddd;
+}
+
+.daterangepicker .ranges {
+  float: none;
+  text-align: left;
+  margin: 0;
+}
+
+.daterangepicker.show-calendar .ranges {
+  margin-top: 8px;
+}
+
+.daterangepicker .ranges ul {
+  list-style: none;
+  margin: 0 auto;
+  padding: 0;
+  width: 100%;
+}
+
+.daterangepicker .ranges li {
+  font-size: 12px;
+  padding: 8px 12px;
+  cursor: pointer;
+}
+
+.daterangepicker .ranges li:hover {
+  background-color: #eee;
+}
+
+.daterangepicker .ranges li.active {
+  background-color: #08c;
+  color: #fff;
+}
+
+/*  Larger Screen Styling */
+@media (min-width: 564px) {
+  .daterangepicker {
+    width: auto; }
+    .daterangepicker .ranges ul {
+      width: 140px; }
+    .daterangepicker.single .ranges ul {
+      width: 100%; }
+    .daterangepicker.single .drp-calendar.left {
+      clear: none; }
+    .daterangepicker.single.ltr .ranges, .daterangepicker.single.ltr .drp-calendar {
+      float: left; }
+    .daterangepicker.single.rtl .ranges, .daterangepicker.single.rtl .drp-calendar {
+      float: right; }
+    .daterangepicker.ltr {
+      direction: ltr;
+      text-align: left; }
+      .daterangepicker.ltr .drp-calendar.left {
+        clear: left;
+        margin-right: 0; }
+        .daterangepicker.ltr .drp-calendar.left .calendar-table {
+          border-right: none;
+          border-top-right-radius: 0;
+          border-bottom-right-radius: 0; }
+      .daterangepicker.ltr .drp-calendar.right {
+        margin-left: 0; }
+        .daterangepicker.ltr .drp-calendar.right .calendar-table {
+          border-left: none;
+          border-top-left-radius: 0;
+          border-bottom-left-radius: 0; }
+      .daterangepicker.ltr .drp-calendar.left .calendar-table {
+        padding-right: 8px; }
+      .daterangepicker.ltr .ranges, .daterangepicker.ltr .drp-calendar {
+        float: left; }
+    .daterangepicker.rtl {
+      direction: rtl;
+      text-align: right; }
+      .daterangepicker.rtl .drp-calendar.left {
+        clear: right;
+        margin-left: 0; }
+        .daterangepicker.rtl .drp-calendar.left .calendar-table {
+          border-left: none;
+          border-top-left-radius: 0;
+          border-bottom-left-radius: 0; }
+      .daterangepicker.rtl .drp-calendar.right {
+        margin-right: 0; }
+        .daterangepicker.rtl .drp-calendar.right .calendar-table {
+          border-right: none;
+          border-top-right-radius: 0;
+          border-bottom-right-radius: 0; }
+      .daterangepicker.rtl .drp-calendar.left .calendar-table {
+        padding-left: 12px; }
+      .daterangepicker.rtl .ranges, .daterangepicker.rtl .drp-calendar {
+        text-align: right;
+        float: right; } }
+@media (min-width: 730px) {
+  .daterangepicker .ranges {
+    width: auto; }
+  .daterangepicker.ltr .ranges {
+    float: left; }
+  .daterangepicker.rtl .ranges {
+    float: right; }
+  .daterangepicker .drp-calendar.left {
+    clear: none !important; } }

+ 1525 - 0
modules/boonex/massmailer/plugins/daterangepicker/daterangepicker.js

@@ -0,0 +1,1525 @@
+/**
+* @version: 3.0.3
+* @author: Dan Grossman http://www.dangrossman.info/
+* @copyright: Copyright (c) 2012-2018 Dan Grossman. All rights reserved.
+* @license: Licensed under the MIT license. See http://www.opensource.org/licenses/mit-license.php
+* @website: http://www.daterangepicker.com/
+*/
+// Following the UMD template https://github.com/umdjs/umd/blob/master/templates/returnExportsGlobal.js
+(function (root, factory) {
+    if (typeof define === 'function' && define.amd) {
+        // AMD. Make globaly available as well
+        define(['moment', 'jquery'], function (moment, jquery) {
+            if (!jquery.fn) jquery.fn = {}; // webpack server rendering
+            return factory(moment, jquery);
+        });
+    } else if (typeof module === 'object' && module.exports) {
+        // Node / Browserify
+        //isomorphic issue
+        var jQuery = (typeof window != 'undefined') ? window.jQuery : undefined;
+        if (!jQuery) {
+            jQuery = require('jquery');
+            if (!jQuery.fn) jQuery.fn = {};
+        }
+        var moment = (typeof window != 'undefined' && typeof window.moment != 'undefined') ? window.moment : require('moment');
+        module.exports = factory(moment, jQuery);
+    } else {
+        // Browser globals
+        root.daterangepicker = factory(root.moment, root.jQuery);
+    }
+}(this, function(moment, $) {
+    var DateRangePicker = function(element, options, cb) {
+
+        //default settings for options
+        this.parentEl = 'body';
+        this.element = $(element);
+        this.startDate = moment().startOf('day');
+        this.endDate = moment().endOf('day');
+        this.minDate = false;
+        this.maxDate = false;
+        this.maxSpan = false;
+        this.autoApply = false;
+        this.singleDatePicker = false;
+        this.showDropdowns = false;
+        this.minYear = moment().subtract(100, 'year').format('YYYY');
+        this.maxYear = moment().add(100, 'year').format('YYYY');
+        this.showWeekNumbers = false;
+        this.showISOWeekNumbers = false;
+        this.showCustomRangeLabel = true;
+        this.timePicker = false;
+        this.timePicker24Hour = false;
+        this.timePickerIncrement = 1;
+        this.timePickerSeconds = false;
+        this.linkedCalendars = true;
+        this.autoUpdateInput = true;
+        this.alwaysShowCalendars = false;
+        this.ranges = {};
+
+        this.opens = 'right';
+        if (this.element.hasClass('pull-right'))
+            this.opens = 'left';
+
+        this.drops = 'down';
+        if (this.element.hasClass('dropup'))
+            this.drops = 'up';
+
+        this.buttonClasses = 'btn btn-sm';
+        this.applyButtonClasses = 'btn-primary';
+        this.cancelButtonClasses = 'btn-default';
+
+        this.locale = {
+            direction: 'ltr',
+            format: moment.localeData().longDateFormat('L'),
+            separator: ' - ',
+            applyLabel: 'Apply',
+            cancelLabel: 'Cancel',
+            weekLabel: 'W',
+            customRangeLabel: 'Custom Range',
+            daysOfWeek: moment.weekdaysMin(),
+            monthNames: moment.monthsShort(),
+            firstDay: moment.localeData().firstDayOfWeek()
+        };
+
+        this.callback = function() { };
+
+        //some state information
+        this.isShowing = false;
+        this.leftCalendar = {};
+        this.rightCalendar = {};
+
+        //custom options from user
+        if (typeof options !== 'object' || options === null)
+            options = {};
+
+        //allow setting options with data attributes
+        //data-api options will be overwritten with custom javascript options
+        options = $.extend(this.element.data(), options);
+
+        //html template for the picker UI
+        if (typeof options.template !== 'string' && !(options.template instanceof $))
+            options.template =
+            '<div class="daterangepicker">' +
+                '<div class="ranges"></div>' +
+                '<div class="drp-calendar left">' +
+                    '<div class="calendar-table"></div>' +
+                    '<div class="calendar-time"></div>' +
+                '</div>' +
+                '<div class="drp-calendar right">' +
+                    '<div class="calendar-table"></div>' +
+                    '<div class="calendar-time"></div>' +
+                '</div>' +
+                '<div class="drp-buttons">' +
+                    '<span class="drp-selected"></span>' +
+                    '<button class="cancelBtn" type="button"></button>' +
+                    '<button class="applyBtn" disabled="disabled" type="button"></button> ' +
+                '</div>' +
+            '</div>';
+
+        this.parentEl = (options.parentEl && $(options.parentEl).length) ? $(options.parentEl) : $(this.parentEl);
+        this.container = $(options.template).appendTo(this.parentEl);
+
+        //
+        // handle all the possible options overriding defaults
+        //
+
+        if (typeof options.locale === 'object') {
+
+            if (typeof options.locale.direction === 'string')
+                this.locale.direction = options.locale.direction;
+
+            if (typeof options.locale.format === 'string')
+                this.locale.format = options.locale.format;
+
+            if (typeof options.locale.separator === 'string')
+                this.locale.separator = options.locale.separator;
+
+            if (typeof options.locale.daysOfWeek === 'object')
+                this.locale.daysOfWeek = options.locale.daysOfWeek.slice();
+
+            if (typeof options.locale.monthNames === 'object')
+              this.locale.monthNames = options.locale.monthNames.slice();
+
+            if (typeof options.locale.firstDay === 'number')
+              this.locale.firstDay = options.locale.firstDay;
+
+            if (typeof options.locale.applyLabel === 'string')
+              this.locale.applyLabel = options.locale.applyLabel;
+
+            if (typeof options.locale.cancelLabel === 'string')
+              this.locale.cancelLabel = options.locale.cancelLabel;
+
+            if (typeof options.locale.weekLabel === 'string')
+              this.locale.weekLabel = options.locale.weekLabel;
+
+            if (typeof options.locale.customRangeLabel === 'string'){
+                //Support unicode chars in the custom range name.
+                var elem = document.createElement('textarea');
+                elem.innerHTML = options.locale.customRangeLabel;
+                var rangeHtml = elem.value;
+                this.locale.customRangeLabel = rangeHtml;
+            }
+        }
+        this.container.addClass(this.locale.direction);
+
+        if (typeof options.startDate === 'string')
+            this.startDate = moment(options.startDate, this.locale.format);
+
+        if (typeof options.endDate === 'string')
+            this.endDate = moment(options.endDate, this.locale.format);
+
+        if (typeof options.minDate === 'string')
+            this.minDate = moment(options.minDate, this.locale.format);
+
+        if (typeof options.maxDate === 'string')
+            this.maxDate = moment(options.maxDate, this.locale.format);
+
+        if (typeof options.startDate === 'object')
+            this.startDate = moment(options.startDate);
+
+        if (typeof options.endDate === 'object')
+            this.endDate = moment(options.endDate);
+
+        if (typeof options.minDate === 'object')
+            this.minDate = moment(options.minDate);
+
+        if (typeof options.maxDate === 'object')
+            this.maxDate = moment(options.maxDate);
+
+        // sanity check for bad options
+        if (this.minDate && this.startDate.isBefore(this.minDate))
+            this.startDate = this.minDate.clone();
+
+        // sanity check for bad options
+        if (this.maxDate && this.endDate.isAfter(this.maxDate))
+            this.endDate = this.maxDate.clone();
+
+        if (typeof options.applyButtonClasses === 'string')
+            this.applyButtonClasses = options.applyButtonClasses;
+
+        if (typeof options.applyClass === 'string') //backwards compat
+            this.applyButtonClasses = options.applyClass;
+
+        if (typeof options.cancelButtonClasses === 'string')
+            this.cancelButtonClasses = options.cancelButtonClasses;
+
+        if (typeof options.cancelClass === 'string') //backwards compat
+            this.cancelButtonClasses = options.cancelClass;
+
+        if (typeof options.maxSpan === 'object')
+            this.maxSpan = options.maxSpan;
+
+        if (typeof options.dateLimit === 'object') //backwards compat
+            this.maxSpan = options.dateLimit;
+
+        if (typeof options.opens === 'string')
+            this.opens = options.opens;
+
+        if (typeof options.drops === 'string')
+            this.drops = options.drops;
+
+        if (typeof options.showWeekNumbers === 'boolean')
+            this.showWeekNumbers = options.showWeekNumbers;
+
+        if (typeof options.showISOWeekNumbers === 'boolean')
+            this.showISOWeekNumbers = options.showISOWeekNumbers;
+
+        if (typeof options.buttonClasses === 'string')
+            this.buttonClasses = options.buttonClasses;
+
+        if (typeof options.buttonClasses === 'object')
+            this.buttonClasses = options.buttonClasses.join(' ');
+
+        if (typeof options.showDropdowns === 'boolean')
+            this.showDropdowns = options.showDropdowns;
+
+        if (typeof options.minYear === 'number')
+            this.minYear = options.minYear;
+
+        if (typeof options.maxYear === 'number')
+            this.maxYear = options.maxYear;
+
+        if (typeof options.showCustomRangeLabel === 'boolean')
+            this.showCustomRangeLabel = options.showCustomRangeLabel;
+
+        if (typeof options.singleDatePicker === 'boolean') {
+            this.singleDatePicker = options.singleDatePicker;
+            if (this.singleDatePicker)
+                this.endDate = this.startDate.clone();
+        }
+
+        if (typeof options.timePicker === 'boolean')
+            this.timePicker = options.timePicker;
+
+        if (typeof options.timePickerSeconds === 'boolean')
+            this.timePickerSeconds = options.timePickerSeconds;
+
+        if (typeof options.timePickerIncrement === 'number')
+            this.timePickerIncrement = options.timePickerIncrement;
+
+        if (typeof options.timePicker24Hour === 'boolean')
+            this.timePicker24Hour = options.timePicker24Hour;
+
+        if (typeof options.autoApply === 'boolean')
+            this.autoApply = options.autoApply;
+
+        if (typeof options.autoUpdateInput === 'boolean')
+            this.autoUpdateInput = options.autoUpdateInput;
+
+        if (typeof options.linkedCalendars === 'boolean')
+            this.linkedCalendars = options.linkedCalendars;
+
+        if (typeof options.isInvalidDate === 'function')
+            this.isInvalidDate = options.isInvalidDate;
+
+        if (typeof options.isCustomDate === 'function')
+            this.isCustomDate = options.isCustomDate;
+
+        if (typeof options.alwaysShowCalendars === 'boolean')
+            this.alwaysShowCalendars = options.alwaysShowCalendars;
+
+        // update day names order to firstDay
+        if (this.locale.firstDay != 0) {
+            var iterator = this.locale.firstDay;
+            while (iterator > 0) {
+                this.locale.daysOfWeek.push(this.locale.daysOfWeek.shift());
+                iterator--;
+            }
+        }
+
+        var start, end, range;
+
+        //if no start/end dates set, check if an input element contains initial values
+        if (typeof options.startDate === 'undefined' && typeof options.endDate === 'undefined') {
+            if ($(this.element).is(':text')) {
+                var val = $(this.element).val(),
+                    split = val.split(this.locale.separator);
+
+                start = end = null;
+
+                if (split.length == 2) {
+                    start = moment(split[0], this.locale.format);
+                    end = moment(split[1], this.locale.format);
+                } else if (this.singleDatePicker && val !== "") {
+                    start = moment(val, this.locale.format);
+                    end = moment(val, this.locale.format);
+                }
+                if (start !== null && end !== null) {
+                    this.setStartDate(start);
+                    this.setEndDate(end);
+                }
+            }
+        }
+
+        if (typeof options.ranges === 'object') {
+            for (range in options.ranges) {
+
+                if (typeof options.ranges[range][0] === 'string')
+                    start = moment(options.ranges[range][0], this.locale.format);
+                else
+                    start = moment(options.ranges[range][0]);
+
+                if (typeof options.ranges[range][1] === 'string')
+                    end = moment(options.ranges[range][1], this.locale.format);
+                else
+                    end = moment(options.ranges[range][1]);
+
+                // If the start or end date exceed those allowed by the minDate or maxSpan
+                // options, shorten the range to the allowable period.
+                if (this.minDate && start.isBefore(this.minDate))
+                    start = this.minDate.clone();
+
+                var maxDate = this.maxDate;
+                if (this.maxSpan && maxDate && start.clone().add(this.maxSpan).isAfter(maxDate))
+                    maxDate = start.clone().add(this.maxSpan);
+                if (maxDate && end.isAfter(maxDate))
+                    end = maxDate.clone();
+
+                // If the end of the range is before the minimum or the start of the range is
+                // after the maximum, don't display this range option at all.
+                if ((this.minDate && end.isBefore(this.minDate, this.timepicker ? 'minute' : 'day'))
+                  || (maxDate && start.isAfter(maxDate, this.timepicker ? 'minute' : 'day')))
+                    continue;
+
+                //Support unicode chars in the range names.
+                var elem = document.createElement('textarea');
+                elem.innerHTML = range;
+                var rangeHtml = elem.value;
+
+                this.ranges[rangeHtml] = [start, end];
+            }
+
+            var list = '<ul>';
+            for (range in this.ranges) {
+                list += '<li data-range-key="' + range + '">' + range + '</li>';
+            }
+            if (this.showCustomRangeLabel) {
+                list += '<li data-range-key="' + this.locale.customRangeLabel + '">' + this.locale.customRangeLabel + '</li>';
+            }
+            list += '</ul>';
+            this.container.find('.ranges').prepend(list);
+        }
+
+        if (typeof cb === 'function') {
+            this.callback = cb;
+        }
+
+        if (!this.timePicker) {
+            this.startDate = this.startDate.startOf('day');
+            this.endDate = this.endDate.endOf('day');
+            this.container.find('.calendar-time').hide();
+        }
+
+        //can't be used together for now
+        if (this.timePicker && this.autoApply)
+            this.autoApply = false;
+
+        if (this.autoApply) {
+            this.container.addClass('auto-apply');
+        }
+
+        if (typeof options.ranges === 'object')
+            this.container.addClass('show-ranges');
+
+        if (this.singleDatePicker) {
+            this.container.addClass('single');
+            this.container.find('.drp-calendar.left').addClass('single');
+            this.container.find('.drp-calendar.left').show();
+            this.container.find('.drp-calendar.right').hide();
+            if (!this.timePicker) {
+                this.container.addClass('auto-apply');
+            }
+        }
+
+        if ((typeof options.ranges === 'undefined' && !this.singleDatePicker) || this.alwaysShowCalendars) {
+            this.container.addClass('show-calendar');
+        }
+
+        this.container.addClass('opens' + this.opens);
+
+        //apply CSS classes and labels to buttons
+        this.container.find('.applyBtn, .cancelBtn').addClass(this.buttonClasses);
+        if (this.applyButtonClasses.length)
+            this.container.find('.applyBtn').addClass(this.applyButtonClasses);
+        if (this.cancelButtonClasses.length)
+            this.container.find('.cancelBtn').addClass(this.cancelButtonClasses);
+        this.container.find('.applyBtn').html(this.locale.applyLabel);
+        this.container.find('.cancelBtn').html(this.locale.cancelLabel);
+
+        //
+        // event listeners
+        //
+
+        this.container.find('.drp-calendar')
+            .on('click.daterangepicker', '.prev', $.proxy(this.clickPrev, this))
+            .on('click.daterangepicker', '.next', $.proxy(this.clickNext, this))
+            .on('mousedown.daterangepicker', 'td.available', $.proxy(this.clickDate, this))
+            .on('mouseenter.daterangepicker', 'td.available', $.proxy(this.hoverDate, this))
+            .on('change.daterangepicker', 'select.yearselect', $.proxy(this.monthOrYearChanged, this))
+            .on('change.daterangepicker', 'select.monthselect', $.proxy(this.monthOrYearChanged, this))
+            .on('change.daterangepicker', 'select.hourselect,select.minuteselect,select.secondselect,select.ampmselect', $.proxy(this.timeChanged, this))
+
+        this.container.find('.ranges')
+            .on('click.daterangepicker', 'li', $.proxy(this.clickRange, this))
+
+        this.container.find('.drp-buttons')
+            .on('click.daterangepicker', 'button.applyBtn', $.proxy(this.clickApply, this))
+            .on('click.daterangepicker', 'button.cancelBtn', $.proxy(this.clickCancel, this))
+
+        if (this.element.is('input') || this.element.is('button')) {
+            this.element.on({
+                'click.daterangepicker': $.proxy(this.show, this),
+                'focus.daterangepicker': $.proxy(this.show, this),
+                'keyup.daterangepicker': $.proxy(this.elementChanged, this),
+                'keydown.daterangepicker': $.proxy(this.keydown, this) //IE 11 compatibility
+            });
+        } else {
+            this.element.on('click.daterangepicker', $.proxy(this.toggle, this));
+            this.element.on('keydown.daterangepicker', $.proxy(this.toggle, this));
+        }
+
+        //
+        // if attached to a text input, set the initial value
+        //
+
+        this.updateElement();
+
+    };
+
+    DateRangePicker.prototype = {
+
+        constructor: DateRangePicker,
+
+        setStartDate: function(startDate) {
+            if (typeof startDate === 'string')
+                this.startDate = moment(startDate, this.locale.format);
+
+            if (typeof startDate === 'object')
+                this.startDate = moment(startDate);
+
+            if (!this.timePicker)
+                this.startDate = this.startDate.startOf('day');
+
+            if (this.timePicker && this.timePickerIncrement)
+                this.startDate.minute(Math.round(this.startDate.minute() / this.timePickerIncrement) * this.timePickerIncrement);
+
+            if (this.minDate && this.startDate.isBefore(this.minDate)) {
+                this.startDate = this.minDate.clone();
+                if (this.timePicker && this.timePickerIncrement)
+                    this.startDate.minute(Math.round(this.startDate.minute() / this.timePickerIncrement) * this.timePickerIncrement);
+            }
+
+            if (this.maxDate && this.startDate.isAfter(this.maxDate)) {
+                this.startDate = this.maxDate.clone();
+                if (this.timePicker && this.timePickerIncrement)
+                    this.startDate.minute(Math.floor(this.startDate.minute() / this.timePickerIncrement) * this.timePickerIncrement);
+            }
+
+            if (!this.isShowing)
+                this.updateElement();
+
+            this.updateMonthsInView();
+        },
+
+        setEndDate: function(endDate) {
+            if (typeof endDate === 'string')
+                this.endDate = moment(endDate, this.locale.format);
+
+            if (typeof endDate === 'object')
+                this.endDate = moment(endDate);
+
+            if (!this.timePicker)
+                this.endDate = this.endDate.add(1,'d').startOf('day').subtract(1,'second');
+
+            if (this.timePicker && this.timePickerIncrement)
+                this.endDate.minute(Math.round(this.endDate.minute() / this.timePickerIncrement) * this.timePickerIncrement);
+
+            if (this.endDate.isBefore(this.startDate))
+                this.endDate = this.startDate.clone();
+
+            if (this.maxDate && this.endDate.isAfter(this.maxDate))
+                this.endDate = this.maxDate.clone();
+
+            if (this.maxSpan && this.startDate.clone().add(this.maxSpan).isBefore(this.endDate))
+                this.endDate = this.startDate.clone().add(this.maxSpan);
+
+            this.previousRightTime = this.endDate.clone();
+
+            this.container.find('.drp-selected').html(this.startDate.format(this.locale.format) + this.locale.separator + this.endDate.format(this.locale.format));
+
+            if (!this.isShowing)
+                this.updateElement();
+
+            this.updateMonthsInView();
+        },
+
+        isInvalidDate: function() {
+            return false;
+        },
+
+        isCustomDate: function() {
+            return false;
+        },
+
+        updateView: function() {
+            if (this.timePicker) {
+                this.renderTimePicker('left');
+                this.renderTimePicker('right');
+                if (!this.endDate) {
+                    this.container.find('.right .calendar-time select').attr('disabled', 'disabled').addClass('disabled');
+                } else {
+                    this.container.find('.right .calendar-time select').removeAttr('disabled').removeClass('disabled');
+                }
+            }
+            if (this.endDate)
+                this.container.find('.drp-selected').html(this.startDate.format(this.locale.format) + this.locale.separator + this.endDate.format(this.locale.format));
+            this.updateMonthsInView();
+            this.updateCalendars();
+            this.updateFormInputs();
+        },
+
+        updateMonthsInView: function() {
+            if (this.endDate) {
+
+                //if both dates are visible already, do nothing
+                if (!this.singleDatePicker && this.leftCalendar.month && this.rightCalendar.month &&
+                    (this.startDate.format('YYYY-MM') == this.leftCalendar.month.format('YYYY-MM') || this.startDate.format('YYYY-MM') == this.rightCalendar.month.format('YYYY-MM'))
+                    &&
+                    (this.endDate.format('YYYY-MM') == this.leftCalendar.month.format('YYYY-MM') || this.endDate.format('YYYY-MM') == this.rightCalendar.month.format('YYYY-MM'))
+                    ) {
+                    return;
+                }
+
+                this.leftCalendar.month = this.startDate.clone().date(2);
+                if (!this.linkedCalendars && (this.endDate.month() != this.startDate.month() || this.endDate.year() != this.startDate.year())) {
+                    this.rightCalendar.month = this.endDate.clone().date(2);
+                } else {
+                    this.rightCalendar.month = this.startDate.clone().date(2).add(1, 'month');
+                }
+
+            } else {
+                if (this.leftCalendar.month.format('YYYY-MM') != this.startDate.format('YYYY-MM') && this.rightCalendar.month.format('YYYY-MM') != this.startDate.format('YYYY-MM')) {
+                    this.leftCalendar.month = this.startDate.clone().date(2);
+                    this.rightCalendar.month = this.startDate.clone().date(2).add(1, 'month');
+                }
+            }
+            if (this.maxDate && this.linkedCalendars && !this.singleDatePicker && this.rightCalendar.month > this.maxDate) {
+              this.rightCalendar.month = this.maxDate.clone().date(2);
+              this.leftCalendar.month = this.maxDate.clone().date(2).subtract(1, 'month');
+            }
+        },
+
+        updateCalendars: function() {
+
+            if (this.timePicker) {
+                var hour, minute, second;
+                if (this.endDate) {
+                    hour = parseInt(this.container.find('.left .hourselect').val(), 10);
+                    minute = parseInt(this.container.find('.left .minuteselect').val(), 10);
+                    second = this.timePickerSeconds ? parseInt(this.container.find('.left .secondselect').val(), 10) : 0;
+                    if (!this.timePicker24Hour) {
+                        var ampm = this.container.find('.left .ampmselect').val();
+                        if (ampm === 'PM' && hour < 12)
+                            hour += 12;
+                        if (ampm === 'AM' && hour === 12)
+                            hour = 0;
+                    }
+                } else {
+                    hour = parseInt(this.container.find('.right .hourselect').val(), 10);
+                    minute = parseInt(this.container.find('.right .minuteselect').val(), 10);
+                    second = this.timePickerSeconds ? parseInt(this.container.find('.right .secondselect').val(), 10) : 0;
+                    if (!this.timePicker24Hour) {
+                        var ampm = this.container.find('.right .ampmselect').val();
+                        if (ampm === 'PM' && hour < 12)
+                            hour += 12;
+                        if (ampm === 'AM' && hour === 12)
+                            hour = 0;
+                    }
+                }
+                this.leftCalendar.month.hour(hour).minute(minute).second(second);
+                this.rightCalendar.month.hour(hour).minute(minute).second(second);
+            }
+
+            this.renderCalendar('left');
+            this.renderCalendar('right');
+
+            //highlight any predefined range matching the current start and end dates
+            this.container.find('.ranges li').removeClass('active');
+            if (this.endDate == null) return;
+
+            this.calculateChosenLabel();
+        },
+
+        renderCalendar: function(side) {
+
+            //
+            // Build the matrix of dates that will populate the calendar
+            //
+
+            var calendar = side == 'left' ? this.leftCalendar : this.rightCalendar;
+            var month = calendar.month.month();
+            var year = calendar.month.year();
+            var hour = calendar.month.hour();
+            var minute = calendar.month.minute();
+            var second = calendar.month.second();
+            var daysInMonth = moment([year, month]).daysInMonth();
+            var firstDay = moment([year, month, 1]);
+            var lastDay = moment([year, month, daysInMonth]);
+            var lastMonth = moment(firstDay).subtract(1, 'month').month();
+            var lastYear = moment(firstDay).subtract(1, 'month').year();
+            var daysInLastMonth = moment([lastYear, lastMonth]).daysInMonth();
+            var dayOfWeek = firstDay.day();
+
+            //initialize a 6 rows x 7 columns array for the calendar
+            var calendar = [];
+            calendar.firstDay = firstDay;
+            calendar.lastDay = lastDay;
+
+            for (var i = 0; i < 6; i++) {
+                calendar[i] = [];
+            }
+
+            //populate the calendar with date objects
+            var startDay = daysInLastMonth - dayOfWeek + this.locale.firstDay + 1;
+            if (startDay > daysInLastMonth)
+                startDay -= 7;
+
+            if (dayOfWeek == this.locale.firstDay)
+                startDay = daysInLastMonth - 6;
+
+            var curDate = moment([lastYear, lastMonth, startDay, 12, minute, second]);
+
+            var col, row;
+            for (var i = 0, col = 0, row = 0; i < 42; i++, col++, curDate = moment(curDate).add(24, 'hour')) {
+                if (i > 0 && col % 7 === 0) {
+                    col = 0;
+                    row++;
+                }
+                calendar[row][col] = curDate.clone().hour(hour).minute(minute).second(second);
+                curDate.hour(12);
+
+                if (this.minDate && calendar[row][col].format('YYYY-MM-DD') == this.minDate.format('YYYY-MM-DD') && calendar[row][col].isBefore(this.minDate) && side == 'left') {
+                    calendar[row][col] = this.minDate.clone();
+                }
+
+                if (this.maxDate && calendar[row][col].format('YYYY-MM-DD') == this.maxDate.format('YYYY-MM-DD') && calendar[row][col].isAfter(this.maxDate) && side == 'right') {
+                    calendar[row][col] = this.maxDate.clone();
+                }
+
+            }
+
+            //make the calendar object available to hoverDate/clickDate
+            if (side == 'left') {
+                this.leftCalendar.calendar = calendar;
+            } else {
+                this.rightCalendar.calendar = calendar;
+            }
+
+            //
+            // Display the calendar
+            //
+
+            var minDate = side == 'left' ? this.minDate : this.startDate;
+            var maxDate = this.maxDate;
+            var selected = side == 'left' ? this.startDate : this.endDate;
+            var arrow = this.locale.direction == 'ltr' ? {left: 'chevron-left', right: 'chevron-right'} : {left: 'chevron-right', right: 'chevron-left'};
+
+            var html = '<table class="table-condensed">';
+            html += '<thead>';
+            html += '<tr>';
+
+            // add empty cell for week number
+            if (this.showWeekNumbers || this.showISOWeekNumbers)
+                html += '<th></th>';
+
+            if ((!minDate || minDate.isBefore(calendar.firstDay)) && (!this.linkedCalendars || side == 'left')) {
+                html += '<th class="prev available"><span></span></th>';
+            } else {
+                html += '<th></th>';
+            }
+
+            var dateHtml = this.locale.monthNames[calendar[1][1].month()] + calendar[1][1].format(" YYYY");
+
+            if (this.showDropdowns) {
+                var currentMonth = calendar[1][1].month();
+                var currentYear = calendar[1][1].year();
+                var maxYear = (maxDate && maxDate.year()) || (this.maxYear);
+                var minYear = (minDate && minDate.year()) || (this.minYear);
+                var inMinYear = currentYear == minYear;
+                var inMaxYear = currentYear == maxYear;
+
+                var monthHtml = '<select class="monthselect">';
+                for (var m = 0; m < 12; m++) {
+                    if ((!inMinYear || m >= minDate.month()) && (!inMaxYear || m <= maxDate.month())) {
+                        monthHtml += "<option value='" + m + "'" +
+                            (m === currentMonth ? " selected='selected'" : "") +
+                            ">" + this.locale.monthNames[m] + "</option>";
+                    } else {
+                        monthHtml += "<option value='" + m + "'" +
+                            (m === currentMonth ? " selected='selected'" : "") +
+                            " disabled='disabled'>" + this.locale.monthNames[m] + "</option>";
+                    }
+                }
+                monthHtml += "</select>";
+
+                var yearHtml = '<select class="yearselect">';
+                for (var y = minYear; y <= maxYear; y++) {
+                    yearHtml += '<option value="' + y + '"' +
+                        (y === currentYear ? ' selected="selected"' : '') +
+                        '>' + y + '</option>';
+                }
+                yearHtml += '</select>';
+
+                dateHtml = monthHtml + yearHtml;
+            }
+
+            html += '<th colspan="5" class="month">' + dateHtml + '</th>';
+            if ((!maxDate || maxDate.isAfter(calendar.lastDay)) && (!this.linkedCalendars || side == 'right' || this.singleDatePicker)) {
+                html += '<th class="next available"><span></span></th>';
+            } else {
+                html += '<th></th>';
+            }
+
+            html += '</tr>';
+            html += '<tr>';
+
+            // add week number label
+            if (this.showWeekNumbers || this.showISOWeekNumbers)
+                html += '<th class="week">' + this.locale.weekLabel + '</th>';
+
+            $.each(this.locale.daysOfWeek, function(index, dayOfWeek) {
+                html += '<th>' + dayOfWeek + '</th>';
+            });
+
+            html += '</tr>';
+            html += '</thead>';
+            html += '<tbody>';
+
+            //adjust maxDate to reflect the maxSpan setting in order to
+            //grey out end dates beyond the maxSpan
+            if (this.endDate == null && this.maxSpan) {
+                var maxLimit = this.startDate.clone().add(this.maxSpan).endOf('day');
+                if (!maxDate || maxLimit.isBefore(maxDate)) {
+                    maxDate = maxLimit;
+                }
+            }
+
+            for (var row = 0; row < 6; row++) {
+                html += '<tr>';
+
+                // add week number
+                if (this.showWeekNumbers)
+                    html += '<td class="week">' + calendar[row][0].week() + '</td>';
+                else if (this.showISOWeekNumbers)
+                    html += '<td class="week">' + calendar[row][0].isoWeek() + '</td>';
+
+                for (var col = 0; col < 7; col++) {
+
+                    var classes = [];
+
+                    //highlight today's date
+                    if (calendar[row][col].isSame(new Date(), "day"))
+                        classes.push('today');
+
+                    //highlight weekends
+                    if (calendar[row][col].isoWeekday() > 5)
+                        classes.push('weekend');
+
+                    //grey out the dates in other months displayed at beginning and end of this calendar
+                    if (calendar[row][col].month() != calendar[1][1].month())
+                        classes.push('off');
+
+                    //don't allow selection of dates before the minimum date
+                    if (this.minDate && calendar[row][col].isBefore(this.minDate, 'day'))
+                        classes.push('off', 'disabled');
+
+                    //don't allow selection of dates after the maximum date
+                    if (maxDate && calendar[row][col].isAfter(maxDate, 'day'))
+                        classes.push('off', 'disabled');
+
+                    //don't allow selection of date if a custom function decides it's invalid
+                    if (this.isInvalidDate(calendar[row][col]))
+                        classes.push('off', 'disabled');
+
+                    //highlight the currently selected start date
+                    if (calendar[row][col].format('YYYY-MM-DD') == this.startDate.format('YYYY-MM-DD'))
+                        classes.push('active', 'start-date');
+
+                    //highlight the currently selected end date
+                    if (this.endDate != null && calendar[row][col].format('YYYY-MM-DD') == this.endDate.format('YYYY-MM-DD'))
+                        classes.push('active', 'end-date');
+
+                    //highlight dates in-between the selected dates
+                    if (this.endDate != null && calendar[row][col] > this.startDate && calendar[row][col] < this.endDate)
+                        classes.push('in-range');
+
+                    //apply custom classes for this date
+                    var isCustom = this.isCustomDate(calendar[row][col]);
+                    if (isCustom !== false) {
+                        if (typeof isCustom === 'string')
+                            classes.push(isCustom);
+                        else
+                            Array.prototype.push.apply(classes, isCustom);
+                    }
+
+                    var cname = '', disabled = false;
+                    for (var i = 0; i < classes.length; i++) {
+                        cname += classes[i] + ' ';
+                        if (classes[i] == 'disabled')
+                            disabled = true;
+                    }
+                    if (!disabled)
+                        cname += 'available';
+
+                    html += '<td class="' + cname.replace(/^\s+|\s+$/g, '') + '" data-title="' + 'r' + row + 'c' + col + '">' + calendar[row][col].date() + '</td>';
+
+                }
+                html += '</tr>';
+            }
+
+            html += '</tbody>';
+            html += '</table>';
+
+            this.container.find('.drp-calendar.' + side + ' .calendar-table').html(html);
+
+        },
+
+        renderTimePicker: function(side) {
+
+            // Don't bother updating the time picker if it's currently disabled
+            // because an end date hasn't been clicked yet
+            if (side == 'right' && !this.endDate) return;
+
+            var html, selected, minDate, maxDate = this.maxDate;
+
+            if (this.maxSpan && (!this.maxDate || this.startDate.clone().add(this.maxSpan).isAfter(this.maxDate)))
+                maxDate = this.startDate.clone().add(this.maxSpan);
+
+            if (side == 'left') {
+                selected = this.startDate.clone();
+                minDate = this.minDate;
+            } else if (side == 'right') {
+                selected = this.endDate.clone();
+                minDate = this.startDate;
+
+                //Preserve the time already selected
+                var timeSelector = this.container.find('.drp-calendar.right .calendar-time');
+                if (timeSelector.html() != '') {
+
+                    selected.hour(selected.hour() || timeSelector.find('.hourselect option:selected').val());
+                    selected.minute(selected.minute() || timeSelector.find('.minuteselect option:selected').val());
+                    selected.second(selected.second() || timeSelector.find('.secondselect option:selected').val());
+
+                    if (!this.timePicker24Hour) {
+                        var ampm = timeSelector.find('.ampmselect option:selected').val();
+                        if (ampm === 'PM' && selected.hour() < 12)
+                            selected.hour(selected.hour() + 12);
+                        if (ampm === 'AM' && selected.hour() === 12)
+                            selected.hour(0);
+                    }
+
+                }
+
+                if (selected.isBefore(this.startDate))
+                    selected = this.startDate.clone();
+
+                if (maxDate && selected.isAfter(maxDate))
+                    selected = maxDate.clone();
+
+            }
+
+            //
+            // hours
+            //
+
+            html = '<select class="hourselect">';
+
+            var start = this.timePicker24Hour ? 0 : 1;
+            var end = this.timePicker24Hour ? 23 : 12;
+
+            for (var i = start; i <= end; i++) {
+                var i_in_24 = i;
+                if (!this.timePicker24Hour)
+                    i_in_24 = selected.hour() >= 12 ? (i == 12 ? 12 : i + 12) : (i == 12 ? 0 : i);
+
+                var time = selected.clone().hour(i_in_24);
+                var disabled = false;
+                if (minDate && time.minute(59).isBefore(minDate))
+                    disabled = true;
+                if (maxDate && time.minute(0).isAfter(maxDate))
+                    disabled = true;
+
+                if (i_in_24 == selected.hour() && !disabled) {
+                    html += '<option value="' + i + '" selected="selected">' + i + '</option>';
+                } else if (disabled) {
+                    html += '<option value="' + i + '" disabled="disabled" class="disabled">' + i + '</option>';
+                } else {
+                    html += '<option value="' + i + '">' + i + '</option>';
+                }
+            }
+
+            html += '</select> ';
+
+            //
+            // minutes
+            //
+
+            html += ': <select class="minuteselect">';
+
+            for (var i = 0; i < 60; i += this.timePickerIncrement) {
+                var padded = i < 10 ? '0' + i : i;
+                var time = selected.clone().minute(i);
+
+                var disabled = false;
+                if (minDate && time.second(59).isBefore(minDate))
+                    disabled = true;
+                if (maxDate && time.second(0).isAfter(maxDate))
+                    disabled = true;
+
+                if (selected.minute() == i && !disabled) {
+                    html += '<option value="' + i + '" selected="selected">' + padded + '</option>';
+                } else if (disabled) {
+                    html += '<option value="' + i + '" disabled="disabled" class="disabled">' + padded + '</option>';
+                } else {
+                    html += '<option value="' + i + '">' + padded + '</option>';
+                }
+            }
+
+            html += '</select> ';
+
+            //
+            // seconds
+            //
+
+            if (this.timePickerSeconds) {
+                html += ': <select class="secondselect">';
+
+                for (var i = 0; i < 60; i++) {
+                    var padded = i < 10 ? '0' + i : i;
+                    var time = selected.clone().second(i);
+
+                    var disabled = false;
+                    if (minDate && time.isBefore(minDate))
+                        disabled = true;
+                    if (maxDate && time.isAfter(maxDate))
+                        disabled = true;
+
+                    if (selected.second() == i && !disabled) {
+                        html += '<option value="' + i + '" selected="selected">' + padded + '</option>';
+                    } else if (disabled) {
+                        html += '<option value="' + i + '" disabled="disabled" class="disabled">' + padded + '</option>';
+                    } else {
+                        html += '<option value="' + i + '">' + padded + '</option>';
+                    }
+                }
+
+                html += '</select> ';
+            }
+
+            //
+            // AM/PM
+            //
+
+            if (!this.timePicker24Hour) {
+                html += '<select class="ampmselect">';
+
+                var am_html = '';
+                var pm_html = '';
+
+                if (minDate && selected.clone().hour(12).minute(0).second(0).isBefore(minDate))
+                    am_html = ' disabled="disabled" class="disabled"';
+
+                if (maxDate && selected.clone().hour(0).minute(0).second(0).isAfter(maxDate))
+                    pm_html = ' disabled="disabled" class="disabled"';
+
+                if (selected.hour() >= 12) {
+                    html += '<option value="AM"' + am_html + '>AM</option><option value="PM" selected="selected"' + pm_html + '>PM</option>';
+                } else {
+                    html += '<option value="AM" selected="selected"' + am_html + '>AM</option><option value="PM"' + pm_html + '>PM</option>';
+                }
+
+                html += '</select>';
+            }
+
+            this.container.find('.drp-calendar.' + side + ' .calendar-time').html(html);
+
+        },
+
+        updateFormInputs: function() {
+
+            if (this.singleDatePicker || (this.endDate && (this.startDate.isBefore(this.endDate) || this.startDate.isSame(this.endDate)))) {
+                this.container.find('button.applyBtn').removeAttr('disabled');
+            } else {
+                this.container.find('button.applyBtn').attr('disabled', 'disabled');
+            }
+
+        },
+
+        move: function() {
+            var parentOffset = { top: 0, left: 0 },
+                containerTop;
+            var parentRightEdge = $(window).width();
+            if (!this.parentEl.is('body')) {
+                parentOffset = {
+                    top: this.parentEl.offset().top - this.parentEl.scrollTop(),
+                    left: this.parentEl.offset().left - this.parentEl.scrollLeft()
+                };
+                parentRightEdge = this.parentEl[0].clientWidth + this.parentEl.offset().left;
+            }
+
+            if (this.drops == 'up')
+                containerTop = this.element.offset().top - this.container.outerHeight() - parentOffset.top;
+            else
+                containerTop = this.element.offset().top + this.element.outerHeight() - parentOffset.top;
+            this.container[this.drops == 'up' ? 'addClass' : 'removeClass']('drop-up');
+
+            if (this.opens == 'left') {
+                this.container.css({
+                    top: containerTop,
+                    right: parentRightEdge - this.element.offset().left - this.element.outerWidth(),
+                    left: 'auto'
+                });
+                if (this.container.offset().left < 0) {
+                    this.container.css({
+                        right: 'auto',
+                        left: 9
+                    });
+                }
+            } else if (this.opens == 'center') {
+                this.container.css({
+                    top: containerTop,
+                    left: this.element.offset().left - parentOffset.left + this.element.outerWidth() / 2
+                            - this.container.outerWidth() / 2,
+                    right: 'auto'
+                });
+                if (this.container.offset().left < 0) {
+                    this.container.css({
+                        right: 'auto',
+                        left: 9
+                    });
+                }
+            } else {
+                this.container.css({
+                    top: containerTop,
+                    left: this.element.offset().left - parentOffset.left,
+                    right: 'auto'
+                });
+                if (this.container.offset().left + this.container.outerWidth() > $(window).width()) {
+                    this.container.css({
+                        left: 'auto',
+                        right: 0
+                    });
+                }
+            }
+        },
+
+        show: function(e) {
+            if (this.isShowing) return;
+
+            // Create a click proxy that is private to this instance of datepicker, for unbinding
+            this._outsideClickProxy = $.proxy(function(e) { this.outsideClick(e); }, this);
+
+            // Bind global datepicker mousedown for hiding and
+            $(document)
+              .on('mousedown.daterangepicker', this._outsideClickProxy)
+              // also support mobile devices
+              .on('touchend.daterangepicker', this._outsideClickProxy)
+              // also explicitly play nice with Bootstrap dropdowns, which stopPropagation when clicking them
+              .on('click.daterangepicker', '[data-toggle=dropdown]', this._outsideClickProxy)
+              // and also close when focus changes to outside the picker (eg. tabbing between controls)
+              .on('focusin.daterangepicker', this._outsideClickProxy);
+
+            // Reposition the picker if the window is resized while it's open
+            $(window).on('resize.daterangepicker', $.proxy(function(e) { this.move(e); }, this));
+
+            this.oldStartDate = this.startDate.clone();
+            this.oldEndDate = this.endDate.clone();
+            this.previousRightTime = this.endDate.clone();
+
+            this.updateView();
+            this.container.show();
+            this.move();
+            this.element.trigger('show.daterangepicker', this);
+            this.isShowing = true;
+        },
+
+        hide: function(e) {
+            if (!this.isShowing) return;
+
+            //incomplete date selection, revert to last values
+            if (!this.endDate) {
+                this.startDate = this.oldStartDate.clone();
+                this.endDate = this.oldEndDate.clone();
+            }
+
+            //if a new date range was selected, invoke the user callback function
+            if (!this.startDate.isSame(this.oldStartDate) || !this.endDate.isSame(this.oldEndDate))
+                this.callback(this.startDate.clone(), this.endDate.clone(), this.chosenLabel);
+
+            //if picker is attached to a text input, update it
+            this.updateElement();
+
+            $(document).off('.daterangepicker');
+            $(window).off('.daterangepicker');
+            this.container.hide();
+            this.element.trigger('hide.daterangepicker', this);
+            this.isShowing = false;
+        },
+
+        toggle: function(e) {
+            if (this.isShowing) {
+                this.hide();
+            } else {
+                this.show();
+            }
+        },
+
+        outsideClick: function(e) {
+            var target = $(e.target);
+            // if the page is clicked anywhere except within the daterangerpicker/button
+            // itself then call this.hide()
+            if (
+                // ie modal dialog fix
+                e.type == "focusin" ||
+                target.closest(this.element).length ||
+                target.closest(this.container).length ||
+                target.closest('.calendar-table').length
+                ) return;
+            this.hide();
+            this.element.trigger('outsideClick.daterangepicker', this);
+        },
+
+        showCalendars: function() {
+            this.container.addClass('show-calendar');
+            this.move();
+            this.element.trigger('showCalendar.daterangepicker', this);
+        },
+
+        hideCalendars: function() {
+            this.container.removeClass('show-calendar');
+            this.element.trigger('hideCalendar.daterangepicker', this);
+        },
+
+        clickRange: function(e) {
+            var label = e.target.getAttribute('data-range-key');
+            this.chosenLabel = label;
+            if (label == this.locale.customRangeLabel) {
+                this.showCalendars();
+            } else {
+                var dates = this.ranges[label];
+                this.startDate = dates[0];
+                this.endDate = dates[1];
+
+                if (!this.timePicker) {
+                    this.startDate.startOf('day');
+                    this.endDate.endOf('day');
+                }
+
+                if (!this.alwaysShowCalendars)
+                    this.hideCalendars();
+                this.clickApply();
+            }
+        },
+
+        clickPrev: function(e) {
+            var cal = $(e.target).parents('.drp-calendar');
+            if (cal.hasClass('left')) {
+                this.leftCalendar.month.subtract(1, 'month');
+                if (this.linkedCalendars)
+                    this.rightCalendar.month.subtract(1, 'month');
+            } else {
+                this.rightCalendar.month.subtract(1, 'month');
+            }
+            this.updateCalendars();
+        },
+
+        clickNext: function(e) {
+            var cal = $(e.target).parents('.drp-calendar');
+            if (cal.hasClass('left')) {
+                this.leftCalendar.month.add(1, 'month');
+            } else {
+                this.rightCalendar.month.add(1, 'month');
+                if (this.linkedCalendars)
+                    this.leftCalendar.month.add(1, 'month');
+            }
+            this.updateCalendars();
+        },
+
+        hoverDate: function(e) {
+
+            //ignore dates that can't be selected
+            if (!$(e.target).hasClass('available')) return;
+
+            var title = $(e.target).attr('data-title');
+            var row = title.substr(1, 1);
+            var col = title.substr(3, 1);
+            var cal = $(e.target).parents('.drp-calendar');
+            var date = cal.hasClass('left') ? this.leftCalendar.calendar[row][col] : this.rightCalendar.calendar[row][col];
+
+            //highlight the dates between the start date and the date being hovered as a potential end date
+            var leftCalendar = this.leftCalendar;
+            var rightCalendar = this.rightCalendar;
+            var startDate = this.startDate;
+            if (!this.endDate) {
+                this.container.find('.drp-calendar tbody td').each(function(index, el) {
+
+                    //skip week numbers, only look at dates
+                    if ($(el).hasClass('week')) return;
+
+                    var title = $(el).attr('data-title');
+                    var row = title.substr(1, 1);
+                    var col = title.substr(3, 1);
+                    var cal = $(el).parents('.drp-calendar');
+                    var dt = cal.hasClass('left') ? leftCalendar.calendar[row][col] : rightCalendar.calendar[row][col];
+
+                    if ((dt.isAfter(startDate) && dt.isBefore(date)) || dt.isSame(date, 'day')) {
+                        $(el).addClass('in-range');
+                    } else {
+                        $(el).removeClass('in-range');
+                    }
+
+                });
+            }
+
+        },
+
+        clickDate: function(e) {
+
+            if (!$(e.target).hasClass('available')) return;
+
+            var title = $(e.target).attr('data-title');
+            var row = title.substr(1, 1);
+            var col = title.substr(3, 1);
+            var cal = $(e.target).parents('.drp-calendar');
+            var date = cal.hasClass('left') ? this.leftCalendar.calendar[row][col] : this.rightCalendar.calendar[row][col];
+
+            //
+            // this function needs to do a few things:
+            // * alternate between selecting a start and end date for the range,
+            // * if the time picker is enabled, apply the hour/minute/second from the select boxes to the clicked date
+            // * if autoapply is enabled, and an end date was chosen, apply the selection
+            // * if single date picker mode, and time picker isn't enabled, apply the selection immediately
+            // * if one of the inputs above the calendars was focused, cancel that manual input
+            //
+
+            if (this.endDate || date.isBefore(this.startDate, 'day')) { //picking start
+                if (this.timePicker) {
+                    var hour = parseInt(this.container.find('.left .hourselect').val(), 10);
+                    if (!this.timePicker24Hour) {
+                        var ampm = this.container.find('.left .ampmselect').val();
+                        if (ampm === 'PM' && hour < 12)
+                            hour += 12;
+                        if (ampm === 'AM' && hour === 12)
+                            hour = 0;
+                    }
+                    var minute = parseInt(this.container.find('.left .minuteselect').val(), 10);
+                    var second = this.timePickerSeconds ? parseInt(this.container.find('.left .secondselect').val(), 10) : 0;
+                    date = date.clone().hour(hour).minute(minute).second(second);
+                }
+                this.endDate = null;
+                this.setStartDate(date.clone());
+            } else if (!this.endDate && date.isBefore(this.startDate)) {
+                //special case: clicking the same date for start/end,
+                //but the time of the end date is before the start date
+                this.setEndDate(this.startDate.clone());
+            } else { // picking end
+                if (this.timePicker) {
+                    var hour = parseInt(this.container.find('.right .hourselect').val(), 10);
+                    if (!this.timePicker24Hour) {
+                        var ampm = this.container.find('.right .ampmselect').val();
+                        if (ampm === 'PM' && hour < 12)
+                            hour += 12;
+                        if (ampm === 'AM' && hour === 12)
+                            hour = 0;
+                    }
+                    var minute = parseInt(this.container.find('.right .minuteselect').val(), 10);
+                    var second = this.timePickerSeconds ? parseInt(this.container.find('.right .secondselect').val(), 10) : 0;
+                    date = date.clone().hour(hour).minute(minute).second(second);
+                }
+                this.setEndDate(date.clone());
+                if (this.autoApply) {
+                  this.calculateChosenLabel();
+                  this.clickApply();
+                }
+            }
+
+            if (this.singleDatePicker) {
+                this.setEndDate(this.startDate);
+                if (!this.timePicker)
+                    this.clickApply();
+            }
+
+            this.updateView();
+
+            //This is to cancel the blur event handler if the mouse was in one of the inputs
+            e.stopPropagation();
+
+        },
+
+        calculateChosenLabel: function () {
+            var customRange = true;
+            var i = 0;
+            for (var range in this.ranges) {
+              if (this.timePicker) {
+                    var format = this.timePickerSeconds ? "YYYY-MM-DD hh:mm:ss" : "YYYY-MM-DD hh:mm";
+                    //ignore times when comparing dates if time picker seconds is not enabled
+                    if (this.startDate.format(format) == this.ranges[range][0].format(format) && this.endDate.format(format) == this.ranges[range][1].format(format)) {
+                        customRange = false;
+                        this.chosenLabel = this.container.find('.ranges li:eq(' + i + ')').addClass('active').attr('data-range-key');
+                        break;
+                    }
+                } else {
+                    //ignore times when comparing dates if time picker is not enabled
+                    if (this.startDate.format('YYYY-MM-DD') == this.ranges[range][0].format('YYYY-MM-DD') && this.endDate.format('YYYY-MM-DD') == this.ranges[range][1].format('YYYY-MM-DD')) {
+                        customRange = false;
+                        this.chosenLabel = this.container.find('.ranges li:eq(' + i + ')').addClass('active').attr('data-range-key');
+                        break;
+                    }
+                }
+                i++;
+            }
+            if (customRange) {
+                if (this.showCustomRangeLabel) {
+                    this.chosenLabel = this.container.find('.ranges li:last').addClass('active').attr('data-range-key');
+                } else {
+                    this.chosenLabel = null;
+                }
+                this.showCalendars();
+            }
+        },
+
+        clickApply: function(e) {
+            this.hide();
+            this.element.trigger('apply.daterangepicker', this);
+        },
+
+        clickCancel: function(e) {
+            this.startDate = this.oldStartDate;
+            this.endDate = this.oldEndDate;
+            this.hide();
+            this.element.trigger('cancel.daterangepicker', this);
+        },
+
+        monthOrYearChanged: function(e) {
+            var isLeft = $(e.target).closest('.drp-calendar').hasClass('left'),
+                leftOrRight = isLeft ? 'left' : 'right',
+                cal = this.container.find('.drp-calendar.'+leftOrRight);
+
+            // Month must be Number for new moment versions
+            var month = parseInt(cal.find('.monthselect').val(), 10);
+            var year = cal.find('.yearselect').val();
+
+            if (!isLeft) {
+                if (year < this.startDate.year() || (year == this.startDate.year() && month < this.startDate.month())) {
+                    month = this.startDate.month();
+                    year = this.startDate.year();
+                }
+            }
+
+            if (this.minDate) {
+                if (year < this.minDate.year() || (year == this.minDate.year() && month < this.minDate.month())) {
+                    month = this.minDate.month();
+                    year = this.minDate.year();
+                }
+            }
+
+            if (this.maxDate) {
+                if (year > this.maxDate.year() || (year == this.maxDate.year() && month > this.maxDate.month())) {
+                    month = this.maxDate.month();
+                    year = this.maxDate.year();
+                }
+            }
+
+            if (isLeft) {
+                this.leftCalendar.month.month(month).year(year);
+                if (this.linkedCalendars)
+                    this.rightCalendar.month = this.leftCalendar.month.clone().add(1, 'month');
+            } else {
+                this.rightCalendar.month.month(month).year(year);
+                if (this.linkedCalendars)
+                    this.leftCalendar.month = this.rightCalendar.month.clone().subtract(1, 'month');
+            }
+            this.updateCalendars();
+        },
+
+        timeChanged: function(e) {
+
+            var cal = $(e.target).closest('.drp-calendar'),
+                isLeft = cal.hasClass('left');
+
+            var hour = parseInt(cal.find('.hourselect').val(), 10);
+            var minute = parseInt(cal.find('.minuteselect').val(), 10);
+            var second = this.timePickerSeconds ? parseInt(cal.find('.secondselect').val(), 10) : 0;
+
+            if (!this.timePicker24Hour) {
+                var ampm = cal.find('.ampmselect').val();
+                if (ampm === 'PM' && hour < 12)
+                    hour += 12;
+                if (ampm === 'AM' && hour === 12)
+                    hour = 0;
+            }
+
+            if (isLeft) {
+                var start = this.startDate.clone();
+                start.hour(hour);
+                start.minute(minute);
+                start.second(second);
+                this.setStartDate(start);
+                if (this.singleDatePicker) {
+                    this.endDate = this.startDate.clone();
+                } else if (this.endDate && this.endDate.format('YYYY-MM-DD') == start.format('YYYY-MM-DD') && this.endDate.isBefore(start)) {
+                    this.setEndDate(start.clone());
+                }
+            } else if (this.endDate) {
+                var end = this.endDate.clone();
+                end.hour(hour);
+                end.minute(minute);
+                end.second(second);
+                this.setEndDate(end);
+            }
+
+            //update the calendars so all clickable dates reflect the new time component
+            this.updateCalendars();
+
+            //update the form inputs above the calendars with the new time
+            this.updateFormInputs();
+
+            //re-render the time pickers because changing one selection can affect what's enabled in another
+            this.renderTimePicker('left');
+            this.renderTimePicker('right');
+
+        },
+
+        elementChanged: function() {
+            if (!this.element.is('input')) return;
+            if (!this.element.val().length) return;
+
+            var dateString = this.element.val().split(this.locale.separator),
+                start = null,
+                end = null;
+
+            if (dateString.length === 2) {
+                start = moment(dateString[0], this.locale.format);
+                end = moment(dateString[1], this.locale.format);
+            }
+
+            if (this.singleDatePicker || start === null || end === null) {
+                start = moment(this.element.val(), this.locale.format);
+                end = start;
+            }
+
+            if (!start.isValid() || !end.isValid()) return;
+
+            this.setStartDate(start);
+            this.setEndDate(end);
+            this.updateView();
+        },
+
+        keydown: function(e) {
+            //hide on tab or enter
+            if ((e.keyCode === 9) || (e.keyCode === 13)) {
+                this.hide();
+            }
+
+            //hide on esc and prevent propagation
+            if (e.keyCode === 27) {
+                e.preventDefault();
+                e.stopPropagation();
+
+                this.hide();
+            }
+        },
+
+        updateElement: function() {
+            if (this.element.is('input') && this.autoUpdateInput) {
+                var newValue = this.startDate.format(this.locale.format);
+                if (!this.singleDatePicker) {
+                    newValue += this.locale.separator + this.endDate.format(this.locale.format);
+                }
+                if (newValue !== this.element.val()) {
+                    this.element.val(newValue).trigger('change');
+                }
+            }
+        },
+
+        remove: function() {
+            this.container.remove();
+            this.element.off('.daterangepicker');
+            this.element.removeData();
+        }
+
+    };
+
+    $.fn.daterangepicker = function(options, callback) {
+        var implementOptions = $.extend(true, {}, $.fn.daterangepicker.defaultOptions, options);
+        this.each(function() {
+            var el = $(this);
+            if (el.data('daterangepicker'))
+                el.data('daterangepicker').remove();
+            el.data('daterangepicker', new DateRangePicker(el, implementOptions, callback));
+        });
+        return this;
+    };
+
+    return DateRangePicker;
+
+}));

+ 3 - 0
modules/boonex/massmailer/template/attributes.html

@@ -0,0 +1,3 @@
+<bx_repeat:items>
+<strong>__title__</strong><br>__list__<br>
+</bx_repeat:items>

+ 1 - 0
modules/boonex/massmailer/template/attributes_part.html

@@ -0,0 +1 @@
+<bx_repeat:items>__title__ : {__attribute__}<br></bx_repeat:items>

+ 1 - 0
modules/boonex/massmailer/template/breadcrumb.html

@@ -0,0 +1 @@
+<bx_include_base:../modules/base/text/template/breadcrumb.html />

+ 25 - 0
modules/boonex/massmailer/template/campaign_clicks.html

@@ -0,0 +1,25 @@
+<div class="bx_massmailer_table">
+    <table class="bx_massmailer_table_clicks">
+        <thead>
+            <tr>
+                <th>__title_title__</th>
+                <th>__link_title__</th>
+                <th>__click_count_title__</th>
+                <th>__last_click_title__</th>
+            </tr>
+        </thead>
+        <bx_repeat:items>
+            <tr>
+                <td>__title__</td>
+                <td>__link__</td>
+                <td>__click_count__</td>
+                <td>__last_click__</td>
+            </tr>
+        </bx_repeat:items>
+    </table>
+</div>
+<script type="text/javascript">
+$(document).ready(function () {
+    $('.bx_massmailer_table table.bx_massmailer_table_clicks').DataTable({ dom: '<"top"i>rt<"bottom"flp><"clear">', paging: false, searching: false, ordering: true, order: [] });
+});
+</script>

+ 20 - 0
modules/boonex/massmailer/template/campaign_info.html

@@ -0,0 +1,20 @@
+<strong>__subject_title__</strong> __subject__<br>
+
+<!--<strong>__body_title__</strong>
+<hr>
+__body__
+<hr><br>-->
+<bx_if:from_name>
+    <strong>__from_name_title__</strong> __from_name__<br>
+</bx_if:from_name>
+<bx_if:reply>
+    <strong>__reply_title__</strong> __reply__<br>
+</bx_if:reply>
+<strong>__created_title__</strong> __created__<br>
+<strong>__sent_on_title__</strong> __sent_on__<br>
+<strong>__sent_to_title__</strong> __sent_to__<br>
+<div id="bx_massmailer_chart_container">
+    <div class="bx_massmailer_chart_cnt bx-def-margin-top">
+        <canvas class="bx_massmailer_chart"></canvas>
+    </div>
+</div>

+ 5 - 3
modules/boonex/massmailer/template/entity_view.html → modules/boonex/massmailer/template/campaign_subscribers.html

@@ -1,10 +1,11 @@
-<div style="width:100%;" class="bx-def-padding bx_massmailer_table">
-    <table style="width:100%;">
+<div class="bx_massmailer_table">
+    <table  class="bx_massmailer_table_subscribers">
         <thead>
             <tr>
                 <th>__email_title__</th>
                 <th>__date_sent_title__</th>
                 <th>__date_seen_title__</th>
+                <th>__date_click_title__</th>
             </tr>
         </thead>
         <bx_repeat:items>
@@ -12,12 +13,13 @@
                 <td>__email__</td>
                 <td>__date_sent__</td>
                 <td>__date_seen__</td>
+                <td>__date_click__</td>
             </tr>
         </bx_repeat:items>
     </table>
 </div>
 <script type="text/javascript">
 $(document).ready(function () {
-    $('.bx_massmailer_table table').DataTable({ dom: '<"top"i>rt<"bottom"flp><"clear">', paging: true, searching: false, ordering: true, order: [] });
+    $('.bx_massmailer_table table.bx_massmailer_table_subscribers').DataTable({ dom: '<"top"i>rt<"bottom"flp><"clear">', paging: false, searching: false, ordering: true, order: [] });
 });
 </script>

+ 46 - 1
modules/boonex/massmailer/template/css/main.css

@@ -8,7 +8,52 @@
 .bx_massmailer_table .bottom {
     margin-top:1rem;
 }
-
 .bx_massmailer_table .dataTables_info {
     display: none;
+}
+.bx_massmailer_table, .bx_massmailer_table table {
+    width:100%;
+}
+.bx_massmailer_filters
+{
+    display: -webkit-box;
+    display: -webkit-flex;
+    display: -ms-flexbox;
+    display: flex;
+    -webkit-box-align: stretch;
+    -webkit-align-items: stretch;
+    -ms-flex-align: stretch;
+    align-items: stretch;
+    -webkit-flex-wrap: wrap;
+    -ms-flex-wrap: wrap;
+    flex-wrap: wrap;
+    -webkit-box-pack: start;
+    -ms-flex-pack: start;
+    plex-pack: start;
+    -webkit-justify-content: space-between;
+    justify-content: space-between;
+}
+
+.bx_massmailer_filters .bx-def-font-inputs{
+    position: relative;
+    display: inline-block;
+    overflow: hidden;
+    width: calc(33% - 1rem);
+}
+
+.bx_massmailer_data .bx-def-font-inputs {
+    width: calc(100%);
+    max-width:200px;
+    float:left;
+}
+
+.bx_massmailer_chart_cnt
+{
+    max-width:800px;
+	max-height:600px;
+	margin:0px auto;
+}
+.bx_massmailer_chart{
+    width:100%;
+    height:100%;
 }

Некоторые файлы не были показаны из-за большого количества измененных файлов