diff --git a/assets/css/src/listing/newsletters.styl b/assets/css/src/listing/newsletters.styl index 329c08c0c3..a8a030f3f6 100644 --- a/assets/css/src/listing/newsletters.styl +++ b/assets/css/src/listing/newsletters.styl @@ -1,3 +1,52 @@ +$excellent-badge-color = #2993ab +$good-badge-color = #f0b849 +$bad-badge-color = #d54e21 +$green-badge-color = #55bd56 + #newsletters_container h2.nav-tab-wrapper - margin-bottom: 1rem \ No newline at end of file + margin-bottom: 1rem + +.mailpoet_stats_text + font-size: 14px + font-weight: 600; + +.mailpoet_stat + + &_excellent + color: $excellent-badge-color + + &_good + color: $good-badge-color + + &_bad + color: $bad-badge-color + + &_hidden + display: none + + &_link_small + text-decoration: underline !important + font-size: 0.75rem + +.mailpoet_badge + padding: 4px 6px 3px 6px + color: #FFFFFF + margin-right: 4px + text-transform: uppercase + font-size: 0.5625rem + font-weight: 500 + border-radius: 3px + letter-spacing: 1px + + &_excellent + background: $excellent-badge-color + + &_good + background: $good-badge-color + + &_bad + background: $bad-badge-color + + &_green + background: $green-badge-color diff --git a/assets/js/src/listing/listing.jsx b/assets/js/src/listing/listing.jsx index 2c28bf5cbb..3cf9b7799d 100644 --- a/assets/js/src/listing/listing.jsx +++ b/assets/js/src/listing/listing.jsx @@ -209,8 +209,8 @@ const ListingItems = React.createClass({ className="colspanchange"> { (this.props.loading === true) - ? MailPoet.I18n.t('loadingItems') - : MailPoet.I18n.t('noItemsFound') + ? (this.props.messages.onLoadingItems || MailPoet.I18n.t('loadingItems')) + : (this.props.messages.onNoItemsFound || MailPoet.I18n.t('noItemsFound')) } @@ -793,6 +793,12 @@ const Listing = React.createClass({ groups = false; } + // messages + let messages = {}; + if (this.props.messages !== undefined) { + messages = this.props.messages; + } + return (
{ groups } @@ -846,6 +852,7 @@ const Listing = React.createClass({ count={ this.state.count } limit={ this.state.limit } item_actions={ item_actions } + messages={ messages } items={ items } /> diff --git a/assets/js/src/newsletters/badges/badge.jsx b/assets/js/src/newsletters/badges/badge.jsx new file mode 100644 index 0000000000..04d10512ea --- /dev/null +++ b/assets/js/src/newsletters/badges/badge.jsx @@ -0,0 +1,37 @@ +import React from 'react' +import classNames from 'classnames' +import ReactTooltip from 'react-tooltip' + +class Badge extends React.Component { + render() { + const badgeClasses = classNames( + 'mailpoet_badge', + this.props.type ? `mailpoet_badge_${this.props.type}` : '' + ); + + const tooltip = this.props.tooltip ? this.props.tooltip.replace(/\n/g, '
') : false; + // tooltip ID must be unique, defaults to tooltip text + const tooltipId = this.props.tooltipId || tooltip; + + return ( + + + {this.props.name} + + { tooltip && ( + + ) } + + ); + } +} + +export default Badge; diff --git a/assets/js/src/newsletters/badges/stats.jsx b/assets/js/src/newsletters/badges/stats.jsx new file mode 100644 index 0000000000..1926a6e9d8 --- /dev/null +++ b/assets/js/src/newsletters/badges/stats.jsx @@ -0,0 +1,105 @@ +import MailPoet from 'mailpoet' +import React from 'react' + +import Badge from './badge.jsx' + +const badges = { + excellent: { + name: MailPoet.I18n.t('excellentBadgeName'), + tooltipTitle: MailPoet.I18n.t('excellentBadgeTooltip') + }, + good: { + name: MailPoet.I18n.t('goodBadgeName'), + tooltipTitle: MailPoet.I18n.t('goodBadgeTooltip') + }, + bad: { + name: MailPoet.I18n.t('badBadgeName'), + tooltipTitle: MailPoet.I18n.t('badBadgeTooltip') + } +}; + +const stats = { + opened: { + badgeRanges: [30, 15, 0], + badgeTypes: [ + 'excellent', + 'good', + 'bad' + ], + tooltipText: MailPoet.I18n.t('openedStatTooltip'), + }, + clicked: { + badgeRanges: [3, 1, 0], + badgeTypes: [ + 'excellent', + 'good', + 'bad' + ], + tooltipText: MailPoet.I18n.t('clickedStatTooltip') + }, + unsubscribed: { + badgeRanges: [3, 1, 0], + badgeTypes: [ + 'bad', + 'good', + 'excellent' + ], + tooltipText: MailPoet.I18n.t('unsubscribedStatTooltip') + }, +}; + +class StatsBadge extends React.Component { + getBadgeType(stat, rate) { + const len = stat.badgeRanges.length; + for (var i = 0; i < len; i++) { + if (rate > stat.badgeRanges[i]) { + return stat.badgeTypes[i]; + } + } + // rate must be zero at this point + return stat.badgeTypes[len - 1]; + } + render() { + const stat = stats[this.props.stat] || null; + if (!stat) { + return null; + } + + const rate = this.props.rate; + if (rate < 0 || rate > 100) { + return null; + } + + const badgeType = this.getBadgeType(stat, rate); + const badge = badges[badgeType] || null; + if (!badge) { + return null; + } + + const tooltipText = `${badge.tooltipTitle}\n\n${stat.tooltipText}`; + const tooltipId = this.props.tooltipId || null; + + const content = ( + + ); + + if (this.props.headline) { + return ( +
+ + {this.props.headline} + {content} +
+ ); + } + + return content; + } +} + +export default StatsBadge; diff --git a/assets/js/src/newsletters/listings/mixins.jsx b/assets/js/src/newsletters/listings/mixins.jsx index 98555a8815..3e37a995a7 100644 --- a/assets/js/src/newsletters/listings/mixins.jsx +++ b/assets/js/src/newsletters/listings/mixins.jsx @@ -1,9 +1,13 @@ import React from 'react' import ReactDOM from 'react-dom' import ReactStringReplace from 'react-string-replace' +import { Link } from 'react-router' import MailPoet from 'mailpoet' import classNames from 'classnames' +import moment from 'moment' import jQuery from 'jquery' +import Hooks from 'wp-js-hooks' +import StatsBadge from 'newsletters/badges/stats.jsx' const _QueueMixin = { pauseSending: function(newsletter) { @@ -140,40 +144,162 @@ const _QueueMixin = { }; const _StatisticsMixin = { - renderStatistics: function(newsletter) { - if ( - newsletter.statistics - && newsletter.queue - && newsletter.queue.status !== 'scheduled' - ) { - const total_sent = ~~(newsletter.queue.count_processed); - - let percentage_clicked = 0; - let percentage_opened = 0; - let percentage_unsubscribed = 0; - - if (total_sent > 0) { - percentage_clicked = Math.round( - (~~(newsletter.statistics.clicked) * 100) / total_sent - ); - percentage_opened = Math.round( - (~~(newsletter.statistics.opened) * 100) / total_sent - ); - percentage_unsubscribed = Math.round( - (~~(newsletter.statistics.unsubscribed) * 100) / total_sent - ); - } - - return ( - - { percentage_opened }%, { percentage_clicked }%, { percentage_unsubscribed }% - - ); - } else { + renderStatistics: function(newsletter, is_sent, current_time) { + if (is_sent === undefined) { + // condition for standard and post notification listings + is_sent = newsletter.statistics + && newsletter.queue + && newsletter.queue.status !== 'scheduled' + } + if (!is_sent) { return ( {MailPoet.I18n.t('notSentYet')} ); } + + let params = {}; + params = Hooks.applyFilters('mailpoet_newsletters_listing_stats_before', params, newsletter); + + // welcome emails provide explicit total_sent value + const total_sent = ~~(newsletter.total_sent || newsletter.queue.count_processed); + + let percentage_clicked = 0; + let percentage_opened = 0; + let percentage_unsubscribed = 0; + + if (total_sent > 0) { + percentage_clicked = (newsletter.statistics.clicked * 100) / total_sent; + percentage_opened = (newsletter.statistics.opened * 100) / total_sent; + percentage_unsubscribed = (newsletter.statistics.unsubscribed * 100) / total_sent; + } + + // format to 1 decimal place + const percentage_clicked_display = MailPoet.Num.toLocaleFixed(percentage_clicked, 1); + const percentage_opened_display = MailPoet.Num.toLocaleFixed(percentage_opened, 1); + const percentage_unsubscribed_display = MailPoet.Num.toLocaleFixed(percentage_unsubscribed, 1); + + let show_stats_timeout, + newsletter_date, + sent_hours_ago, + too_early_for_stats, + show_kb_link; + if (current_time !== undefined) { + // standard emails and post notifications: + // display green box for newsletters that were just sent + show_stats_timeout = 6; // in hours + newsletter_date = newsletter.queue.scheduled_at || newsletter.queue.created_at; + sent_hours_ago = moment(current_time).diff(moment(newsletter_date), 'hours'); + too_early_for_stats = sent_hours_ago < show_stats_timeout; + show_kb_link = true; + } else { + // welcome emails: no green box and KB link + too_early_for_stats = false; + show_kb_link = false; + } + + const improveStatsKBLink = 'http://beta.docs.mailpoet.com/article/191-how-to-improve-my-open-and-click-rates'; + + // thresholds to display badges + const min_newsletters_sent = 20; + const min_newsletter_opens = 5; + + let content; + if (total_sent >= min_newsletters_sent + && newsletter.statistics.opened >= min_newsletter_opens + && !too_early_for_stats + ) { + // display stats with badges + content = ( +
+
+ { percentage_opened_display }% + +
+
+ { percentage_clicked_display }% + +
+
+ { percentage_unsubscribed_display }% +
+
+ ); + } else { + // display simple stats + content = ( +
+ + { percentage_opened_display }%, + { " " } + { percentage_clicked_display }% + + , { percentage_unsubscribed_display }% + + + { too_early_for_stats && ( +
+ {MailPoet.I18n.t('checkBackInHours') + .replace('%$1d', show_stats_timeout - sent_hours_ago)} +
+ ) } +
+ ); + } + + // thresholds to display bad open rate help + const max_percentage_opened = 5; + const min_sent_hours_ago = 24; + const min_total_sent = 10; + + let after_content; + if (show_kb_link + && percentage_opened < max_percentage_opened + && sent_hours_ago >= min_sent_hours_ago + && total_sent >= min_total_sent + ) { + // help link for bad open rate + after_content = ( +
+ + {MailPoet.I18n.t('improveThisLinkText')} + +
+ ); + } + + if (total_sent > 0 && !too_early_for_stats && params.link) { + // wrap content in a link + return ( +
+ + {content} + + {after_content} +
+ ); + } + + return ( +
+ {content} + {after_content} +
+ ); } } diff --git a/assets/js/src/newsletters/listings/notification_history.jsx b/assets/js/src/newsletters/listings/notification_history.jsx index 7820516839..dd5fd35211 100644 --- a/assets/js/src/newsletters/listings/notification_history.jsx +++ b/assets/js/src/newsletters/listings/notification_history.jsx @@ -87,7 +87,7 @@ const NewsletterListNotificationHistory = React.createClass({ { (mailpoet_tracking_enabled === true) ? ( - { this.renderStatistics(newsletter) } + { this.renderStatistics(newsletter, undefined, meta.current_time) } ) : null } diff --git a/assets/js/src/newsletters/listings/standard.jsx b/assets/js/src/newsletters/listings/standard.jsx index f399df617e..116521ecaa 100644 --- a/assets/js/src/newsletters/listings/standard.jsx +++ b/assets/js/src/newsletters/listings/standard.jsx @@ -184,7 +184,7 @@ const NewsletterListStandard = React.createClass({ { (mailpoet_tracking_enabled === true) ? ( - { this.renderStatistics(newsletter) } + { this.renderStatistics(newsletter, undefined, meta.current_time) } ) : null } diff --git a/assets/js/src/newsletters/listings/welcome.jsx b/assets/js/src/newsletters/listings/welcome.jsx index ac4d269089..226eefcf9b 100644 --- a/assets/js/src/newsletters/listings/welcome.jsx +++ b/assets/js/src/newsletters/listings/welcome.jsx @@ -5,12 +5,13 @@ import { createHashHistory } from 'history' import Listing from 'listing/listing.jsx' import ListingTabs from 'newsletters/listings/tabs.jsx' -import { MailerMixin } from 'newsletters/listings/mixins.jsx' +import { StatisticsMixin, MailerMixin } from 'newsletters/listings/mixins.jsx' import classNames from 'classnames' import jQuery from 'jquery' import MailPoet from 'mailpoet' import _ from 'underscore' +import Hooks from 'wp-js-hooks' const mailpoet_roles = window.mailpoet_roles || {}; const mailpoet_segments = window.mailpoet_segments || {}; @@ -155,7 +156,7 @@ const newsletter_actions = [ ]; const NewsletterListWelcome = React.createClass({ - mixins: [ MailerMixin ], + mixins: [ StatisticsMixin, MailerMixin ], updateStatus: function(e) { // make the event persist so that we can still override the selected value // in the ajax callback @@ -276,35 +277,6 @@ const NewsletterListWelcome = React.createClass({ ); }, - renderStatistics: function(newsletter) { - if (mailpoet_tracking_enabled === false) { - return; - } - - if (newsletter.total_sent > 0 && newsletter.statistics) { - const total_sent = ~~(newsletter.total_sent); - - const percentage_clicked = Math.round( - (~~(newsletter.statistics.clicked) * 100) / total_sent - ); - const percentage_opened = Math.round( - (~~(newsletter.statistics.opened) * 100) / total_sent - ); - const percentage_unsubscribed = Math.round( - (~~(newsletter.statistics.unsubscribed) * 100) / total_sent - ); - - return ( - - { percentage_opened }%, { percentage_clicked }%, { percentage_unsubscribed }% - - ); - } else { - return ( - {MailPoet.I18n.t('notSentYet')} - ); - } - }, renderItem: function(newsletter, actions) { const rowClasses = classNames( 'manage-column', @@ -331,7 +303,10 @@ const NewsletterListWelcome = React.createClass({ { (mailpoet_tracking_enabled === true) ? ( - { this.renderStatistics(newsletter) } + { this.renderStatistics( + newsletter, + newsletter.total_sent > 0 && newsletter.statistics + ) } ) : null } diff --git a/assets/js/src/newsletters/newsletters.jsx b/assets/js/src/newsletters/newsletters.jsx index fd82973843..e03772c039 100644 --- a/assets/js/src/newsletters/newsletters.jsx +++ b/assets/js/src/newsletters/newsletters.jsx @@ -2,6 +2,7 @@ import React from 'react' import ReactDOM from 'react-dom' import { Router, Route, IndexRedirect, Link, useRouterHistory } from 'react-router' import { createHashHistory } from 'history' +import Hooks from 'wp-js-hooks' import NewsletterTypes from 'newsletters/types.jsx' import NewsletterTemplates from 'newsletters/templates.jsx' @@ -27,6 +28,9 @@ const App = React.createClass({ const container = document.getElementById('newsletters_container'); if(container) { + let extra_routes = []; + extra_routes = Hooks.applyFilters('mailpoet_newsletters_before_router', extra_routes); + const mailpoet_listing = ReactDOM.render(( @@ -46,6 +50,8 @@ if(container) { {/* Sending options */} + {/* Extra routes */} + { extra_routes.map(rt => ) } ), container); diff --git a/assets/js/src/num.js b/assets/js/src/num.js new file mode 100644 index 0000000000..80568be959 --- /dev/null +++ b/assets/js/src/num.js @@ -0,0 +1,21 @@ +define('num', + [ + 'mailpoet' + ], function( + MailPoet +) { + 'use strict'; + + MailPoet.Num = { + toLocaleFixed: function (num, precision) { + precision = precision || 0; + var factor = Math.pow(10, precision); + return (Math.round(num * factor) / factor) + .toLocaleString( + undefined, + {minimumFractionDigits: precision, maximumFractionDigits: precision} + ); + } + }; + +}); diff --git a/lib/API/Endpoints/v1/Newsletters.php b/lib/API/Endpoints/v1/Newsletters.php index 0573d236ad..22473d67f7 100644 --- a/lib/API/Endpoints/v1/Newsletters.php +++ b/lib/API/Endpoints/v1/Newsletters.php @@ -372,7 +372,8 @@ class Newsletters extends APIEndpoint { 'filters' => $listing_data['filters'], 'groups' => $listing_data['groups'], 'mta_log' => Setting::getValue('mta_log'), - 'mta_method' => Setting::getValue('mta.method') + 'mta_method' => Setting::getValue('mta.method'), + 'current_time' => current_time('mysql') )); } diff --git a/lib/Config/Migrator.php b/lib/Config/Migrator.php index 6e8275dede..9d4a2509c6 100644 --- a/lib/Config/Migrator.php +++ b/lib/Config/Migrator.php @@ -300,7 +300,8 @@ class Migrator { 'subscriber_id mediumint(9) NOT NULL,', 'queue_id mediumint(9) NOT NULL,', 'sent_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,', - 'PRIMARY KEY (id)', + 'PRIMARY KEY (id),', + 'KEY newsletter_id (newsletter_id)', ); return $this->sqlify(__FUNCTION__, $attributes); } @@ -316,6 +317,7 @@ class Migrator { 'created_at TIMESTAMP NULL,', 'updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,', 'PRIMARY KEY (id),', + 'KEY newsletter_id (newsletter_id),', 'KEY queue_id (queue_id)', ); return $this->sqlify(__FUNCTION__, $attributes); @@ -329,6 +331,7 @@ class Migrator { 'queue_id mediumint(9) NOT NULL,', 'created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,', 'PRIMARY KEY (id),', + 'KEY newsletter_id (newsletter_id),', 'KEY queue_id (queue_id)', ); return $this->sqlify(__FUNCTION__, $attributes); @@ -342,6 +345,7 @@ class Migrator { 'queue_id mediumint(9) NOT NULL,', 'created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,', 'PRIMARY KEY (id),', + 'KEY newsletter_id (newsletter_id),', 'KEY queue_id (queue_id)', ); return $this->sqlify(__FUNCTION__, $attributes); diff --git a/lib/Models/Newsletter.php b/lib/Models/Newsletter.php index 83e6b766f7..3f42f9136b 100644 --- a/lib/Models/Newsletter.php +++ b/lib/Models/Newsletter.php @@ -264,7 +264,8 @@ class Newsletter extends Model { 'status', 'count_processed', 'count_total', - 'scheduled_at' + 'scheduled_at', + 'created_at' )); if($queue === false) { $this->queue = false; diff --git a/package.json b/package.json index ae5c11c6da..cd924bc121 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "react-dom": "~15.4.2", "react-router": "~3.0.2", "react-string-replace": "^0.3.2", + "react-tooltip": "^3.2.10", "select2": "^4.0.0", "spectrum-colorpicker": "^1.6.2", "tinymce": "4.5.6", diff --git a/tests/unit/Statistics/Track/OpensTest.php b/tests/unit/Statistics/Track/OpensTest.php index 13b5d12405..4fa4d90501 100644 --- a/tests/unit/Statistics/Track/OpensTest.php +++ b/tests/unit/Statistics/Track/OpensTest.php @@ -3,7 +3,6 @@ use Codeception\Util\Stub; use MailPoet\Models\Newsletter; use MailPoet\Models\SendingQueue; -use MailPoet\Models\StatisticsClicks; use MailPoet\Models\StatisticsOpens; use MailPoet\Models\Subscriber; use MailPoet\Statistics\Track\Opens; @@ -90,6 +89,5 @@ class OpensTest extends MailPoetTest { ORM::raw_execute('TRUNCATE ' . Subscriber::$_table); ORM::raw_execute('TRUNCATE ' . SendingQueue::$_table); ORM::raw_execute('TRUNCATE ' . StatisticsOpens::$_table); - ORM::raw_execute('TRUNCATE ' . StatisticsClicks::$_table); } } \ No newline at end of file diff --git a/views/layout.html b/views/layout.html index 091ae71d5d..bbdb4e7782 100644 --- a/views/layout.html +++ b/views/layout.html @@ -31,6 +31,9 @@ jQuery('.toplevel_page_mailpoet-newsletters.menu-top-last') <%= stylesheet( 'admin.css' )%> + +<%= do_action('mailpoet_styles_admin_after') %> + <% if is_rtl %> <%= stylesheet('rtl.css') %> diff --git a/views/newsletters.html b/views/newsletters.html index 40d74f3b8f..0ed3ca06ca 100644 --- a/views/newsletters.html +++ b/views/newsletters.html @@ -60,7 +60,7 @@ 'subject': __('Subject'), 'status': __('Status'), - 'statistics': __('Opened, Clicked, Unsubscribed'), + 'statistics': __('Opened, Clicked'), 'lists': __('Lists'), 'settings': __('Settings'), 'history': __('History'), @@ -90,6 +90,18 @@ 'paused': __('Paused'), 'new': __('Add New'), + 'excellentBadgeName': __('Excellent'), + 'excellentBadgeTooltip': __('Congrats!'), + 'goodBadgeName': __('Good'), + 'goodBadgeTooltip': __('Good stuff.'), + 'badBadgeName': __('Bad'), + 'badBadgeTooltip': __('Something to improve.'), + 'openedStatTooltip': __('Above 30% is excellent.\\nBetween 15 and 30% is good.\\nUnder 15% is bad.'), + 'clickedStatTooltip': __('Above 3% is excellent.\\nBetween 1 and 3% is good.\\nUnder 1% is bad.'), + 'unsubscribedStatTooltip': __('Under 1% is excellent.\\nBetween 1 and 3% is good.\\nOver 3% is bad.'), + 'checkBackInHours': __('Nice job! Check back in %$1d hour(s) for more stats.'), + 'improveThisLinkText': __('What can I do to improve this?'), + 'templateFileMalformedError': __('This template file appears to be damaged. Please try another one.'), 'importTemplateTitle': __('Import a template'), 'selectJsonFileToUpload': __('Select a .json file to upload'), diff --git a/webpack.config.js b/webpack.config.js index 42312f945a..ea3d5e179a 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -65,7 +65,11 @@ baseConfig = { include: require.resolve('react'), loader: 'expose-loader?' + globalPrefix + '.React', }, - { + { + include: require.resolve('react-router'), + loader: 'expose-loader?' + globalPrefix + '.ReactRouter', + }, + { include: require.resolve('react-string-replace'), loader: 'expose-loader?' + globalPrefix + '.ReactStringReplace', }, @@ -73,6 +77,14 @@ baseConfig = { test: /wp-js-hooks/i, loader: 'expose-loader?' + globalPrefix + '.Hooks!exports-loader?wp.hooks', }, + { + test: /listing.jsx/i, + loader: 'expose-loader?' + globalPrefix + '.Listing!babel-loader', + }, + { + include: path.resolve(__dirname, 'assets/js/src/newsletters/badges/stats.jsx'), + loader: 'expose-loader?' + globalPrefix + '.StatsBadge!babel-loader', + }, { include: /Blob.js$/, loader: 'exports-loader?window.Blob', @@ -117,6 +129,7 @@ config.push(_.extend({}, baseConfig, { 'i18n', 'modal', 'notice', + 'num', 'jquery.serialize_object', 'parsleyjs' ], @@ -124,7 +137,9 @@ config.push(_.extend({}, baseConfig, { 'react', 'react-dom', 'react-router', - 'react-string-replace' + 'react-string-replace', + 'listing/listing.jsx', + 'newsletters/badges/stats.jsx' ], admin: [ 'subscribers/subscribers.jsx',