Move statistics page layout from premium to free
[MAILPOET-2104]
This commit is contained in:
91
assets/js/src/newsletters/campaign_stats/newsletter_info.jsx
Normal file
91
assets/js/src/newsletters/campaign_stats/newsletter_info.jsx
Normal file
@@ -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 (
|
||||||
|
<div>
|
||||||
|
<div className="mailpoet_stat_spaced">
|
||||||
|
<a
|
||||||
|
href={newsletter.preview_url}
|
||||||
|
className="button-secondary"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
{MailPoet.I18n.t('statsPreviewNewsletter')}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
{MailPoet.I18n.t('statsDateSent')}
|
||||||
|
:
|
||||||
|
{' '}
|
||||||
|
{MailPoet.Date.format(newsletterDate)}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{ segments && (
|
||||||
|
<p>
|
||||||
|
{MailPoet.I18n.t('statsToSegments')}
|
||||||
|
:
|
||||||
|
{' '}
|
||||||
|
{ segments }
|
||||||
|
</p>
|
||||||
|
) }
|
||||||
|
|
||||||
|
<p>
|
||||||
|
{MailPoet.I18n.t('statsFromAddress')}
|
||||||
|
:
|
||||||
|
{' '}
|
||||||
|
{ senderAddress }
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{replyToAddress && (
|
||||||
|
<p>
|
||||||
|
{MailPoet.I18n.t('statsReplyToAddress')}
|
||||||
|
:
|
||||||
|
{' '}
|
||||||
|
{ replyToAddress }
|
||||||
|
</p>
|
||||||
|
) }
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
124
assets/js/src/newsletters/campaign_stats/newsletter_stats.jsx
Normal file
124
assets/js/src/newsletters/campaign_stats/newsletter_stats.jsx
Normal file
@@ -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 = (
|
||||||
|
<div className="mailpoet_stat_grey">
|
||||||
|
<div className="mailpoet_stat_big mailpoet_stat_spaced">
|
||||||
|
<StatsBadge
|
||||||
|
stat="opened"
|
||||||
|
rate={percentageOpened}
|
||||||
|
headline={headlineOpened}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mailpoet_stat_big mailpoet_stat_spaced">
|
||||||
|
<StatsBadge
|
||||||
|
stat="clicked"
|
||||||
|
rate={percentageClicked}
|
||||||
|
headline={headlineClicked}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<RevenuesStats
|
||||||
|
revenue={newsletter.statistics.revenue}
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<StatsBadge
|
||||||
|
stat="unsubscribed"
|
||||||
|
rate={percentageUnsubscribed}
|
||||||
|
headline={headlineUnsubscribed}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// display stats without badges
|
||||||
|
statsContent = (
|
||||||
|
<div className="mailpoet_stat_grey">
|
||||||
|
<div className="mailpoet_stat_big mailpoet_stat_spaced">
|
||||||
|
{headlineOpened}
|
||||||
|
</div>
|
||||||
|
<div className="mailpoet_stat_big mailpoet_stat_spaced">
|
||||||
|
{headlineClicked}
|
||||||
|
</div>
|
||||||
|
<RevenuesStats
|
||||||
|
revenue={newsletter.statistics.revenue}
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
{headlineUnsubscribed}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p className="mailpoet_stat_grey mailpoet_stat_big">
|
||||||
|
{MailPoet.I18n.t('statsTotalSent')}
|
||||||
|
{' '}
|
||||||
|
{parseInt(totalSent, 10).toLocaleString()}
|
||||||
|
</p>
|
||||||
|
{statsContent}
|
||||||
|
{ newsletter.ga_campaign && (
|
||||||
|
<p>
|
||||||
|
{MailPoet.I18n.t('googleAnalytics')}
|
||||||
|
:
|
||||||
|
{ newsletter.ga_campaign }
|
||||||
|
</p>
|
||||||
|
) }
|
||||||
|
<p>
|
||||||
|
<a href={statsKBLink} target="_blank" rel="noopener noreferrer">
|
||||||
|
{MailPoet.I18n.t('readMoreOnStats')}
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
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;
|
231
assets/js/src/newsletters/campaign_stats/page.jsx
Normal file
231
assets/js/src/newsletters/campaign_stats/page.jsx
Normal file
@@ -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) => (
|
||||||
|
<a
|
||||||
|
key={i}
|
||||||
|
href="?page=mailpoet-newsletters#/new"
|
||||||
|
>
|
||||||
|
{match}
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
message = ReactStringReplace(message, '%s', () => segmentName);
|
||||||
|
|
||||||
|
segmentCreatedSuccessMessage = (
|
||||||
|
<div className="mailpoet_notice notice inline notice-success">
|
||||||
|
<p>{message}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return segmentCreatedSuccessMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
renderCreateSegmentError() {
|
||||||
|
const { segmentErrors } = this.state;
|
||||||
|
let error;
|
||||||
|
|
||||||
|
if (segmentErrors.length > 0) {
|
||||||
|
error = (
|
||||||
|
<div>
|
||||||
|
{segmentErrors.map(errorMessage => (
|
||||||
|
<div className="mailpoet_notice notice inline error" key={`error-${errorMessage}`}>
|
||||||
|
<p>{errorMessage}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { item, loading, savingSegment } = this.state;
|
||||||
|
const newsletter = item;
|
||||||
|
const { match, location } = this.props;
|
||||||
|
|
||||||
|
if (loading || !newsletter.queue) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1 className="title">
|
||||||
|
{MailPoet.I18n.t('statsTitle')}
|
||||||
|
<Link
|
||||||
|
className="page-title-action"
|
||||||
|
to="/"
|
||||||
|
>
|
||||||
|
{MailPoet.I18n.t('backToList')}
|
||||||
|
</Link>
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1 className="title">
|
||||||
|
{`${MailPoet.I18n.t('statsTitle')}: ${newsletter.subject}`}
|
||||||
|
<Link
|
||||||
|
className="page-title-action"
|
||||||
|
to="/"
|
||||||
|
>
|
||||||
|
{MailPoet.I18n.t('backToList')}
|
||||||
|
</Link>
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<div className="mailpoet_stat_triple-spaced">
|
||||||
|
<div className="mailpoet_stat_info">
|
||||||
|
<NewsletterStatsInfo newsletter={newsletter} />
|
||||||
|
</div>
|
||||||
|
<div className="mailpoet_stat_general">
|
||||||
|
<NewsletterGeneralStats newsletter={newsletter} />
|
||||||
|
</div>
|
||||||
|
<div style={{ clear: 'both' }} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>{MailPoet.I18n.t('clickedLinks')}</h2>
|
||||||
|
|
||||||
|
<div className="mailpoet_stat_triple-spaced">
|
||||||
|
<ClickedLinksTable links={newsletter.clicked_links} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mailpoet_stat_triple-spaced">
|
||||||
|
<PurchasedProducts newsletter={newsletter} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>{MailPoet.I18n.t('subscriberEngagement')}</h2>
|
||||||
|
|
||||||
|
{this.renderCreateSegmentSuccess()}
|
||||||
|
{this.renderCreateSegmentError()}
|
||||||
|
|
||||||
|
<SubscriberEngagementListing
|
||||||
|
location={location}
|
||||||
|
params={match.params}
|
||||||
|
newsletter={newsletter}
|
||||||
|
handleCreateSegment={this.handleCreateSegment}
|
||||||
|
savingSegment={savingSegment}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
Reference in New Issue
Block a user