diff --git a/assets/js/src/newsletters/campaign_stats/newsletter_info.jsx b/assets/js/src/newsletters/campaign_stats/newsletter_info.jsx
new file mode 100644
index 0000000000..0fc4c2a38d
--- /dev/null
+++ b/assets/js/src/newsletters/campaign_stats/newsletter_info.jsx
@@ -0,0 +1,91 @@
+import MailPoet from 'mailpoet';
+import React from 'react';
+import PropTypes from 'prop-types';
+
+function formatAddress(address, name) {
+ let addressString = '';
+ if (address) {
+ addressString = (name) ? `${name} <${address}>` : address;
+ }
+ return addressString;
+}
+
+function NewsletterStatsInfo(props) {
+ const { newsletter } = props;
+
+ const newsletterDate = newsletter.queue.scheduled_at || newsletter.queue.created_at;
+
+ const senderAddress = formatAddress(
+ newsletter.sender_address || '',
+ newsletter.sender_name || ''
+ );
+ const replyToAddress = formatAddress(
+ newsletter.reply_to_address || '',
+ newsletter.reply_to_name || ''
+ );
+
+ const segments = (newsletter.segments || []).map(segment => segment.name).join(', ');
+
+ return (
+
+
+
+
+ {MailPoet.I18n.t('statsDateSent')}
+:
+ {' '}
+ {MailPoet.Date.format(newsletterDate)}
+
+
+ { segments && (
+
+ {MailPoet.I18n.t('statsToSegments')}
+:
+ {' '}
+ { segments }
+
+ ) }
+
+
+ {MailPoet.I18n.t('statsFromAddress')}
+:
+ {' '}
+ { senderAddress }
+
+
+ {replyToAddress && (
+
+ {MailPoet.I18n.t('statsReplyToAddress')}
+:
+ {' '}
+ { replyToAddress }
+
+ ) }
+
+ );
+}
+
+NewsletterStatsInfo.propTypes = {
+ newsletter: PropTypes.shape({
+ queue: PropTypes.shape({
+ scheduled_at: PropTypes.string,
+ created_at: PropTypes.string,
+ }).isRequired,
+ sender_address: PropTypes.string,
+ sender_name: PropTypes.string,
+ reply_to_address: PropTypes.string,
+ reply_to_name: PropTypes.string,
+ segments: PropTypes.array,
+ }).isRequired,
+};
+
+export default NewsletterStatsInfo;
diff --git a/assets/js/src/newsletters/campaign_stats/newsletter_stats.jsx b/assets/js/src/newsletters/campaign_stats/newsletter_stats.jsx
new file mode 100644
index 0000000000..50230e896b
--- /dev/null
+++ b/assets/js/src/newsletters/campaign_stats/newsletter_stats.jsx
@@ -0,0 +1,124 @@
+import MailPoet from 'mailpoet';
+import React from 'react';
+import StatsBadge from 'stats-badge';
+import PropTypes from 'prop-types';
+
+import RevenuesStats from './revenues_stats.jsx';
+
+const NewsletterGeneralStats = ({ newsletter }) => {
+ const totalSent = newsletter.total_sent || 0;
+ let percentageClicked = 0;
+ let percentageOpened = 0;
+ let percentageUnsubscribed = 0;
+ if (totalSent > 0) {
+ percentageClicked = (newsletter.statistics.clicked * 100) / totalSent;
+ percentageOpened = (newsletter.statistics.opened * 100) / totalSent;
+ percentageUnsubscribed = (newsletter.statistics.unsubscribed * 100) / totalSent;
+ }
+ // format to 1 decimal place
+ const percentageClickedDisplay = MailPoet.Num.toLocaleFixed(percentageClicked, 1);
+ const percentageOpenedDisplay = MailPoet.Num.toLocaleFixed(percentageOpened, 1);
+ const percentageUnsubscribedDisplay = MailPoet.Num.toLocaleFixed(percentageUnsubscribed, 1);
+ const headlineOpened = `${percentageOpenedDisplay}% ${MailPoet.I18n.t('percentageOpened')}`;
+ const headlineClicked = `${percentageClickedDisplay}% ${MailPoet.I18n.t('percentageClicked')}`;
+ const headlineUnsubscribed = `${percentageUnsubscribedDisplay}% ${MailPoet.I18n.t('percentageUnsubscribed')}`;
+ const statsKBLink = 'http://beta.docs.mailpoet.com/article/190-whats-a-good-email-open-rate';
+ // thresholds to display badges
+ const minNewslettersSent = 20;
+ const minNewslettersOpened = 5;
+ let statsContent;
+ if (totalSent >= minNewslettersSent
+ && newsletter.statistics.opened >= minNewslettersOpened
+ ) {
+ // display stats with badges
+ statsContent = (
+
+ );
+ } else {
+ // display stats without badges
+ statsContent = (
+
+
+ {headlineOpened}
+
+
+ {headlineClicked}
+
+
+
+ {headlineUnsubscribed}
+
+
+ );
+ }
+
+ return (
+
+
+ {MailPoet.I18n.t('statsTotalSent')}
+ {' '}
+ {parseInt(totalSent, 10).toLocaleString()}
+
+ {statsContent}
+ { newsletter.ga_campaign && (
+
+ {MailPoet.I18n.t('googleAnalytics')}
+ :
+ { newsletter.ga_campaign }
+
+ ) }
+
+
+ {MailPoet.I18n.t('readMoreOnStats')}
+
+
+
+ );
+};
+
+NewsletterGeneralStats.propTypes = {
+ newsletter: PropTypes.shape({
+ ga_campaign: PropTypes.string,
+ total_sent: PropTypes.number,
+ statistics: PropTypes.shape({
+ clicked: PropTypes.number,
+ opened: PropTypes.number,
+ unsubscribed: PropTypes.number,
+ revenue: PropTypes.shape({
+ currency: PropTypes.string.isRequired,
+ value: PropTypes.number.isRequired,
+ formatted: PropTypes.string.isRequired,
+ count: PropTypes.number.isRequired,
+ }),
+ }).isRequired,
+ }).isRequired,
+};
+
+export default NewsletterGeneralStats;
diff --git a/assets/js/src/newsletters/campaign_stats/page.jsx b/assets/js/src/newsletters/campaign_stats/page.jsx
new file mode 100644
index 0000000000..14a71f3f94
--- /dev/null
+++ b/assets/js/src/newsletters/campaign_stats/page.jsx
@@ -0,0 +1,231 @@
+import MailPoet from 'mailpoet';
+import React from 'react';
+import { Link, withRouter } from 'react-router-dom';
+import ReactStringReplace from 'react-string-replace';
+import PropTypes from 'prop-types';
+import NewsletterGeneralStats from './newsletter_stats.jsx';
+import NewsletterStatsInfo from './newsletter_info.jsx';
+import ClickedLinksTable from './clicked_links_table.jsx';
+import SubscriberEngagementListing from './subscriber_engagement.jsx';
+import PurchasedProducts from './purchased_products.jsx';
+
+class CampaignStatsPage extends React.Component {
+ constructor(props) {
+ super(props);
+ this.state = {
+ item: {},
+ loading: true,
+ savingSegment: false,
+ segmentCreated: false,
+ segmentErrors: [],
+ };
+ this.handleCreateSegment = this.handleCreateSegment.bind(this);
+ }
+
+ componentDidMount() {
+ const { match } = this.props;
+ // Scroll to top in case we're coming
+ // from the middle of a long newsletter listing
+ window.scrollTo(0, 0);
+ this.loadItem(match.params.id);
+ }
+
+ componentWillReceiveProps(props) {
+ const { match } = this.props;
+ if (match.params.id !== props.match.params.id) {
+ this.loadItem(props.match.params.id);
+ }
+ }
+
+ handleCreateSegment(group, newsletter, linkId) {
+ const name = `${newsletter.subject} – ${group}`;
+ this.setState({ savingSegment: true, segmentCreated: false, segmentErrors: [] });
+ MailPoet.Ajax.post({
+ api_version: window.mailpoet_api_version,
+ endpoint: 'dynamic_segments',
+ action: 'save',
+ data: {
+ segmentType: 'email',
+ action: group === 'unopened' ? 'notOpened' : group,
+ newsletter_id: newsletter.id,
+ link_id: linkId,
+ name,
+ },
+ }).always(() => {
+ this.setState({ savingSegment: false });
+ }).done(() => {
+ this.setState({
+ segmentCreated: true,
+ segmentName: name,
+ });
+ }).fail((response) => {
+ this.setState({
+ segmentErrors:
+ response.errors.map(error => ((error.error === 409) ? MailPoet.I18n.t('segmentExists') : error.message)),
+ });
+ });
+ }
+
+ loadItem(id) {
+ const { history } = this.props;
+ this.setState({ loading: true });
+ MailPoet.Modal.loading(true);
+
+ MailPoet.Ajax.post({
+ api_version: window.mailpoet_api_version,
+ endpoint: 'stats',
+ action: 'get',
+ data: {
+ id,
+ },
+ }).always(() => {
+ MailPoet.Modal.loading(false);
+ }).done((response) => {
+ this.setState({
+ loading: false,
+ item: response.data,
+ });
+ }).fail((response) => {
+ MailPoet.Notice.error(
+ response.errors.map(error => error.message),
+ { scroll: true }
+ );
+ this.setState({
+ loading: false,
+ item: {},
+ }, () => {
+ history.push('/');
+ });
+ });
+ }
+
+ renderCreateSegmentSuccess() {
+ const { segmentCreated, segmentName } = this.state;
+ let segmentCreatedSuccessMessage;
+
+ if (segmentCreated) {
+ let message = ReactStringReplace(
+ MailPoet.I18n.t('successMessage'),
+ /\[link\](.*?)\[\/link\]/g,
+ (match, i) => (
+
+ {match}
+
+ )
+ );
+
+ message = ReactStringReplace(message, '%s', () => segmentName);
+
+ segmentCreatedSuccessMessage = (
+
+ );
+ }
+
+ return segmentCreatedSuccessMessage;
+ }
+
+ renderCreateSegmentError() {
+ const { segmentErrors } = this.state;
+ let error;
+
+ if (segmentErrors.length > 0) {
+ error = (
+
+ {segmentErrors.map(errorMessage => (
+
+ ))}
+
+ );
+ }
+
+ return error;
+ }
+
+ render() {
+ const { item, loading, savingSegment } = this.state;
+ const newsletter = item;
+ const { match, location } = this.props;
+
+ if (loading || !newsletter.queue) {
+ return (
+
+
+ {MailPoet.I18n.t('statsTitle')}
+
+ {MailPoet.I18n.t('backToList')}
+
+
+
+ );
+ }
+
+ return (
+
+
+ {`${MailPoet.I18n.t('statsTitle')}: ${newsletter.subject}`}
+
+ {MailPoet.I18n.t('backToList')}
+
+
+
+
+
+
{MailPoet.I18n.t('clickedLinks')}
+
+
+
+
+
+
+
+
{MailPoet.I18n.t('subscriberEngagement')}
+
+ {this.renderCreateSegmentSuccess()}
+ {this.renderCreateSegmentError()}
+
+
+
+ );
+ }
+}
+
+CampaignStatsPage.propTypes = {
+ match: PropTypes.shape({
+ params: PropTypes.object.isRequired,
+ }).isRequired,
+ location: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
+ history: PropTypes.shape({
+ push: PropTypes.func.isRequired,
+ }).isRequired,
+};
+
+export default withRouter(CampaignStatsPage);