Panels with campaign statistics and some fixes in computation of clicks.
This commit is contained in:
parent
ba996d845d
commit
d103a2cc79
18 changed files with 811 additions and 96 deletions
|
@ -16,8 +16,12 @@ import {
|
||||||
import axios
|
import axios
|
||||||
from "../lib/axios";
|
from "../lib/axios";
|
||||||
import {getUrl} from "../lib/urls";
|
import {getUrl} from "../lib/urls";
|
||||||
|
import {AlignedRow} from "../lib/form";
|
||||||
|
import {Icon} from "../lib/bootstrap-components";
|
||||||
|
|
||||||
import Chart from 'react-google-charts';
|
import styles
|
||||||
|
from "./styles.scss";
|
||||||
|
import {Link} from "react-router-dom";
|
||||||
|
|
||||||
@withTranslation()
|
@withTranslation()
|
||||||
@withPageHelpers
|
@withPageHelpers
|
||||||
|
@ -30,7 +34,8 @@ export default class Statistics extends Component {
|
||||||
const t = props.t;
|
const t = props.t;
|
||||||
|
|
||||||
this.state = {
|
this.state = {
|
||||||
entity: props.entity
|
entity: props.entity,
|
||||||
|
statisticsOverview: props.statisticsOverview
|
||||||
};
|
};
|
||||||
|
|
||||||
this.refreshTimeoutHandler = ::this.periodicRefreshTask;
|
this.refreshTimeoutHandler = ::this.periodicRefreshTask;
|
||||||
|
@ -38,7 +43,8 @@ export default class Statistics extends Component {
|
||||||
}
|
}
|
||||||
|
|
||||||
static propTypes = {
|
static propTypes = {
|
||||||
entity: PropTypes.object
|
entity: PropTypes.object,
|
||||||
|
statisticsOverview: PropTypes.object
|
||||||
}
|
}
|
||||||
|
|
||||||
@withAsyncErrorHandler
|
@withAsyncErrorHandler
|
||||||
|
@ -48,8 +54,12 @@ export default class Statistics extends Component {
|
||||||
resp = await axios.get(getUrl(`rest/campaigns-stats/${this.props.entity.id}`));
|
resp = await axios.get(getUrl(`rest/campaigns-stats/${this.props.entity.id}`));
|
||||||
const entity = resp.data;
|
const entity = resp.data;
|
||||||
|
|
||||||
|
resp = await axios.get(getUrl(`rest/campaign-statistics/${this.props.entity.id}/overview`));
|
||||||
|
const statisticsOverview = resp.data;
|
||||||
|
|
||||||
this.setState({
|
this.setState({
|
||||||
entity
|
entity,
|
||||||
|
statisticsOverview
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -57,7 +67,7 @@ export default class Statistics extends Component {
|
||||||
// The periodic task runs all the time, so that we don't have to worry about starting/stopping it as a reaction to the buttons.
|
// The periodic task runs all the time, so that we don't have to worry about starting/stopping it as a reaction to the buttons.
|
||||||
await this.refreshEntity();
|
await this.refreshEntity();
|
||||||
if (this.refreshTimeoutHandler) { // For some reason the task gets rescheduled if server is restarted while the page is shown. That why we have this check here.
|
if (this.refreshTimeoutHandler) { // For some reason the task gets rescheduled if server is restarted while the page is shown. That why we have this check here.
|
||||||
this.refreshTimeoutId = setTimeout(this.refreshTimeoutHandler, 10000);
|
this.refreshTimeoutId = setTimeout(this.refreshTimeoutHandler, 60000);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -76,48 +86,55 @@ export default class Statistics extends Component {
|
||||||
const t = this.props.t;
|
const t = this.props.t;
|
||||||
const entity = this.state.entity;
|
const entity = this.state.entity;
|
||||||
|
|
||||||
|
const stats = this.state.statisticsOverview;
|
||||||
|
|
||||||
|
const renderMetrics = (key, label, showZoomIn = true) => {
|
||||||
|
const val = stats[key]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AlignedRow label={label}><span className={styles.statsMetrics}>{val}</span>{showZoomIn && <span className={styles.zoomIn}><Link to={`/campaigns/${entity.id}/statistics/${key}`}><Icon icon="zoom-in"/></Link></span>}</AlignedRow>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderMetricsWithProgress = (key, label, progressBarClass, showZoomIn = true) => {
|
||||||
|
const val = stats[key]
|
||||||
|
|
||||||
|
if (!stats.total) {
|
||||||
|
return renderMetrics(key, label);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rate = Math.round(val / stats.total * 100);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AlignedRow label={label}>
|
||||||
|
{showZoomIn && <span className={styles.statsProgressBarZoomIn}><Link to={`/campaigns/${entity.id}/statistics/${key}`}><Icon icon="zoom-in"/></Link></span>}
|
||||||
|
<div className={`progress ${styles.statsProgressBar}`}>
|
||||||
|
<div
|
||||||
|
className={`progress-bar progress-bar-${progressBarClass}`}
|
||||||
|
role="progressbar"
|
||||||
|
aria-valuenow={stats.bounced}
|
||||||
|
aria-valuemin="0"
|
||||||
|
aria-valuemax="100"
|
||||||
|
style={{minWidth: '6em', width: rate + '%'}}>
|
||||||
|
{val} ({rate}%)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AlignedRow>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Title>{t('campaignStatistics')}</Title>
|
<Title>{t('campaignStatistics')}</Title>
|
||||||
|
|
||||||
<Chart
|
{renderMetrics('total', t('Total'), false)}
|
||||||
width="100%"
|
{renderMetrics('delivered', t('Delivered'))}
|
||||||
height="500px"
|
{renderMetrics('blacklisted', t('Blacklisted'), false)}
|
||||||
chartType="PieChart"
|
{renderMetricsWithProgress('bounced', t('Bounced'), 'info')}
|
||||||
loader={<div>Loading Chart</div>}
|
{renderMetricsWithProgress('complained', t('Complaints'), 'danger')}
|
||||||
data={[
|
{renderMetricsWithProgress('unsubscribed', t('Unsubscribed'), 'warning')}
|
||||||
['Task', 'Hours per Day'],
|
{!entity.open_tracking_disabled && renderMetricsWithProgress('opened', t('Opened'), 'success')}
|
||||||
['Work', 11],
|
{!entity.click_tracking_disabled && renderMetricsWithProgress('clicks', t('Clicked'), 'success')}
|
||||||
['Eat', 2],
|
|
||||||
['Commute', 2],
|
|
||||||
['Watch TV', 2],
|
|
||||||
['Sleep', 7],
|
|
||||||
]}
|
|
||||||
options={{
|
|
||||||
title: 'My Daily Activities',
|
|
||||||
}}
|
|
||||||
rootProps={{ 'data-testid': '1' }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Chart
|
|
||||||
width="100%"
|
|
||||||
height="500px"
|
|
||||||
chartType="GeoChart"
|
|
||||||
data={[
|
|
||||||
['Country', 'Popularity'],
|
|
||||||
['Germany', 200],
|
|
||||||
['United States', 300],
|
|
||||||
['Brazil', 400],
|
|
||||||
['Canada', 500],
|
|
||||||
['France', 600],
|
|
||||||
['RU', 700],
|
|
||||||
]}
|
|
||||||
// Note: you will need to get a mapsApiKey for your project.
|
|
||||||
// See: https://developers.google.com/chart/interactive/docs/basic_load_libs#load-settings
|
|
||||||
mapsApiKey="YOUR_KEY_HERE"
|
|
||||||
rootProps={{ 'data-testid': '1' }}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
52
client/src/campaigns/StatisticsLinkClicks.js
Normal file
52
client/src/campaigns/StatisticsLinkClicks.js
Normal file
|
@ -0,0 +1,52 @@
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
import React, {Component} from 'react';
|
||||||
|
import PropTypes
|
||||||
|
from 'prop-types';
|
||||||
|
import {withTranslation} from '../lib/i18n';
|
||||||
|
import {
|
||||||
|
requiresAuthenticatedUser,
|
||||||
|
Title,
|
||||||
|
withPageHelpers
|
||||||
|
} from '../lib/page';
|
||||||
|
import {withErrorHandling} from '../lib/error-handling';
|
||||||
|
import {Table} from "../lib/table";
|
||||||
|
|
||||||
|
@withTranslation()
|
||||||
|
@withPageHelpers
|
||||||
|
@withErrorHandling
|
||||||
|
@requiresAuthenticatedUser
|
||||||
|
export default class StatisticsLinkClicks extends Component {
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
|
||||||
|
const t = props.t;
|
||||||
|
|
||||||
|
this.state = {
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
static propTypes = {
|
||||||
|
entity: PropTypes.object,
|
||||||
|
title: PropTypes.string
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const t = this.props.t;
|
||||||
|
|
||||||
|
const linksColumns = [
|
||||||
|
{ data: 0, title: t('URL'), render: data => <code>{data}</code> },
|
||||||
|
{ data: 1, title: t('Unique visitors') },
|
||||||
|
{ data: 2, title: t('Total clicks') }
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Title>{t('Campaign links')}</Title>
|
||||||
|
|
||||||
|
<Table ref={node => this.table = node} withHeader dataUrl={`rest/campaigns-link-clicks-table/${this.props.entity.id}`} columns={linksColumns} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
231
client/src/campaigns/StatisticsOpened.js
Normal file
231
client/src/campaigns/StatisticsOpened.js
Normal file
|
@ -0,0 +1,231 @@
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
import React, {Component} from 'react';
|
||||||
|
import PropTypes
|
||||||
|
from 'prop-types';
|
||||||
|
import {withTranslation} from '../lib/i18n';
|
||||||
|
import {
|
||||||
|
requiresAuthenticatedUser,
|
||||||
|
Title,
|
||||||
|
withPageHelpers
|
||||||
|
} from '../lib/page';
|
||||||
|
import {
|
||||||
|
withAsyncErrorHandler,
|
||||||
|
withErrorHandling
|
||||||
|
} from '../lib/error-handling';
|
||||||
|
import axios
|
||||||
|
from "../lib/axios";
|
||||||
|
import {getUrl} from "../lib/urls";
|
||||||
|
|
||||||
|
import Chart from 'react-google-charts';
|
||||||
|
import {AlignedRow} from "../lib/form";
|
||||||
|
import {
|
||||||
|
ActionLink,
|
||||||
|
Icon
|
||||||
|
} from "../lib/bootstrap-components";
|
||||||
|
import {SubscriptionStatus} from "../../../shared/lists";
|
||||||
|
|
||||||
|
import styles from "./styles.scss";
|
||||||
|
import {Table} from "../lib/table";
|
||||||
|
import {Link} from "react-router-dom";
|
||||||
|
|
||||||
|
import mailtrainConfig from "mailtrainConfig";
|
||||||
|
|
||||||
|
@withTranslation()
|
||||||
|
@withPageHelpers
|
||||||
|
@withErrorHandling
|
||||||
|
@requiresAuthenticatedUser
|
||||||
|
export default class StatisticsOpened extends Component {
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
|
||||||
|
const t = props.t;
|
||||||
|
|
||||||
|
this.state = {
|
||||||
|
entity: props.entity,
|
||||||
|
statisticsOpened: props.statisticsOpened
|
||||||
|
};
|
||||||
|
|
||||||
|
this.refreshTimeoutHandler = ::this.periodicRefreshTask;
|
||||||
|
this.refreshTimeoutId = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
static propTypes = {
|
||||||
|
entity: PropTypes.object,
|
||||||
|
statisticsOpened: PropTypes.object,
|
||||||
|
agg: PropTypes.string
|
||||||
|
}
|
||||||
|
|
||||||
|
@withAsyncErrorHandler
|
||||||
|
async refreshEntity() {
|
||||||
|
let resp;
|
||||||
|
|
||||||
|
resp = await axios.get(getUrl(`rest/campaigns-stats/${this.props.entity.id}`));
|
||||||
|
const entity = resp.data;
|
||||||
|
|
||||||
|
resp = await axios.get(getUrl(`rest/campaign-statistics/${this.props.entity.id}/opened`));
|
||||||
|
const statisticsOpened = resp.data;
|
||||||
|
|
||||||
|
this.setState({
|
||||||
|
entity,
|
||||||
|
statisticsOpened
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async periodicRefreshTask() {
|
||||||
|
// The periodic task runs all the time, so that we don't have to worry about starting/stopping it as a reaction to the buttons.
|
||||||
|
await this.refreshEntity();
|
||||||
|
if (this.refreshTimeoutHandler) { // For some reason the task gets rescheduled if server is restarted while the page is shown. That why we have this check here.
|
||||||
|
this.refreshTimeoutId = setTimeout(this.refreshTimeoutHandler, 60000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
// noinspection JSIgnoredPromiseFromCall
|
||||||
|
this.periodicRefreshTask();
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillUnmount() {
|
||||||
|
clearTimeout(this.refreshTimeoutId);
|
||||||
|
this.refreshTimeoutHandler = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const t = this.props.t;
|
||||||
|
const entity = this.state.entity;
|
||||||
|
const agg = this.props.agg;
|
||||||
|
|
||||||
|
const stats = this.state.statisticsOpened;
|
||||||
|
|
||||||
|
const subscribersColumns = [
|
||||||
|
{ data: 0, title: t('Email') },
|
||||||
|
{ data: 1, title: t('subscriptionId'), render: data => <code>{data}</code> },
|
||||||
|
{ data: 2, title: t('listId'), render: data => <code>{data}</code> },
|
||||||
|
{ data: 3, title: t('list') },
|
||||||
|
{ data: 4, title: t('listNamespace') },
|
||||||
|
{ data: 5, title: t('Opens count') }
|
||||||
|
];
|
||||||
|
|
||||||
|
console.log(this.state.statisticsOpened);
|
||||||
|
|
||||||
|
const renderNavPill = (key, label) => (
|
||||||
|
<li role="presentation" className={agg === key ? 'active' : ''}>
|
||||||
|
<Link to={`/campaigns/${entity.id}/statistics/opened/${key}`}>{label}</Link>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
|
||||||
|
const navPills = (
|
||||||
|
<ul className={`nav nav-pills ${styles.navPills}`}>
|
||||||
|
{renderNavPill('countries', t('Countries'))}
|
||||||
|
{renderNavPill('devices', t('Devices'))}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
let charts = null;
|
||||||
|
|
||||||
|
if (agg === 'devices') {
|
||||||
|
charts = (
|
||||||
|
<div className={styles.charts}>
|
||||||
|
{navPills}
|
||||||
|
<h4 className={styles.chartTitle}>{t('Distribution by device type')}</h4>
|
||||||
|
<Chart
|
||||||
|
width="100%"
|
||||||
|
height="300px"
|
||||||
|
chartType="PieChart"
|
||||||
|
loader={<div>{t('Loading chart')}</div>}
|
||||||
|
data={[
|
||||||
|
[t('Device type'), t('Count')],
|
||||||
|
...stats.devices.map(entry => [entry.key, entry.count])
|
||||||
|
]}
|
||||||
|
options={{
|
||||||
|
chartArea: {
|
||||||
|
left: "25%",
|
||||||
|
top: 15,
|
||||||
|
width: "100%",
|
||||||
|
height: 270
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
showColorCode: true
|
||||||
|
},
|
||||||
|
legend: {
|
||||||
|
position: "right",
|
||||||
|
alignment: "start",
|
||||||
|
textStyle: {
|
||||||
|
fontSize: 14
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
} else if (agg === 'countries') {
|
||||||
|
charts = (
|
||||||
|
<div className={styles.charts}>
|
||||||
|
{navPills}
|
||||||
|
<h4 className={styles.sectionTitle}>{t('Distribution by country')}</h4>
|
||||||
|
<div className="row">
|
||||||
|
<div className={`col-md-6 ${styles.chart}`}>
|
||||||
|
<Chart
|
||||||
|
width="100%"
|
||||||
|
height="300px"
|
||||||
|
chartType="PieChart"
|
||||||
|
loader={<div>{t('Loading chart')}</div>}
|
||||||
|
data={[
|
||||||
|
[t('Country'), t('Count')],
|
||||||
|
...stats.countries.map(entry => [entry.key || t('Unknown'), entry.count])
|
||||||
|
]}
|
||||||
|
options={{
|
||||||
|
chartArea: {
|
||||||
|
left: "25%",
|
||||||
|
top: 15,
|
||||||
|
width: "100%",
|
||||||
|
height: 270
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
showColorCode: true
|
||||||
|
},
|
||||||
|
legend: {
|
||||||
|
position: "right",
|
||||||
|
alignment: "start",
|
||||||
|
textStyle: {
|
||||||
|
fontSize: 14
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={`col-md-6 ${styles.chart}`}>
|
||||||
|
<Chart
|
||||||
|
width="100%"
|
||||||
|
height="300px"
|
||||||
|
chartType="GeoChart"
|
||||||
|
data={[
|
||||||
|
['Country', 'Count'],
|
||||||
|
...stats.countries.map(entry => [entry.key || t('Unknown'), entry.count])
|
||||||
|
]}
|
||||||
|
mapsApiKey={mailtrainConfig.mapsApiKey}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(mailtrainConfig);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Title>{t('Detailed Statistics')}</Title>
|
||||||
|
|
||||||
|
{charts}
|
||||||
|
|
||||||
|
<hr/>
|
||||||
|
|
||||||
|
<h4 className={styles.sectionTitle}>{t('List of subscribers that opened the campaign')}</h4>
|
||||||
|
<Table ref={node => this.table = node} withHeader dataUrl={`rest/campaigns-opens-table/${entity.id}`} columns={subscribersColumns} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
55
client/src/campaigns/StatisticsSubsList.js
Normal file
55
client/src/campaigns/StatisticsSubsList.js
Normal file
|
@ -0,0 +1,55 @@
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
import React, {Component} from 'react';
|
||||||
|
import PropTypes
|
||||||
|
from 'prop-types';
|
||||||
|
import {withTranslation} from '../lib/i18n';
|
||||||
|
import {
|
||||||
|
requiresAuthenticatedUser,
|
||||||
|
Title,
|
||||||
|
withPageHelpers
|
||||||
|
} from '../lib/page';
|
||||||
|
import {withErrorHandling} from '../lib/error-handling';
|
||||||
|
import {Table} from "../lib/table";
|
||||||
|
|
||||||
|
@withTranslation()
|
||||||
|
@withPageHelpers
|
||||||
|
@withErrorHandling
|
||||||
|
@requiresAuthenticatedUser
|
||||||
|
export default class StatisticsSubsList extends Component {
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
|
||||||
|
const t = props.t;
|
||||||
|
|
||||||
|
this.state = {
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
static propTypes = {
|
||||||
|
entity: PropTypes.object,
|
||||||
|
status: PropTypes.number,
|
||||||
|
title: PropTypes.string
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const t = this.props.t;
|
||||||
|
|
||||||
|
const subscribersColumns = [
|
||||||
|
{ data: 0, title: t('Email') },
|
||||||
|
{ data: 1, title: t('subscriptionId'), render: data => <code>{data}</code> },
|
||||||
|
{ data: 2, title: t('listId'), render: data => <code>{data}</code> },
|
||||||
|
{ data: 3, title: t('list') },
|
||||||
|
{ data: 4, title: t('listNamespace') }
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Title>{this.props.title}</Title>
|
||||||
|
|
||||||
|
<Table ref={node => this.table = node} withHeader dataUrl={`rest/campaigns-subscribers-by-status-table/${this.props.entity.id}/${this.props.status}`} columns={subscribersColumns} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -27,7 +27,8 @@ import {getCampaignLabels} from './helpers';
|
||||||
import {Table} from "../lib/table";
|
import {Table} from "../lib/table";
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Icon
|
Icon,
|
||||||
|
ModalDialog
|
||||||
} from "../lib/bootstrap-components";
|
} from "../lib/bootstrap-components";
|
||||||
import axios from "../lib/axios";
|
import axios from "../lib/axios";
|
||||||
import {getUrl, getPublicUrl} from "../lib/urls";
|
import {getUrl, getPublicUrl} from "../lib/urls";
|
||||||
|
@ -214,9 +215,16 @@ class SendControls extends Component {
|
||||||
}
|
}
|
||||||
|
|
||||||
async resetAsync() {
|
async resetAsync() {
|
||||||
|
const t = this.props.t;
|
||||||
|
this.actionDialog(
|
||||||
|
t('Confirm reset'),
|
||||||
|
t('Do you want to reset the campaign? All statistics and the track of delivered messages will be lost.'),
|
||||||
|
async () => {
|
||||||
await this.postAndMaskStateError(`rest/campaign-reset/${this.props.entity.id}`);
|
await this.postAndMaskStateError(`rest/campaign-reset/${this.props.entity.id}`);
|
||||||
await this.refreshEntity();
|
await this.refreshEntity();
|
||||||
}
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async enableAsync() {
|
async enableAsync() {
|
||||||
await this.postAndMaskStateError(`rest/campaign-enable/${this.props.entity.id}`);
|
await this.postAndMaskStateError(`rest/campaign-enable/${this.props.entity.id}`);
|
||||||
|
@ -228,16 +236,47 @@ class SendControls extends Component {
|
||||||
await this.refreshEntity();
|
await this.refreshEntity();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
actionDialog(title, message, callback) {
|
||||||
|
this.setState({
|
||||||
|
modalTitle: title,
|
||||||
|
modalMessage: message,
|
||||||
|
modalCallback: callback,
|
||||||
|
modalVisible: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
modalAction(isYes) {
|
||||||
|
if (isYes && this.state.modalCallback) {
|
||||||
|
this.state.modalCallback();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.setState({
|
||||||
|
modalTitle: '',
|
||||||
|
modalMessage: '',
|
||||||
|
modalCallback: null,
|
||||||
|
modalVisible: false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const t = this.props.t;
|
const t = this.props.t;
|
||||||
const entity = this.props.entity;
|
const entity = this.props.entity;
|
||||||
|
|
||||||
|
const yesNoDialog = (
|
||||||
|
<ModalDialog hidden={!this.state.modalVisible} title={this.state.modalTitle} onCloseAsync={() => this.modalAction(false)} buttons={[
|
||||||
|
{ label: t('no'), className: 'btn-primary', onClickAsync: () => this.modalAction(false) },
|
||||||
|
{ label: t('yes'), className: 'btn-danger', onClickAsync: () => this.modalAction(true) }
|
||||||
|
]}>
|
||||||
|
{this.state.modalMessage}
|
||||||
|
</ModalDialog>
|
||||||
|
);
|
||||||
|
|
||||||
if (entity.status === CampaignStatus.IDLE || entity.status === CampaignStatus.PAUSED || (entity.status === CampaignStatus.SCHEDULED && entity.scheduled)) {
|
if (entity.status === CampaignStatus.IDLE || entity.status === CampaignStatus.PAUSED || (entity.status === CampaignStatus.SCHEDULED && entity.scheduled)) {
|
||||||
|
|
||||||
const subscrInfo = entity.subscriptionsTotal === undefined ? '' : ` (${entity.subscriptionsToSend} ${t('subscribers-1')})`;
|
const subscrInfo = entity.subscriptionsTotal === undefined ? '' : ` (${entity.subscriptionsToSend} ${t('subscribers-1')})`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>{yesNoDialog}
|
||||||
<AlignedRow label={t('sendStatus')}>
|
<AlignedRow label={t('sendStatus')}>
|
||||||
{entity.scheduled ? t('campaignIsScheduledForDelivery') : t('campaignIsReadyToBeSentOut')}
|
{entity.scheduled ? t('campaignIsScheduledForDelivery') : t('campaignIsReadyToBeSentOut')}
|
||||||
</AlignedRow>
|
</AlignedRow>
|
||||||
|
@ -265,7 +304,7 @@ class SendControls extends Component {
|
||||||
|
|
||||||
} else if (entity.status === CampaignStatus.SENDING || (entity.status === CampaignStatus.SCHEDULED && !entity.scheduled)) {
|
} else if (entity.status === CampaignStatus.SENDING || (entity.status === CampaignStatus.SCHEDULED && !entity.scheduled)) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>{yesNoDialog}
|
||||||
<AlignedRow label={t('sendStatus')}>
|
<AlignedRow label={t('sendStatus')}>
|
||||||
{t('campaignIsBeingSentOut')}
|
{t('campaignIsBeingSentOut')}
|
||||||
</AlignedRow>
|
</AlignedRow>
|
||||||
|
@ -280,7 +319,7 @@ class SendControls extends Component {
|
||||||
const subscrInfo = entity.subscriptionsTotal === undefined ? '' : ` (${entity.subscriptionsToSend} ${t('subscribers-1')})`;
|
const subscrInfo = entity.subscriptionsTotal === undefined ? '' : ` (${entity.subscriptionsToSend} ${t('subscribers-1')})`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>{yesNoDialog}
|
||||||
<AlignedRow label={t('sendStatus')}>
|
<AlignedRow label={t('sendStatus')}>
|
||||||
{t('allMessagesSent!HitContinueIfYouYouWant')}
|
{t('allMessagesSent!HitContinueIfYouYouWant')}
|
||||||
</AlignedRow>
|
</AlignedRow>
|
||||||
|
@ -294,7 +333,7 @@ class SendControls extends Component {
|
||||||
|
|
||||||
} else if (entity.status === CampaignStatus.INACTIVE) {
|
} else if (entity.status === CampaignStatus.INACTIVE) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>{yesNoDialog}
|
||||||
<AlignedRow label={t('sendStatus')}>
|
<AlignedRow label={t('sendStatus')}>
|
||||||
{t('yourCampaignIsCurrentlyDisabledClick')}
|
{t('yourCampaignIsCurrentlyDisabledClick')}
|
||||||
</AlignedRow>
|
</AlignedRow>
|
||||||
|
@ -306,7 +345,7 @@ class SendControls extends Component {
|
||||||
|
|
||||||
} else if (entity.status === CampaignStatus.ACTIVE) {
|
} else if (entity.status === CampaignStatus.ACTIVE) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>{yesNoDialog}
|
||||||
<AlignedRow label={t('sendStatus')}>
|
<AlignedRow label={t('sendStatus')}>
|
||||||
{t('yourCampaignIsEnabledAndSendingMessages')}
|
{t('yourCampaignIsEnabledAndSendingMessages')}
|
||||||
</AlignedRow>
|
</AlignedRow>
|
||||||
|
|
|
@ -1,24 +1,46 @@
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
import React from 'react';
|
import React
|
||||||
|
from 'react';
|
||||||
|
|
||||||
import Status from './Status';
|
import Status
|
||||||
import Statistics from './Statistics';
|
from './Status';
|
||||||
import CampaignsCUD from './CUD';
|
import Statistics
|
||||||
import Content from './Content';
|
from './Statistics';
|
||||||
import CampaignsList from './List';
|
import CampaignsCUD
|
||||||
import Share from '../shares/Share';
|
from './CUD';
|
||||||
import Files from "../lib/files";
|
import Content
|
||||||
|
from './Content';
|
||||||
|
import CampaignsList
|
||||||
|
from './List';
|
||||||
|
import Share
|
||||||
|
from '../shares/Share';
|
||||||
|
import Files
|
||||||
|
from "../lib/files";
|
||||||
import {
|
import {
|
||||||
CampaignSource,
|
CampaignSource,
|
||||||
CampaignStatus,
|
CampaignStatus,
|
||||||
CampaignType
|
CampaignType
|
||||||
} from "../../../shared/campaigns";
|
} from "../../../shared/campaigns";
|
||||||
import TriggersCUD from './triggers/CUD';
|
import TriggersCUD
|
||||||
import TriggersList from './triggers/List';
|
from './triggers/CUD';
|
||||||
|
import TriggersList
|
||||||
|
from './triggers/List';
|
||||||
|
import StatisticsSubsList
|
||||||
|
from "./StatisticsSubsList";
|
||||||
|
import {SubscriptionStatus} from "../../../shared/lists";
|
||||||
|
import StatisticsOpened
|
||||||
|
from "./StatisticsOpened";
|
||||||
|
import StatisticsLinkClicks
|
||||||
|
from "./StatisticsLinkClicks";
|
||||||
|
|
||||||
|
|
||||||
function getMenus(t) {
|
function getMenus(t) {
|
||||||
|
const aggLabels = {
|
||||||
|
'countries': t('Countries'),
|
||||||
|
'devices': t('Devices')
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'campaigns': {
|
'campaigns': {
|
||||||
title: t('campaigns'),
|
title: t('campaigns'),
|
||||||
|
@ -30,7 +52,7 @@ function getMenus(t) {
|
||||||
resolve: {
|
resolve: {
|
||||||
campaign: params => `rest/campaigns-settings/${params.campaignId}`
|
campaign: params => `rest/campaigns-settings/${params.campaignId}`
|
||||||
},
|
},
|
||||||
link: params => `/campaigns/${params.campaignId}/edit`,
|
link: params => `/campaigns/${params.campaignId}/status`,
|
||||||
navs: {
|
navs: {
|
||||||
status: {
|
status: {
|
||||||
title: t('status'),
|
title: t('status'),
|
||||||
|
@ -40,9 +62,53 @@ function getMenus(t) {
|
||||||
},
|
},
|
||||||
statistics: {
|
statistics: {
|
||||||
title: t('statistics'),
|
title: t('statistics'),
|
||||||
|
resolve: {
|
||||||
|
statisticsOverview: params => `rest/campaign-statistics/${params.campaignId}/overview`
|
||||||
|
},
|
||||||
link: params => `/campaigns/${params.campaignId}/statistics`,
|
link: params => `/campaigns/${params.campaignId}/statistics`,
|
||||||
visible: resolved => resolved.campaign.permissions.includes('viewStats') && (resolved.campaign.status === CampaignStatus.SENDING || resolved.campaign.status === CampaignStatus.PAUSED || resolved.campaign.status === CampaignStatus.FINISHED),
|
visible: resolved => resolved.campaign.permissions.includes('viewStats') && (resolved.campaign.status === CampaignStatus.SENDING || resolved.campaign.status === CampaignStatus.PAUSED || resolved.campaign.status === CampaignStatus.FINISHED),
|
||||||
panelRender: props => <Statistics entity={props.resolved.campaign} />
|
panelRender: props => <Statistics entity={props.resolved.campaign} statisticsOverview={props.resolved.statisticsOverview} />,
|
||||||
|
children: {
|
||||||
|
delivered: {
|
||||||
|
title: t('Delivered'),
|
||||||
|
link: params => `/campaigns/${params.campaignId}/statistics/delivered`,
|
||||||
|
panelRender: props => <StatisticsSubsList entity={props.resolved.campaign} title={t('Delivered Emails')} status={SubscriptionStatus.SUBSCRIBED} />
|
||||||
|
},
|
||||||
|
complained: {
|
||||||
|
title: t('Complained'),
|
||||||
|
link: params => `/campaigns/${params.campaignId}/statistics/complained`,
|
||||||
|
panelRender: props => <StatisticsSubsList entity={props.resolved.campaign} title={t('Subscribers that Complained')} status={SubscriptionStatus.COMPLAINED} />
|
||||||
|
},
|
||||||
|
bounced: {
|
||||||
|
title: t('Bounced'),
|
||||||
|
link: params => `/campaigns/${params.campaignId}/statistics/bounced`,
|
||||||
|
panelRender: props => <StatisticsSubsList entity={props.resolved.campaign} title={t('Emails that Bounced')} status={SubscriptionStatus.BOUNCED} />
|
||||||
|
},
|
||||||
|
unsubscribed: {
|
||||||
|
title: t('Unsubscribed'),
|
||||||
|
link: params => `/campaigns/${params.campaignId}/statistics/unsubscribed`,
|
||||||
|
panelRender: props => <StatisticsSubsList entity={props.resolved.campaign} title={t('Subscribers that Unsubscribed')} status={SubscriptionStatus.UNSUBSCRIBED} />
|
||||||
|
},
|
||||||
|
'opened': {
|
||||||
|
title: t('Opened'),
|
||||||
|
resolve: {
|
||||||
|
statisticsOpened: params => `rest/campaign-statistics/${params.campaignId}/opened`
|
||||||
|
},
|
||||||
|
link: params => `/campaigns/${params.campaignId}/statistics/opened/countries`,
|
||||||
|
children: {
|
||||||
|
':agg(countries|devices)': {
|
||||||
|
title: (resolved, params) => aggLabels[params.agg],
|
||||||
|
link: params => `/campaigns/${params.campaignId}/statistics/opened/${params.agg}`,
|
||||||
|
panelRender: props => <StatisticsOpened entity={props.resolved.campaign} statisticsOpened={props.resolved.statisticsOpened} agg={props.match.params.agg} />
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'clicks': {
|
||||||
|
title: t('Clicks'),
|
||||||
|
link: params => `/campaigns/${params.campaignId}/statistics/clicks`,
|
||||||
|
panelRender: props => <StatisticsLinkClicks entity={props.resolved.campaign} />
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
':action(edit|delete)': {
|
':action(edit|delete)': {
|
||||||
title: t('edit'),
|
title: t('edit'),
|
||||||
|
|
|
@ -40,3 +40,54 @@
|
||||||
.sendButtonRow {
|
.sendButtonRow {
|
||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.statsMetrics {
|
||||||
|
width: 10ex;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statsProgressBar {
|
||||||
|
margin-right: 30px;
|
||||||
|
margin-bottom: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statsProgressBarZoomIn {
|
||||||
|
float: right;
|
||||||
|
width: 30px;
|
||||||
|
text-align: right;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zoomIn {
|
||||||
|
padding-left: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navPills {
|
||||||
|
margin-top: -3px;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
float: right;
|
||||||
|
|
||||||
|
& > li {
|
||||||
|
display: inline-block;
|
||||||
|
float: none;
|
||||||
|
|
||||||
|
& > a {
|
||||||
|
padding: 3px 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.charts {
|
||||||
|
margin-bottom: 30px;
|
||||||
|
|
||||||
|
.chart {
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionTitle {
|
||||||
|
font-weight: bold;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -288,10 +288,12 @@ class InputField extends Component {
|
||||||
let type = 'text';
|
let type = 'text';
|
||||||
if (props.type === 'password') {
|
if (props.type === 'password') {
|
||||||
type = 'password';
|
type = 'password';
|
||||||
|
} else if (props.type === 'hidden') {
|
||||||
|
type = 'hidden';
|
||||||
}
|
}
|
||||||
|
|
||||||
return wrapInput(id, htmlId, owner, props.format, '', props.label, props.help,
|
return wrapInput(id, htmlId, owner, props.format, '', props.label, props.help,
|
||||||
<input type={type} value={owner.getFormValue(id) || ''} placeholder={props.placeholder} id={htmlId} className="form-control" aria-describedby={htmlId + '_help'} onChange={evt => owner.updateFormValue(id, evt.target.value)}/>
|
<input type={type} value={owner.getFormValue(id)} placeholder={props.placeholder} id={htmlId} className="form-control" aria-describedby={htmlId + '_help'} onChange={evt => owner.updateFormValue(id, evt.target.value)}/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -736,12 +738,15 @@ class TableSelect extends Component {
|
||||||
label: PropTypes.string,
|
label: PropTypes.string,
|
||||||
help: PropTypes.oneOfType([PropTypes.string, PropTypes.object]),
|
help: PropTypes.oneOfType([PropTypes.string, PropTypes.object]),
|
||||||
format: PropTypes.string,
|
format: PropTypes.string,
|
||||||
disabled: PropTypes.bool
|
disabled: PropTypes.bool,
|
||||||
|
|
||||||
|
pageLength: PropTypes.number
|
||||||
}
|
}
|
||||||
|
|
||||||
static defaultProps = {
|
static defaultProps = {
|
||||||
selectMode: TableSelectMode.SINGLE,
|
selectMode: TableSelectMode.SINGLE,
|
||||||
selectionLabelIndex: 0
|
selectionLabelIndex: 0,
|
||||||
|
pageLength: 10
|
||||||
}
|
}
|
||||||
|
|
||||||
static contextTypes = {
|
static contextTypes = {
|
||||||
|
@ -814,7 +819,7 @@ class TableSelect extends Component {
|
||||||
return wrapInput(id, htmlId, owner, props.format, '', props.label, props.help,
|
return wrapInput(id, htmlId, owner, props.format, '', props.label, props.help,
|
||||||
<div>
|
<div>
|
||||||
<div>
|
<div>
|
||||||
<Table ref={node => this.table = node} data={props.data} dataUrl={props.dataUrl} columns={props.columns} selectMode={props.selectMode} selectionAsArray={this.props.selectionAsArray} withHeader={props.withHeader} selectionKeyIndex={props.selectionKeyIndex} selection={owner.getFormValue(id)} onSelectionChangedAsync={::this.onSelectionChangedAsync}/>
|
<Table ref={node => this.table = node} data={props.data} dataUrl={props.dataUrl} columns={props.columns} pageLength={props.pageLength} selectMode={props.selectMode} selectionAsArray={this.props.selectionAsArray} withHeader={props.withHeader} selectionKeyIndex={props.selectionKeyIndex} selection={owner.getFormValue(id)} onSelectionChangedAsync={::this.onSelectionChangedAsync}/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
@ -28,7 +28,7 @@ class Breadcrumb extends Component {
|
||||||
const params = this.props.params;
|
const params = this.props.params;
|
||||||
let title;
|
let title;
|
||||||
if (typeof entry.title === 'function') {
|
if (typeof entry.title === 'function') {
|
||||||
title = entry.title(this.props.resolved);
|
title = entry.title(this.props.resolved, params);
|
||||||
} else {
|
} else {
|
||||||
title = entry.title;
|
title = entry.title;
|
||||||
}
|
}
|
||||||
|
|
|
@ -48,12 +48,14 @@ class Table extends Component {
|
||||||
onSelectionChangedAsync: PropTypes.func,
|
onSelectionChangedAsync: PropTypes.func,
|
||||||
onSelectionDataAsync: PropTypes.func,
|
onSelectionDataAsync: PropTypes.func,
|
||||||
withHeader: PropTypes.bool,
|
withHeader: PropTypes.bool,
|
||||||
refreshInterval: PropTypes.number
|
refreshInterval: PropTypes.number,
|
||||||
|
pageLength: PropTypes.number
|
||||||
}
|
}
|
||||||
|
|
||||||
static defaultProps = {
|
static defaultProps = {
|
||||||
selectMode: TableSelectMode.NONE,
|
selectMode: TableSelectMode.NONE,
|
||||||
selectionKeyIndex: 0
|
selectionKeyIndex: 0,
|
||||||
|
pageLength: 50
|
||||||
}
|
}
|
||||||
|
|
||||||
refresh() {
|
refresh() {
|
||||||
|
@ -262,7 +264,8 @@ class Table extends Component {
|
||||||
}
|
}
|
||||||
|
|
||||||
const dtOptions = {
|
const dtOptions = {
|
||||||
columns
|
columns,
|
||||||
|
pageLength: this.props.pageLength
|
||||||
};
|
};
|
||||||
|
|
||||||
const self = this;
|
const self = this;
|
||||||
|
|
|
@ -79,6 +79,7 @@ export default class Update extends Component {
|
||||||
<InputField id="defaultHomepage" label={t('defaultHomepageUrl')} help={t('thisUrlWillBeUsedInListSubscriptionForms')}/>
|
<InputField id="defaultHomepage" label={t('defaultHomepageUrl')} help={t('thisUrlWillBeUsedInListSubscriptionForms')}/>
|
||||||
|
|
||||||
<InputField id="uaCode" label={t('trackingId')} placeholder={t('uaxxxxxxx')} help={t('enterGoogleAnalyticsTrackingCode')}/>
|
<InputField id="uaCode" label={t('trackingId')} placeholder={t('uaxxxxxxx')} help={t('enterGoogleAnalyticsTrackingCode')}/>
|
||||||
|
<InputField id="mapsApiKey" label={t('Google Maps API Key')} placeholder={t('XXXXXX')} help={t('The map overview in campaign statistics requires a Google Maps API key. Please enter it here. If no key is given, Google may throttle map requests, which will result in occassional unavailability of the map in the campaign statistics.')}/>
|
||||||
|
|
||||||
<TextArea id="shoutout" label={t('frontpageShoutOut')} help={t('htmlCodeShownInTheFrontPageHeaderSection')}/>
|
<TextArea id="shoutout" label={t('frontpageShoutOut')} help={t('htmlCodeShownInTheFrontPageHeaderSection')}/>
|
||||||
|
|
||||||
|
|
|
@ -5,6 +5,8 @@ const config = require('config');
|
||||||
const forms = require('../models/forms');
|
const forms = require('../models/forms');
|
||||||
const shares = require('../models/shares');
|
const shares = require('../models/shares');
|
||||||
const urls = require('./urls');
|
const urls = require('./urls');
|
||||||
|
const settings = require('../models/settings');
|
||||||
|
const contextHelpers = require('./context-helpers');
|
||||||
|
|
||||||
|
|
||||||
async function getAnonymousConfig(context, appType) {
|
async function getAnonymousConfig(context, appType) {
|
||||||
|
@ -31,6 +33,8 @@ async function getAuthenticatedConfig(context) {
|
||||||
globalPermissions[perm] = true;
|
globalPermissions[perm] = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const setts = await settings.get(contextHelpers.getAdminContext(), ['mapsApiKey']);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
defaultCustomFormValues: await forms.getDefaultCustomFormValues(),
|
defaultCustomFormValues: await forms.getDefaultCustomFormValues(),
|
||||||
user: {
|
user: {
|
||||||
|
@ -42,7 +46,8 @@ async function getAuthenticatedConfig(context) {
|
||||||
editors: config.editors,
|
editors: config.editors,
|
||||||
mosaico: config.mosaico,
|
mosaico: config.mosaico,
|
||||||
verpEnabled: config.verp.enabled,
|
verpEnabled: config.verp.enabled,
|
||||||
reportsEnabled: config.reports.enabled
|
reportsEnabled: config.reports.enabled,
|
||||||
|
mapsApiKey: setts.mapsApiKey
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -10,13 +10,14 @@ const shares = require('./shares');
|
||||||
const namespaceHelpers = require('../lib/namespace-helpers');
|
const namespaceHelpers = require('../lib/namespace-helpers');
|
||||||
const files = require('./files');
|
const files = require('./files');
|
||||||
const templates = require('./templates');
|
const templates = require('./templates');
|
||||||
const { CampaignStatus, CampaignSource, CampaignType, getSendConfigurationPermissionRequiredForSend} = require('../../shared/campaigns');
|
const { CampaignStatus, CampaignSource, CampaignType, getSendConfigurationPermissionRequiredForSend } = require('../../shared/campaigns');
|
||||||
const sendConfigurations = require('./send-configurations');
|
const sendConfigurations = require('./send-configurations');
|
||||||
const triggers = require('./triggers');
|
const triggers = require('./triggers');
|
||||||
const {SubscriptionStatus} = require('../../shared/lists');
|
const {SubscriptionStatus} = require('../../shared/lists');
|
||||||
const subscriptions = require('./subscriptions');
|
const subscriptions = require('./subscriptions');
|
||||||
const segments = require('./segments');
|
const segments = require('./segments');
|
||||||
const senders = require('../lib/senders');
|
const senders = require('../lib/senders');
|
||||||
|
const {LinkId} = require('./links');
|
||||||
|
|
||||||
const allowedKeysCommon = ['name', 'description', 'segment', 'namespace',
|
const allowedKeysCommon = ['name', 'description', 'segment', 'namespace',
|
||||||
'send_configuration', 'from_name_override', 'from_email_override', 'reply_to_override', 'subject_override', 'data', 'click_tracking_disabled', 'open_tracking_disabled', 'unsubscribe_url'];
|
'send_configuration', 'from_name_override', 'from_email_override', 'reply_to_override', 'subject_override', 'data', 'click_tracking_disabled', 'open_tracking_disabled', 'unsubscribe_url'];
|
||||||
|
@ -158,7 +159,8 @@ async function listTestUsersDTAjax(context, campaignId, params) {
|
||||||
subsQry = knex.raw(subsUnionSql, subsUnionBindings);
|
subsQry = knex.raw(subsUnionSql, subsUnionBindings);
|
||||||
}
|
}
|
||||||
|
|
||||||
return await dtHelpers.ajaxListWithPermissions(
|
return await dtHelpers.ajaxListWithPermissionsTx(
|
||||||
|
tx,
|
||||||
context,
|
context,
|
||||||
[{ entityTypeId: 'list', requiredOperations: ['viewSubscriptions'], column: 'subs.list_id' }],
|
[{ entityTypeId: 'list', requiredOperations: ['viewSubscriptions'], column: 'subs.list_id' }],
|
||||||
params,
|
params,
|
||||||
|
@ -191,6 +193,115 @@ async function listTestUsersDTAjax(context, campaignId, params) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function _listSubscriberResultsDTAjax(context, campaignId, getSubsQrys, columns, params) {
|
||||||
|
return await knex.transaction(async tx => {
|
||||||
|
await shares.enforceEntityPermissionTx(tx, context, 'campaign', campaignId, 'view');
|
||||||
|
|
||||||
|
const subsQrys = [];
|
||||||
|
const cpgLists = await tx('campaign_lists').where('campaign', campaignId);
|
||||||
|
|
||||||
|
for (const cpgList of cpgLists) {
|
||||||
|
const subsTable = subscriptions.getSubscriptionTableName(cpgList.list);
|
||||||
|
subsQrys.push(getSubsQrys(subsTable, cpgList));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subsQrys.length > 0) {
|
||||||
|
let subsSql, subsBindings;
|
||||||
|
|
||||||
|
if (subsQrys.length === 1) {
|
||||||
|
subsSql = '(' + subsQrys[0].sql + ') as `subs`'
|
||||||
|
subsBindings = subsQrys[0].bindings;
|
||||||
|
|
||||||
|
} else {
|
||||||
|
subsSql = '(' +
|
||||||
|
subsQrys.map(qry => '(' + qry.sql + ')').join(' UNION ALL ') +
|
||||||
|
') as `subs`';
|
||||||
|
subsBindings = Array.prototype.concat(...subsQrys.map(qry => qry.bindings));
|
||||||
|
}
|
||||||
|
|
||||||
|
return await dtHelpers.ajaxListWithPermissionsTx(
|
||||||
|
tx,
|
||||||
|
context,
|
||||||
|
[{ entityTypeId: 'list', requiredOperations: ['viewSubscriptions'], column: 'lists.id' }],
|
||||||
|
params,
|
||||||
|
(builder, tx) => builder.from(knex.raw(subsSql, subsBindings))
|
||||||
|
.innerJoin('lists', 'subs.list', 'lists.id')
|
||||||
|
.innerJoin('namespaces', 'lists.namespace', 'namespaces.id')
|
||||||
|
,
|
||||||
|
columns
|
||||||
|
);
|
||||||
|
|
||||||
|
} else {
|
||||||
|
const result = {
|
||||||
|
draw: params.draw,
|
||||||
|
recordsTotal: 0,
|
||||||
|
recordsFiltered: 0,
|
||||||
|
data: []
|
||||||
|
};
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async function listSentByStatusDTAjax(context, campaignId, status, params) {
|
||||||
|
return await _listSubscriberResultsDTAjax(
|
||||||
|
context,
|
||||||
|
campaignId,
|
||||||
|
(subsTable, cpgList) => knex.from(subsTable)
|
||||||
|
.innerJoin(
|
||||||
|
function () {
|
||||||
|
return this.from('campaign_messages')
|
||||||
|
.where('campaign_messages.campaign', campaignId)
|
||||||
|
.where('campaign_messages.list', cpgList.list)
|
||||||
|
.where('campaign_messages.status', status)
|
||||||
|
.as('related_campaign_messages');
|
||||||
|
},
|
||||||
|
'related_campaign_messages.subscription', subsTable + '.id')
|
||||||
|
.select([subsTable + '.email', subsTable + '.cid', knex.raw('? AS list', [cpgList.list])])
|
||||||
|
.toSQL().toNative(),
|
||||||
|
[ 'subs.email', 'subs.cid', 'lists.cid', 'lists.name', 'namespaces.name' ],
|
||||||
|
params
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listOpensDTAjax(context, campaignId, params) {
|
||||||
|
return await _listSubscriberResultsDTAjax(
|
||||||
|
context,
|
||||||
|
campaignId,
|
||||||
|
(subsTable, cpgList) => knex.from(subsTable)
|
||||||
|
.innerJoin(
|
||||||
|
function () {
|
||||||
|
return this.from('campaign_links')
|
||||||
|
.where('campaign_links.campaign', campaignId)
|
||||||
|
.where('campaign_links.list', cpgList.list)
|
||||||
|
.where('campaign_links.link', LinkId.OPEN)
|
||||||
|
.as('related_campaign_links');
|
||||||
|
},
|
||||||
|
'related_campaign_links.subscription', subsTable + '.id')
|
||||||
|
.select([subsTable + '.email', subsTable + '.cid', knex.raw('? AS list', [cpgList.list]), 'related_campaign_links.count'])
|
||||||
|
.toSQL().toNative(),
|
||||||
|
[ 'subs.email', 'subs.cid', 'lists.cid', 'lists.name', 'namespaces.name', 'subs.count' ],
|
||||||
|
params
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listLinkClicksDTAjax(context, campaignId, params) {
|
||||||
|
return await knex.transaction(async (tx) => {
|
||||||
|
await shares.enforceEntityPermissionTx(tx, context, 'campaign', campaignId, 'viewStats');
|
||||||
|
|
||||||
|
return await dtHelpers.ajaxListTx(
|
||||||
|
tx,
|
||||||
|
params,
|
||||||
|
builder => builder.from('links')
|
||||||
|
.where('links.campaign', campaignId),
|
||||||
|
[ 'links.url', 'links.visits', 'links.hits' ]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
async function getTrackingSettingsByCidTx(tx, cid) {
|
async function getTrackingSettingsByCidTx(tx, cid) {
|
||||||
const entity = await tx('campaigns').where('campaigns.cid', cid)
|
const entity = await tx('campaigns').where('campaigns.cid', cid)
|
||||||
.select([
|
.select([
|
||||||
|
@ -511,6 +622,8 @@ async function _removeTx(tx, context, id, existing = null) {
|
||||||
await tx('campaign_messages').where('campaign', id).del();
|
await tx('campaign_messages').where('campaign', id).del();
|
||||||
await tx('campaign_links').where('campaign', id).del();
|
await tx('campaign_links').where('campaign', id).del();
|
||||||
|
|
||||||
|
await tx('links').where('campaign', id).del();
|
||||||
|
|
||||||
await triggers.removeAllByCampaignIdTx(tx, context, id);
|
await triggers.removeAllByCampaignIdTx(tx, context, id);
|
||||||
|
|
||||||
await tx('template_dep_campaigns')
|
await tx('template_dep_campaigns')
|
||||||
|
@ -779,11 +892,19 @@ async function reset(context, campaignId) {
|
||||||
}
|
}
|
||||||
|
|
||||||
await tx('campaigns').where('id', campaignId).update({
|
await tx('campaigns').where('id', campaignId).update({
|
||||||
status: CampaignStatus.IDLE
|
status: CampaignStatus.IDLE,
|
||||||
|
delivered: 0,
|
||||||
|
unsubscribed: 0,
|
||||||
|
bounced: 0,
|
||||||
|
complained: 0,
|
||||||
|
blacklisted: 0,
|
||||||
|
opened: 0,
|
||||||
|
clicks: 0
|
||||||
});
|
});
|
||||||
|
|
||||||
await tx('campaign_messages').where('campaign', campaignId).del();
|
await tx('campaign_messages').where('campaign', campaignId).del();
|
||||||
await tx('campaign_links').where('campaign', campaignId).del();
|
await tx('campaign_links').where('campaign', campaignId).del();
|
||||||
|
await tx('links').where('campaign', campaignId).del();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -796,14 +917,51 @@ async function disable(context, campaignId) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async function getStatisticsOverview(context, id) {
|
||||||
|
return await knex.transaction(async tx => {
|
||||||
|
await shares.enforceEntityPermissionTx(tx, context, 'campaign', id, 'viewStats');
|
||||||
|
|
||||||
|
const stats = await tx('campaigns').where('id', id).select(['delivered', 'unsubscribed', 'bounced', 'complained', 'blacklisted', 'opened', 'clicks']).first();
|
||||||
|
|
||||||
|
const totalQryGen = await getSubscribersQueryGeneratorTx(tx, id, false);
|
||||||
|
if (totalQryGen) {
|
||||||
|
const res = await totalQryGen(tx).count('* AS subscriptionsTotal').first();
|
||||||
|
stats.total = res.subscriptionsTotal;
|
||||||
|
} else {
|
||||||
|
stats.total = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return stats;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getStatisticsOpened(context, id) {
|
||||||
|
return await knex.transaction(async tx => {
|
||||||
|
await shares.enforceEntityPermissionTx(tx, context, 'campaign', id, 'viewStats');
|
||||||
|
|
||||||
|
const devices = await tx('campaign_links').where('campaign', id).groupBy('device_type').select('device_type AS key').count('* as count');
|
||||||
|
const countries = await tx('campaign_links').where('campaign', id).groupBy('country').select('country AS key').count('* as count');
|
||||||
|
|
||||||
|
return {
|
||||||
|
devices,
|
||||||
|
countries
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
module.exports.Content = Content;
|
module.exports.Content = Content;
|
||||||
module.exports.hash = hash;
|
module.exports.hash = hash;
|
||||||
|
|
||||||
module.exports.listDTAjax = listDTAjax;
|
module.exports.listDTAjax = listDTAjax;
|
||||||
module.exports.listChildrenDTAjax = listChildrenDTAjax;
|
module.exports.listChildrenDTAjax = listChildrenDTAjax;
|
||||||
module.exports.listWithContentDTAjax = listWithContentDTAjax;
|
module.exports.listWithContentDTAjax = listWithContentDTAjax;
|
||||||
module.exports.listOthersWhoseListsAreIncludedDTAjax = listOthersWhoseListsAreIncludedDTAjax;
|
module.exports.listOthersWhoseListsAreIncludedDTAjax = listOthersWhoseListsAreIncludedDTAjax;
|
||||||
module.exports.listTestUsersDTAjax = listTestUsersDTAjax;
|
module.exports.listTestUsersDTAjax = listTestUsersDTAjax;
|
||||||
|
module.exports.listSentByStatusDTAjax = listSentByStatusDTAjax;
|
||||||
|
module.exports.listOpensDTAjax = listOpensDTAjax;
|
||||||
|
module.exports.listLinkClicksDTAjax = listLinkClicksDTAjax;
|
||||||
|
|
||||||
|
|
||||||
module.exports.getByIdTx = getByIdTx;
|
module.exports.getByIdTx = getByIdTx;
|
||||||
module.exports.getById = getById;
|
module.exports.getById = getById;
|
||||||
module.exports.create = create;
|
module.exports.create = create;
|
||||||
|
@ -830,3 +988,5 @@ module.exports.disable = disable;
|
||||||
|
|
||||||
module.exports.rawGetByTx = rawGetByTx;
|
module.exports.rawGetByTx = rawGetByTx;
|
||||||
module.exports.getTrackingSettingsByCidTx = getTrackingSettingsByCidTx;
|
module.exports.getTrackingSettingsByCidTx = getTrackingSettingsByCidTx;
|
||||||
|
module.exports.getStatisticsOverview = getStatisticsOverview;
|
||||||
|
module.exports.getStatisticsOpened = getStatisticsOpened;
|
||||||
|
|
|
@ -14,6 +14,7 @@ const he = require('he');
|
||||||
const { enforce } = require('../lib/helpers');
|
const { enforce } = require('../lib/helpers');
|
||||||
const { getPublicUrl } = require('../lib/urls');
|
const { getPublicUrl } = require('../lib/urls');
|
||||||
const tools = require('../lib/tools');
|
const tools = require('../lib/tools');
|
||||||
|
const shortid = require('shortid');
|
||||||
|
|
||||||
const LinkId = {
|
const LinkId = {
|
||||||
OPEN: -1,
|
OPEN: -1,
|
||||||
|
@ -41,7 +42,7 @@ async function countLink(remoteIp, userAgent, campaignCid, listCid, subscription
|
||||||
campaign: campaign.id,
|
campaign: campaign.id,
|
||||||
list: list.id,
|
list: list.id,
|
||||||
subscription: subscription.id,
|
subscription: subscription.id,
|
||||||
link: linkId,
|
link: clickLinkId,
|
||||||
ip: remoteIp,
|
ip: remoteIp,
|
||||||
device_type: device.type,
|
device_type: device.type,
|
||||||
country
|
country
|
||||||
|
@ -49,7 +50,7 @@ async function countLink(remoteIp, userAgent, campaignCid, listCid, subscription
|
||||||
|
|
||||||
const campaignLinksQryResult = await tx.raw(campaignLinksQry.sql + (incrementOnDup ? ' ON DUPLICATE KEY UPDATE `count`=`count`+1' : ''), campaignLinksQry.bindings);
|
const campaignLinksQryResult = await tx.raw(campaignLinksQry.sql + (incrementOnDup ? ' ON DUPLICATE KEY UPDATE `count`=`count`+1' : ''), campaignLinksQry.bindings);
|
||||||
|
|
||||||
if (campaignLinksQryResult.affectedRows > 1) { // When using DUPLICATE KEY UPDATE, this means that the entry was already there
|
if (campaignLinksQryResult[0].affectedRows > 1) { // When using DUPLICATE KEY UPDATE, this means that the entry was already there
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -84,7 +85,10 @@ async function countLink(remoteIp, userAgent, campaignCid, listCid, subscription
|
||||||
|
|
||||||
// Update clicks
|
// Update clicks
|
||||||
if (linkId > LinkId.GENERAL_CLICK && !campaign.click_tracking_disabled) {
|
if (linkId > LinkId.GENERAL_CLICK && !campaign.click_tracking_disabled) {
|
||||||
|
await tx('links').increment('hits').where('id', linkId);
|
||||||
if (await _countLink(linkId, true)) {
|
if (await _countLink(linkId, true)) {
|
||||||
|
await tx('links').increment('visits').where('id', linkId);
|
||||||
|
|
||||||
if (await _countLink(LinkId.GENERAL_CLICK, false)) {
|
if (await _countLink(LinkId.GENERAL_CLICK, false)) {
|
||||||
await tx('campaigns').increment('clicks').where('id', campaign.id);
|
await tx('campaigns').increment('clicks').where('id', campaign.id);
|
||||||
}
|
}
|
||||||
|
@ -103,7 +107,7 @@ async function countLink(remoteIp, userAgent, campaignCid, listCid, subscription
|
||||||
|
|
||||||
async function addOrGet(campaignId, url) {
|
async function addOrGet(campaignId, url) {
|
||||||
return await knex.transaction(async tx => {
|
return await knex.transaction(async tx => {
|
||||||
const link = tx('links').select(['id', 'cid']).where({
|
const link = await tx('links').select(['id', 'cid']).where({
|
||||||
campaign: campaignId,
|
campaign: campaignId,
|
||||||
url
|
url
|
||||||
}).first();
|
}).first();
|
||||||
|
@ -111,7 +115,7 @@ async function addOrGet(campaignId, url) {
|
||||||
if (!link) {
|
if (!link) {
|
||||||
let cid = shortid.generate();
|
let cid = shortid.generate();
|
||||||
|
|
||||||
const ids = tx('links').insert({
|
const ids = await tx('links').insert({
|
||||||
campaign: campaignId,
|
campaign: campaignId,
|
||||||
cid,
|
cid,
|
||||||
url
|
url
|
||||||
|
|
|
@ -5,7 +5,7 @@ const { filterObject } = require('../lib/helpers');
|
||||||
const hasher = require('node-object-hash')();
|
const hasher = require('node-object-hash')();
|
||||||
const shares = require('./shares');
|
const shares = require('./shares');
|
||||||
|
|
||||||
const allowedKeys = new Set(['adminEmail', 'uaCode', 'shoutout', 'pgpPassphrase', 'pgpPrivateKey', 'defaultHomepage']);
|
const allowedKeys = new Set(['adminEmail', 'uaCode', 'mapsApiKey', 'shoutout', 'pgpPassphrase', 'pgpPrivateKey', 'defaultHomepage']);
|
||||||
// defaultHomepage is used as a default to list.homepage - if the list.homepage is not filled in
|
// defaultHomepage is used as a default to list.homepage - if the list.homepage is not filled in
|
||||||
|
|
||||||
function hash(entity) {
|
function hash(entity) {
|
||||||
|
|
|
@ -8,6 +8,20 @@ const interoperableErrors = require('../../shared/interoperable-errors');
|
||||||
|
|
||||||
const trackImg = Buffer.from('R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7', 'base64');
|
const trackImg = Buffer.from('R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7', 'base64');
|
||||||
|
|
||||||
|
router.getAsync('/:campaign/:list/:subscription/:link', async (req, res) => {
|
||||||
|
const link = await links.resolve(req.params.link);
|
||||||
|
|
||||||
|
if (link) {
|
||||||
|
// In Mailtrain v1 we would do the URL expansion here based on merge tags. We don't do it here anymore. Instead, the URLs are expanded when message is sent out (in links.updateLinks)
|
||||||
|
res.redirect(link.url);
|
||||||
|
|
||||||
|
await links.countLink(req.ip, req.headers['user-agent'], req.params.campaign, req.params.list, req.params.subscription, link.id);
|
||||||
|
} else {
|
||||||
|
log.error('Redirect', 'Unresolved URL: <%s>', req.url);
|
||||||
|
throw new interoperableErrors.NotFoundError('Oops, we couldn\'t find a link for the URL you clicked');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
router.getAsync('/:campaign/:list/:subscription', async (req, res) => {
|
router.getAsync('/:campaign/:list/:subscription', async (req, res) => {
|
||||||
res.writeHead(200, {
|
res.writeHead(200, {
|
||||||
'Content-Type': 'image/gif',
|
'Content-Type': 'image/gif',
|
||||||
|
@ -20,18 +34,4 @@ router.getAsync('/:campaign/:list/:subscription', async (req, res) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
router.getAsync('/:campaign/:list/:subscription/:link', async (req, res) => {
|
|
||||||
const link = await links.resolve(req.params.link);
|
|
||||||
|
|
||||||
if (link) {
|
|
||||||
// In Mailtrain v1 we would do the URL expansion here based on merge tags. We don't do it here anymore. Instead, the URLs are expanded when message is sent out (in links.updateLinks)
|
|
||||||
res.redirect(url);
|
|
||||||
|
|
||||||
await links.countLink(req.ip, req.headers['user-agent'], req.params.campaign, req.params.list, req.params.subscription, link.id);
|
|
||||||
} else {
|
|
||||||
log.error('Redirect', 'Unresolved URL: <%s>', req.url);
|
|
||||||
throw new interoperableErrors.NotFoundError('Oops, we couldn\'t find a link for the URL you clicked');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|
|
@ -94,5 +94,26 @@ router.postAsync('/campaign-disable/:campaignId', passport.loggedIn, passport.cs
|
||||||
return res.json(await campaigns.disable(req.context, castToInteger(req.params.campaignId), null));
|
return res.json(await campaigns.disable(req.context, castToInteger(req.params.campaignId), null));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.getAsync('/campaign-statistics/:campaignId/overview', passport.loggedIn, async (req, res) => {
|
||||||
|
return res.json(await campaigns.getStatisticsOverview(req.context, castToInteger(req.params.campaignId)));
|
||||||
|
});
|
||||||
|
|
||||||
|
router.getAsync('/campaign-statistics/:campaignId/opened', passport.loggedIn, async (req, res) => {
|
||||||
|
return res.json(await campaigns.getStatisticsOpened(req.context, castToInteger(req.params.campaignId)));
|
||||||
|
});
|
||||||
|
|
||||||
|
router.postAsync('/campaigns-subscribers-by-status-table/:campaignId/:status', passport.loggedIn, async (req, res) => {
|
||||||
|
return res.json(await campaigns.listSentByStatusDTAjax(req.context, castToInteger(req.params.campaignId), castToInteger(req.params.status), req.body));
|
||||||
|
});
|
||||||
|
|
||||||
|
router.postAsync('/campaigns-opens-table/:campaignId', passport.loggedIn, async (req, res) => {
|
||||||
|
return res.json(await campaigns.listOpensDTAjax(req.context, castToInteger(req.params.campaignId), req.body));
|
||||||
|
});
|
||||||
|
|
||||||
|
router.postAsync('/campaigns-link-clicks-table/:campaignId', passport.loggedIn, async (req, res) => {
|
||||||
|
return res.json(await campaigns.listLinkClicksDTAjax(req.context, castToInteger(req.params.campaignId), req.body));
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
|
@ -1102,6 +1102,11 @@ async function migrateCampaigns(knex) {
|
||||||
table.integer('send_configuration').unsigned().notNullable().alter();
|
table.integer('send_configuration').unsigned().notNullable().alter();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await knex.schema.table('links', table => {
|
||||||
|
table.dropColumn('clicks');
|
||||||
|
table.integer('visits').unsigned().notNullable().defaultTo(0);
|
||||||
|
table.integer('hits').unsigned().notNullable().defaultTo(0);
|
||||||
|
});
|
||||||
|
|
||||||
await knex.schema.dropTableIfExists('campaign');
|
await knex.schema.dropTableIfExists('campaign');
|
||||||
await knex.schema.dropTableIfExists('campaign_tracker');
|
await knex.schema.dropTableIfExists('campaign_tracker');
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue