Panels with campaign statistics and some fixes in computation of clicks.

This commit is contained in:
Tomas Bures 2018-12-16 13:47:08 +01:00
parent ba996d845d
commit d103a2cc79
18 changed files with 811 additions and 96 deletions

View file

@ -16,8 +16,12 @@ import {
import axios
from "../lib/axios";
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()
@withPageHelpers
@ -30,7 +34,8 @@ export default class Statistics extends Component {
const t = props.t;
this.state = {
entity: props.entity
entity: props.entity,
statisticsOverview: props.statisticsOverview
};
this.refreshTimeoutHandler = ::this.periodicRefreshTask;
@ -38,7 +43,8 @@ export default class Statistics extends Component {
}
static propTypes = {
entity: PropTypes.object
entity: PropTypes.object,
statisticsOverview: PropTypes.object
}
@withAsyncErrorHandler
@ -48,8 +54,12 @@ export default class Statistics extends Component {
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}/overview`));
const statisticsOverview = resp.data;
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.
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, 10000);
this.refreshTimeoutId = setTimeout(this.refreshTimeoutHandler, 60000);
}
}
@ -76,49 +86,56 @@ export default class Statistics extends Component {
const t = this.props.t;
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}&nbsp;({rate}%)
</div>
</div>
</AlignedRow>
);
}
return (
<div>
<Title>{t('campaignStatistics')}</Title>
<Chart
width="100%"
height="500px"
chartType="PieChart"
loader={<div>Loading Chart</div>}
data={[
['Task', 'Hours per Day'],
['Work', 11],
['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>
{renderMetrics('total', t('Total'), false)}
{renderMetrics('delivered', t('Delivered'))}
{renderMetrics('blacklisted', t('Blacklisted'), false)}
{renderMetricsWithProgress('bounced', t('Bounced'), 'info')}
{renderMetricsWithProgress('complained', t('Complaints'), 'danger')}
{renderMetricsWithProgress('unsubscribed', t('Unsubscribed'), 'warning')}
{!entity.open_tracking_disabled && renderMetricsWithProgress('opened', t('Opened'), 'success')}
{!entity.click_tracking_disabled && renderMetricsWithProgress('clicks', t('Clicked'), 'success')}
</div>
);
}
}

View 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>
);
}
}

View 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>
);
}
}

View 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>
);
}
}

View file

@ -27,7 +27,8 @@ import {getCampaignLabels} from './helpers';
import {Table} from "../lib/table";
import {
Button,
Icon
Icon,
ModalDialog
} from "../lib/bootstrap-components";
import axios from "../lib/axios";
import {getUrl, getPublicUrl} from "../lib/urls";
@ -214,8 +215,15 @@ class SendControls extends Component {
}
async resetAsync() {
await this.postAndMaskStateError(`rest/campaign-reset/${this.props.entity.id}`);
await this.refreshEntity();
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.refreshEntity();
}
);
}
async enableAsync() {
@ -228,16 +236,47 @@ class SendControls extends Component {
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() {
const t = this.props.t;
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)) {
const subscrInfo = entity.subscriptionsTotal === undefined ? '' : ` (${entity.subscriptionsToSend} ${t('subscribers-1')})`;
return (
<div>
<div>{yesNoDialog}
<AlignedRow label={t('sendStatus')}>
{entity.scheduled ? t('campaignIsScheduledForDelivery') : t('campaignIsReadyToBeSentOut')}
</AlignedRow>
@ -265,7 +304,7 @@ class SendControls extends Component {
} else if (entity.status === CampaignStatus.SENDING || (entity.status === CampaignStatus.SCHEDULED && !entity.scheduled)) {
return (
<div>
<div>{yesNoDialog}
<AlignedRow label={t('sendStatus')}>
{t('campaignIsBeingSentOut')}
</AlignedRow>
@ -280,7 +319,7 @@ class SendControls extends Component {
const subscrInfo = entity.subscriptionsTotal === undefined ? '' : ` (${entity.subscriptionsToSend} ${t('subscribers-1')})`;
return (
<div>
<div>{yesNoDialog}
<AlignedRow label={t('sendStatus')}>
{t('allMessagesSent!HitContinueIfYouYouWant')}
</AlignedRow>
@ -294,7 +333,7 @@ class SendControls extends Component {
} else if (entity.status === CampaignStatus.INACTIVE) {
return (
<div>
<div>{yesNoDialog}
<AlignedRow label={t('sendStatus')}>
{t('yourCampaignIsCurrentlyDisabledClick')}
</AlignedRow>
@ -306,7 +345,7 @@ class SendControls extends Component {
} else if (entity.status === CampaignStatus.ACTIVE) {
return (
<div>
<div>{yesNoDialog}
<AlignedRow label={t('sendStatus')}>
{t('yourCampaignIsEnabledAndSendingMessages')}
</AlignedRow>

View file

@ -1,24 +1,46 @@
'use strict';
import React from 'react';
import React
from 'react';
import Status from './Status';
import Statistics from './Statistics';
import CampaignsCUD from './CUD';
import Content from './Content';
import CampaignsList from './List';
import Share from '../shares/Share';
import Files from "../lib/files";
import Status
from './Status';
import Statistics
from './Statistics';
import CampaignsCUD
from './CUD';
import Content
from './Content';
import CampaignsList
from './List';
import Share
from '../shares/Share';
import Files
from "../lib/files";
import {
CampaignSource,
CampaignStatus,
CampaignType
} from "../../../shared/campaigns";
import TriggersCUD from './triggers/CUD';
import TriggersList from './triggers/List';
import TriggersCUD
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) {
const aggLabels = {
'countries': t('Countries'),
'devices': t('Devices')
};
return {
'campaigns': {
title: t('campaigns'),
@ -30,7 +52,7 @@ function getMenus(t) {
resolve: {
campaign: params => `rest/campaigns-settings/${params.campaignId}`
},
link: params => `/campaigns/${params.campaignId}/edit`,
link: params => `/campaigns/${params.campaignId}/status`,
navs: {
status: {
title: t('status'),
@ -40,9 +62,53 @@ function getMenus(t) {
},
statistics: {
title: t('statistics'),
resolve: {
statisticsOverview: params => `rest/campaign-statistics/${params.campaignId}/overview`
},
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),
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)': {
title: t('edit'),

View file

@ -39,4 +39,55 @@
.sendButtonRow {
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;
}

View file

@ -288,10 +288,12 @@ class InputField extends Component {
let type = 'text';
if (props.type === 'password') {
type = 'password';
} else if (props.type === 'hidden') {
type = 'hidden';
}
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,
help: PropTypes.oneOfType([PropTypes.string, PropTypes.object]),
format: PropTypes.string,
disabled: PropTypes.bool
disabled: PropTypes.bool,
pageLength: PropTypes.number
}
static defaultProps = {
selectMode: TableSelectMode.SINGLE,
selectionLabelIndex: 0
selectionLabelIndex: 0,
pageLength: 10
}
static contextTypes = {
@ -814,7 +819,7 @@ class TableSelect extends Component {
return wrapInput(id, htmlId, owner, props.format, '', props.label, props.help,
<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>
);

View file

@ -28,7 +28,7 @@ class Breadcrumb extends Component {
const params = this.props.params;
let title;
if (typeof entry.title === 'function') {
title = entry.title(this.props.resolved);
title = entry.title(this.props.resolved, params);
} else {
title = entry.title;
}

View file

@ -48,12 +48,14 @@ class Table extends Component {
onSelectionChangedAsync: PropTypes.func,
onSelectionDataAsync: PropTypes.func,
withHeader: PropTypes.bool,
refreshInterval: PropTypes.number
refreshInterval: PropTypes.number,
pageLength: PropTypes.number
}
static defaultProps = {
selectMode: TableSelectMode.NONE,
selectionKeyIndex: 0
selectionKeyIndex: 0,
pageLength: 50
}
refresh() {
@ -262,7 +264,8 @@ class Table extends Component {
}
const dtOptions = {
columns
columns,
pageLength: this.props.pageLength
};
const self = this;

View file

@ -79,6 +79,7 @@ export default class Update extends Component {
<InputField id="defaultHomepage" label={t('defaultHomepageUrl')} help={t('thisUrlWillBeUsedInListSubscriptionForms')}/>
<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')}/>