Compare commits
274 Commits
Author | SHA1 | Date | |
---|---|---|---|
7fade4e4a0 | |||
3566cc022d | |||
9f73073874 | |||
15f9025b67 | |||
cea9927779 | |||
d4347d1fc5 | |||
af5d3ab1d9 | |||
c09410af94 | |||
6414cc832c | |||
29f32a52b8 | |||
a7d67ed09b | |||
bd0158fe86 | |||
db713f4db8 | |||
0629bb2878 | |||
a51280091a | |||
65dc4a8e32 | |||
8e640acaf1 | |||
aac40e2a24 | |||
a18dddedf3 | |||
957b317222 | |||
ecde4c10e3 | |||
adc052fc55 | |||
a446a13354 | |||
916547d29d | |||
530fc0acd3 | |||
d4d244fa0e | |||
45b907f3d9 | |||
13202ed191 | |||
34ce26c58a | |||
26c45e2b7b | |||
afd0e6ab34 | |||
c9e95c6c66 | |||
6d6cb98dd6 | |||
dd9474bdd6 | |||
b188bfc2b8 | |||
da2bfdb299 | |||
7887615711 | |||
59cff3ab5c | |||
2d2bfea188 | |||
b5b1c14137 | |||
7af66364f4 | |||
b615c37926 | |||
3a4d3067a7 | |||
7c1dd656d3 | |||
221e0ffc77 | |||
687743004e | |||
6b83a73c76 | |||
f61480a114 | |||
af12dc3fd3 | |||
6a50515e77 | |||
6e47d4ae53 | |||
c39def9071 | |||
330d1e2f34 | |||
57909b80a8 | |||
170ff27faa | |||
2a981c87dc | |||
5192b7898c | |||
6e5c69f35b | |||
01c7f42d74 | |||
f7bf01a1c5 | |||
0ec59e16a4 | |||
efc6e7fef7 | |||
021d8774e7 | |||
4c80949efd | |||
327f78b902 | |||
ee40743a96 | |||
5f84d5af1c | |||
c404188832 | |||
f0f24552f0 | |||
62ac4d5e27 | |||
99630f85aa | |||
f8e0ba118c | |||
3ec4505445 | |||
b15065f2a6 | |||
1096921da7 | |||
a5a7966663 | |||
ee882b99e9 | |||
d7e283dea9 | |||
f96a1d4892 | |||
117080b0da | |||
83703eadfa | |||
a7d260ac2e | |||
fe318f5a30 | |||
aece1b60a9 | |||
a1b51aecf0 | |||
f522c0786c | |||
f15d2f1cda | |||
725012ae56 | |||
aab3801aee | |||
ce9cbdc45d | |||
c3bb4d1433 | |||
3ab674d5fa | |||
161af43f4d | |||
edaefea5b7 | |||
d31483db2d | |||
d2d0afa9f5 | |||
1333b33d42 | |||
1053f062e3 | |||
947b788bb6 | |||
ed26cb8962 | |||
7d6e69e639 | |||
1e33574c76 | |||
27e346eed0 | |||
bf8e1e1b0c | |||
e2dc6855a8 | |||
20152569dd | |||
88e10be66d | |||
be1595862c | |||
3a5f2624c8 | |||
44d869af70 | |||
7b4e862014 | |||
f048a43026 | |||
7092b5713e | |||
1c56c3d87b | |||
0aaf0f335e | |||
5ead6a5b39 | |||
6d94cf6c16 | |||
1c370f6136 | |||
d4dc3bb301 | |||
963ecdb3b5 | |||
f81b75a0a8 | |||
7cec3233fc | |||
8bd03b7a10 | |||
ecf4d9cfdb | |||
635d4d33d5 | |||
f0fd089b9c | |||
8ab1df5ffa | |||
5eeee574b2 | |||
feb0297fb9 | |||
d04c84d6d4 | |||
64094387eb | |||
14a2603ac3 | |||
25a4515ebb | |||
0e6c885ad1 | |||
2d4967b64c | |||
2d3a8fadf7 | |||
9f9b2c6dab | |||
872bd80224 | |||
3607dd9d71 | |||
2d5e93ffb8 | |||
33e7e2d5f1 | |||
acd5846f70 | |||
ba728c5612 | |||
871d9fd9f8 | |||
725d81077f | |||
ec8b5260dd | |||
b4d0b35a1d | |||
5605259159 | |||
93c29512df | |||
74a5dde19f | |||
94d8d28253 | |||
7954f9d74f | |||
8454925e9a | |||
466782790a | |||
35b7e9177c | |||
f0a8f3bfd3 | |||
435152281a | |||
7f363a96f5 | |||
9684285105 | |||
5fd02b2bab | |||
5c4dcb77c5 | |||
aca358d276 | |||
092fcb78d0 | |||
85733f1540 | |||
941675bdde | |||
fea8db19bc | |||
4d5b4885fe | |||
8de46db560 | |||
ea5e4a37d2 | |||
edbb40ee42 | |||
3ded9be927 | |||
f27f469b17 | |||
ef7d19e49d | |||
7b461d904b | |||
630b60e62e | |||
f3bccaff7f | |||
a1574f82c9 | |||
278fbca955 | |||
105fbe1de5 | |||
2fabee6c3f | |||
5d5232da90 | |||
0f94288213 | |||
47f4858806 | |||
66e05a0063 | |||
0e3b7b20e7 | |||
1574c87e73 | |||
5f07143661 | |||
273a4729f6 | |||
8ec491a721 | |||
c2dc986420 | |||
abf535a7bb | |||
be669a5f61 | |||
1ede595dbb | |||
24aae85132 | |||
0a3068462f | |||
1c6b3975ed | |||
8753050eb5 | |||
fceb48014a | |||
78214d5389 | |||
533e19865c | |||
5bcdd8965e | |||
cd2bceae4b | |||
53a815601e | |||
431f53b19d | |||
d8cf0121bf | |||
77ff76f77f | |||
06e08d9265 | |||
79ad0cc15b | |||
4b6dd0dc13 | |||
fcdbb65091 | |||
4a02d5cfaf | |||
b07c041e35 | |||
89c3492bc4 | |||
7db7e78c71 | |||
0e4987f7ab | |||
7739d9df9b | |||
1198c52808 | |||
9a218a706b | |||
8a2c435b9c | |||
c0df921998 | |||
408f0e35dd | |||
fd2e0f8500 | |||
9afe3655b0 | |||
29e471a8d6 | |||
730944e2fc | |||
1f68c8d02c | |||
e05e62035e | |||
e95b8dcc49 | |||
700d21445a | |||
a33422123a | |||
5841c09a31 | |||
8242fbcbbb | |||
a9084d5326 | |||
e194a4d04b | |||
b566959c4a | |||
0490a2b9a8 | |||
36df49836c | |||
7be598c40f | |||
07b6d61c28 | |||
4895e9eefd | |||
fc68729ff6 | |||
cfd53cc495 | |||
768ed43f9e | |||
a5e00a08ef | |||
00d7d7c7bb | |||
fb057af6f8 | |||
ad5ee0bebe | |||
4199822aff | |||
2a6af4a77b | |||
8d8fcf3164 | |||
dca7a0d974 | |||
790385a0c7 | |||
7f3de49baa | |||
74657f990d | |||
c160c04819 | |||
fd04d005d0 | |||
8d9133b79e | |||
d2fce2014a | |||
ae57f81c14 | |||
b80875d268 | |||
99f5d64d61 | |||
4c0bd1815b | |||
8b81016814 | |||
5e4631ec9d | |||
9363e8714a | |||
bd17cf98bf | |||
854f0e5315 | |||
d112bb81cb | |||
82270e074e | |||
439ccf4a1b | |||
23b15f419c | |||
19de902c3f | |||
f7689232b2 | |||
88702d65d8 |
22
.pnpmfile.cjs
Normal file
22
.pnpmfile.cjs
Normal file
@ -0,0 +1,22 @@
|
||||
function readPackage(pkg) {
|
||||
// Resolve @wordpress/* dependencies of @woocommerce packages to those used by MailPoet.
|
||||
// This avoids their duplication and downgrading due to @woocommerce pinning them to wp-6.0.
|
||||
// This should be removed once we adopt similar pinning strategy and use dependency extraction.
|
||||
// See: https://github.com/woocommerce/woocommerce/pull/37034
|
||||
if (pkg.name?.startsWith('@woocommerce/')) {
|
||||
pkg.dependencies = Object.fromEntries(
|
||||
Object.entries(pkg.dependencies).map(([name, version]) =>
|
||||
name.startsWith('@wordpress/') || name.startsWith('@types/wordpress__')
|
||||
? [name, '*']
|
||||
: [name, version],
|
||||
),
|
||||
);
|
||||
}
|
||||
return pkg;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
hooks: {
|
||||
readPackage,
|
||||
},
|
||||
};
|
@ -1 +1,2 @@
|
||||
engine-strict=true
|
||||
# we can set this back to "true" once @woocommerce/components have less restrictive definitions
|
||||
engine-strict=false
|
||||
|
@ -447,9 +447,14 @@ class RoboFile extends \Robo\Tasks {
|
||||
$this->say("Validator metadata generated to: $validatorMetadataDir");
|
||||
}
|
||||
|
||||
public function migrationsNew() {
|
||||
/**
|
||||
* Creates a new migration file. Use `migrations:new db` for a db level migration or `migrations:new app` for app level migration.
|
||||
* @param $level string - db or app
|
||||
*/
|
||||
public function migrationsNew($level) {
|
||||
$generator = new \MailPoet\Migrator\Repository();
|
||||
$result = $generator->create();
|
||||
$level = strtolower($level);
|
||||
$result = $generator->create($level);
|
||||
$path = realpath($result['path']);
|
||||
$this->output->writeln('MAILPOET DATABASE MIGRATIONS');
|
||||
$this->output->writeln("============================\n");
|
||||
|
@ -1,7 +1,11 @@
|
||||
$color-grey: #ddd;
|
||||
$color-wp-gray-0: #fbfbfb;
|
||||
$color-poet-gray-dividers: #dcdcde;
|
||||
$color-gutenberg-blue: #007cba;
|
||||
$color-gutenberg-grey-100: #f0f0f0;
|
||||
$color-gutenberg-grey-600: #949494;
|
||||
$color-gutenberg-grey-700: #757575;
|
||||
$color-gutenberg-grey-800: #2f2f2f;
|
||||
$color-white: #fff;
|
||||
$color-black: #1e1e1e;
|
||||
$color-primary: #007cba;
|
@ -0,0 +1,29 @@
|
||||
.mailpoet-automation-analytics {
|
||||
.woocommerce-table__header,
|
||||
.woocommerce-table__item,
|
||||
.woocommerce-table__empty-item {
|
||||
text-wrap: balance;
|
||||
|
||||
@include respond-to(medium-screen) {
|
||||
padding: 8px 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.woocommerce-table__header.is-sortable {
|
||||
padding: 2px;
|
||||
|
||||
button {
|
||||
padding: 4px 20px;
|
||||
width: 100%;
|
||||
|
||||
@include respond-to(medium-screen) {
|
||||
padding: 4px 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.woocommerce-table__header,
|
||||
.woocommerce-table__header button {
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
@ -0,0 +1,162 @@
|
||||
@import '../colors';
|
||||
|
||||
.mailpoet-automation-editor-stats-placeholder {
|
||||
.mailpoet-automation-stats-label {
|
||||
background: $color-gutenberg-grey-100;
|
||||
color: $color-gutenberg-grey-100;
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
|
||||
.mailpoet-automation-stats-value {
|
||||
background: $color-gutenberg-grey-100;
|
||||
color: $color-gutenberg-grey-100;
|
||||
display: block;
|
||||
margin: auto;
|
||||
width: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.mailpoet-automation-editor-step-wrapper-placeholder {
|
||||
.mailpoet-automation-editor-step-icon {
|
||||
background: $color-gutenberg-grey-100;
|
||||
border-radius: 50%;
|
||||
height: 49px;
|
||||
width: 49px;
|
||||
}
|
||||
|
||||
.mailpoet-automation-editor-step-title {
|
||||
background: $color-gutenberg-grey-100;
|
||||
height: 20px;
|
||||
margin-bottom: 3px;
|
||||
width: 50%;
|
||||
}
|
||||
|
||||
.mailpoet-automation-editor-step-subtitle {
|
||||
background: $color-gutenberg-grey-100;
|
||||
height: 20px;
|
||||
width: 90%;
|
||||
}
|
||||
}
|
||||
|
||||
.mailpoet-automation-analytics-step-failed {
|
||||
bottom: 0;
|
||||
display: none;
|
||||
grid-column: 1 / -1;
|
||||
justify-content: center;
|
||||
left: -100px;
|
||||
position: absolute;
|
||||
|
||||
&:after {
|
||||
border-top: 1px dashed $color-gutenberg-grey-600;
|
||||
content: '';
|
||||
height: 0;
|
||||
left: 100%;
|
||||
position: absolute;
|
||||
top: 15px;
|
||||
width: 100px;
|
||||
}
|
||||
|
||||
span {
|
||||
display: block;
|
||||
}
|
||||
|
||||
p {
|
||||
padding: 0 8px;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (min-width: 600px) {
|
||||
.mailpoet-automation-analytics-step-failed {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.mailpoet-automation-analytics-step-footer {
|
||||
background: $color-wp-gray-0;
|
||||
border-top: 1px solid $color-poet-gray-dividers;
|
||||
display: flex;
|
||||
grid-column: 1 / -1;
|
||||
justify-content: center;
|
||||
margin-bottom: -12px;
|
||||
margin-left: -12px;
|
||||
width: calc(100% + 24px);
|
||||
z-index: 1;
|
||||
|
||||
p {
|
||||
padding: 6px 0;
|
||||
}
|
||||
}
|
||||
|
||||
.mailpoet-automation-analytics-step-footer,
|
||||
.mailpoet-automation-analytics-step-failed {
|
||||
p {
|
||||
margin: 0;
|
||||
|
||||
a {
|
||||
color: $color-gutenberg-grey-800;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
color: $color-gutenberg-blue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
span {
|
||||
color: $color-gutenberg-grey-600;
|
||||
}
|
||||
|
||||
a:hover span {
|
||||
color: $color-gutenberg-blue;
|
||||
}
|
||||
}
|
||||
|
||||
.mailpoet-automation-analytics-separator {
|
||||
p {
|
||||
background: $color-wp-gray-0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
span {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.mailpoet-automation-analytics-separator-text {
|
||||
color: $color-gutenberg-grey-600;
|
||||
}
|
||||
}
|
||||
|
||||
// Send Email Panel
|
||||
.mailpoet-automation-analytics-send-email-panel {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.mailpoet-automation-analytics-send-email-panel-section {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin: 1em 0;
|
||||
|
||||
&.is-loading {
|
||||
background: $color-gutenberg-grey-100;
|
||||
height: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.mailpoet-automation-analytics-send-email-panel-label {
|
||||
color: $color-gutenberg-grey-700;
|
||||
}
|
||||
|
||||
.mailpoet-automation-analytics-send-email-panel-value {
|
||||
text-align: end;
|
||||
}
|
||||
|
||||
.mailpoet-automation-editor-automation-notices {
|
||||
background: $color-wp-gray-0;
|
||||
padding: 32px 0 0;
|
||||
}
|
||||
|
||||
.mailpoet-automation-flow-notice {
|
||||
margin: 0 auto;
|
||||
max-width: 480px;
|
||||
width: 100%;
|
||||
}
|
@ -9,6 +9,10 @@
|
||||
}
|
||||
|
||||
.mailpoet-analytics-badge {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.mailpoet-analytics-badge-text {
|
||||
@include badge;
|
||||
}
|
||||
|
||||
@ -23,7 +27,7 @@
|
||||
.mailpoet-analytics-badge-success {
|
||||
color: $color-gutenberg-alert-green;
|
||||
|
||||
.mailpoet-analytics-badge {
|
||||
.mailpoet-analytics-badge-text {
|
||||
background: $color-wp-green-0;
|
||||
color: $color-wp-green-60;
|
||||
}
|
||||
@ -32,7 +36,7 @@
|
||||
.mailpoet-analytics-badge-warning {
|
||||
color: $color-wp-yellow-50;
|
||||
|
||||
.mailpoet-analytics-badge {
|
||||
.mailpoet-analytics-badge-text {
|
||||
background: $color-wp-yellow-0;
|
||||
color: $color-wp-yellow-60;
|
||||
}
|
@ -27,6 +27,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.woocommerce-summary__item {
|
||||
.mailpoet-automation-analytics .woocommerce-summary__item {
|
||||
box-sizing: border-box;
|
||||
}
|
@ -2,3 +2,5 @@
|
||||
@import 'general';
|
||||
@import 'email';
|
||||
@import 'orders';
|
||||
@import 'automation_flow';
|
||||
@import 'subscribers';
|
@ -8,4 +8,5 @@
|
||||
line-height: 16px;
|
||||
margin-right: 4px;
|
||||
padding: 4px 8px;
|
||||
white-space: nowrap;
|
||||
}
|
@ -1,11 +1,21 @@
|
||||
@import '../colors';
|
||||
@import 'mixins';
|
||||
|
||||
.mailpoet-analytics-tab-orders {
|
||||
.mailpoet-analytics-tab-orders,
|
||||
.mailpoet-analytics-tab-subscribers {
|
||||
color: $color-gutenberg-grey-600;
|
||||
}
|
||||
|
||||
.woocommerce-table__item {
|
||||
.mailpoet-analytics-filter {
|
||||
.is-loading {
|
||||
background: $color-gutenberg-grey-100;
|
||||
height: 20px;
|
||||
max-width: 200px;
|
||||
width: 45%;
|
||||
}
|
||||
}
|
||||
|
||||
.mailpoet-automation-analytics .woocommerce-table__item {
|
||||
a.mailpoet-analytics-orders__customer {
|
||||
align-items: flex-start;
|
||||
display: flex;
|
||||
@ -19,16 +29,27 @@
|
||||
|
||||
.mailpoet-automations-analytics-order-products {
|
||||
align-items: center;
|
||||
column-gap: 8px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
.quantity {
|
||||
color: $color-gutenberg-grey-700;
|
||||
}
|
||||
|
||||
.woocommerce-view-more-list {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
|
||||
button {
|
||||
background: transparent;
|
||||
border-radius: 0;
|
||||
color: $color-gutenberg-grey-700;
|
||||
height: auto;
|
||||
line-height: inherit;
|
||||
margin: 0;
|
||||
overflow: visible;
|
||||
padding: 0;
|
||||
text-decoration: underline dotted;
|
||||
}
|
||||
}
|
@ -0,0 +1,41 @@
|
||||
@import '../colors';
|
||||
|
||||
.mailpoet-analytics-clear-filters {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.mailpoet-analytics-filter {
|
||||
align-items: end;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.mailpoet-analytics-filter-controls {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
|
||||
.components-base-control__field {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.mailpoet-analytics-subscribers-step-cell {
|
||||
column-gap: 8px;
|
||||
display: grid;
|
||||
grid-template-columns: 20px 1fr;
|
||||
|
||||
.mailpoet-automation-colored-icon {
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
span {
|
||||
color: $color-gutenberg-grey-600;
|
||||
font-size: 11px;
|
||||
grid-column-start: 2;
|
||||
}
|
||||
}
|
@ -0,0 +1,3 @@
|
||||
#mailpoet_automation_editor .interface-interface-skeleton__header {
|
||||
border-bottom: none;
|
||||
}
|
@ -0,0 +1,22 @@
|
||||
/* See: https://github.com/WordPress/gutenberg/blob/0b4ad0072a5c3dd4832081ed00d4e27389ae88c8/packages/editor/src/components/post-saved-state/index.js */
|
||||
.mailpoet-automation-editor-saved-state {
|
||||
align-items: center;
|
||||
color: #757575;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
|
||||
&.is-saving[aria-disabled='true'],
|
||||
&.is-saving[aria-disabled='true']:hover,
|
||||
&.is-saved[aria-disabled='true'],
|
||||
&.is-saved[aria-disabled='true']:hover {
|
||||
background: transparent;
|
||||
color: #757575;
|
||||
}
|
||||
|
||||
svg {
|
||||
display: inline-block;
|
||||
fill: currentColor;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
}
|
@ -0,0 +1,25 @@
|
||||
// settings
|
||||
|
||||
@import '../settings/colors';
|
||||
|
||||
// styles
|
||||
|
||||
@import './add-step-button';
|
||||
@import './add-trigger';
|
||||
@import './automation';
|
||||
@import './block-icon';
|
||||
@import './chip';
|
||||
@import './dropdown';
|
||||
@import './empty-automation';
|
||||
@import './errors';
|
||||
@import './panel';
|
||||
@import './saved-state';
|
||||
@import './separator';
|
||||
@import './status';
|
||||
@import './step';
|
||||
@import './step-filters';
|
||||
@import './step-card';
|
||||
@import './filters';
|
||||
@import './header';
|
||||
@import './notices';
|
||||
@import './deactivate-modal';
|
@ -10,10 +10,9 @@ $mailpoet-form-template-thumbnail-height: 316px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.mailpoet-templates {
|
||||
.mailpoet-form-templates {
|
||||
@include formTemplatesGrid;
|
||||
padding-bottom: math.div($mailpoet-form-template-thumbnail-height, 3);
|
||||
padding-top: $grid-gap-large;
|
||||
|
||||
.mailpoet-categories {
|
||||
grid-column: 1 / -1;
|
||||
@ -32,37 +31,13 @@ $mailpoet-form-template-thumbnail-height: 316px;
|
||||
}
|
||||
}
|
||||
|
||||
$templates-one-column-breakpoint: 2 * ($mailpoet-form-template-thumbnail-width + $grid-gap-half) + $grid-gap + 160;
|
||||
/**
|
||||
The header uses grid to position heading in center (second column) and a new form button on right (third column)
|
||||
*/
|
||||
.mailpoet-template-selection-header {
|
||||
@include formTemplatesGrid;
|
||||
background: $color-input-background;
|
||||
border-bottom: 1px solid $color-tertiary-light;
|
||||
grid-row-gap: 0;
|
||||
justify-items: center;
|
||||
padding: $grid-gap 0;
|
||||
position: relative;
|
||||
|
||||
@include breakpoint-min-width($templates-one-column-breakpoint) {
|
||||
justify-items: right;
|
||||
}
|
||||
|
||||
.mailpoet-h4 {
|
||||
// Keep heading centered when we are sure there are 2 or more columns
|
||||
|
||||
@include breakpoint-min-width($templates-one-column-breakpoint) {
|
||||
left: 50%;
|
||||
margin-top: 0;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
}
|
||||
|
||||
.mailpoet-button {
|
||||
align-self: center;
|
||||
grid-column-end: -1;
|
||||
}
|
||||
.mailpoet-form-template-selection-header {
|
||||
grid-column: 1/-1;
|
||||
}
|
||||
|
||||
.mailpoet-form-template-selection-footer {
|
||||
border-top: 1px solid $color-tertiary-light;
|
||||
grid-column: 1/-1;
|
||||
margin-top: $grid-gap-medium;
|
||||
text-align: center;
|
||||
}
|
||||
|
@ -7,7 +7,7 @@
|
||||
.mailpoet-subscriber-stats-summary-grid {
|
||||
display: grid;
|
||||
grid-gap: $grid-gap;
|
||||
grid-template-columns: auto auto 1fr;
|
||||
grid-template-columns: auto auto auto 1fr;
|
||||
|
||||
.mailpoet-listing .mailpoet-listing-table {
|
||||
border: 0;
|
||||
|
@ -152,7 +152,9 @@
|
||||
}
|
||||
|
||||
.mailpoet-wizard-woocommerce-toggle {
|
||||
align-self: flex-start;
|
||||
margin-left: $grid-gap;
|
||||
margin-top: $grid-gap;
|
||||
}
|
||||
|
||||
.mailpoet-welcome-wizard-confirmation-modal {
|
||||
|
@ -1,5 +1,22 @@
|
||||
// dependencies
|
||||
|
||||
@import '../../../node_modules/@woocommerce/components/build-style/style';
|
||||
@import '../../../node_modules/react-dates/lib/css/_datepicker.scss';
|
||||
|
||||
// settings & mixins
|
||||
|
||||
@import 'settings/breakpoints';
|
||||
@import 'settings/colors';
|
||||
@import 'mixins/breakpoints';
|
||||
|
||||
// automation editor styles
|
||||
|
||||
@import './components-automation/statistics';
|
||||
@import './components-automation-editor/editor';
|
||||
|
||||
// styles
|
||||
|
||||
@import './components-automation-analytics/header';
|
||||
@import './components-automation-analytics/overview';
|
||||
@import './components-automation-analytics/table';
|
||||
@import './components-automation-analytics/tabs';
|
||||
|
@ -9,23 +9,7 @@
|
||||
|
||||
// automation editor
|
||||
|
||||
@import './components-automation-editor/add-step-button';
|
||||
@import './components-automation-editor/add-trigger';
|
||||
@import './components-automation-editor/automation';
|
||||
@import './components-automation-editor/block-icon';
|
||||
@import './components-automation-editor/chip';
|
||||
@import './components-automation-editor/dropdown';
|
||||
@import './components-automation-editor/empty-automation';
|
||||
@import './components-automation-editor/errors';
|
||||
@import './components-automation-editor/panel';
|
||||
@import './components-automation-editor/separator';
|
||||
@import './components-automation-editor/status';
|
||||
@import './components-automation-editor/step';
|
||||
@import './components-automation-editor/step-filters';
|
||||
@import './components-automation-editor/step-card';
|
||||
@import './components-automation-editor/filters';
|
||||
@import './components-automation-editor/notices';
|
||||
@import './components-automation-editor/deactivate-modal';
|
||||
@import './components-automation-editor/editor';
|
||||
|
||||
// integrations
|
||||
|
||||
|
@ -9,9 +9,10 @@ import { storeName } from '../../store';
|
||||
|
||||
type Props = {
|
||||
step: Step;
|
||||
context: 'edit' | 'view';
|
||||
};
|
||||
|
||||
export function AddTrigger({ step }: Props): JSX.Element {
|
||||
export function AddTrigger({ step, context }: Props): JSX.Element {
|
||||
const compositeState = useContext(AutomationCompositeContext);
|
||||
const { setInserterPopover } = useDispatch(storeName);
|
||||
|
||||
@ -22,13 +23,17 @@ export function AddTrigger({ step }: Props): JSX.Element {
|
||||
className="mailpoet-automation-add-trigger"
|
||||
data-previous-step-id={step.id}
|
||||
focusable
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
setInserterPopover({
|
||||
anchor: (event.target as HTMLElement).closest('button'),
|
||||
type: 'triggers',
|
||||
});
|
||||
}}
|
||||
onClick={
|
||||
context === 'edit'
|
||||
? (event) => {
|
||||
event.stopPropagation();
|
||||
setInserterPopover({
|
||||
anchor: (event.target as HTMLElement).closest('button'),
|
||||
type: 'triggers',
|
||||
});
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Icon icon={plus} size={16} />
|
||||
{__('Add trigger', 'mailpoet')}
|
||||
|
@ -21,7 +21,10 @@ import {
|
||||
RenderStepType,
|
||||
} from '../../../types/filters';
|
||||
|
||||
export function Automation(): JSX.Element {
|
||||
type AutomationProps = {
|
||||
context: 'edit' | 'view';
|
||||
};
|
||||
export function Automation({ context }: AutomationProps): JSX.Element {
|
||||
const { automationData, selectedStep } = useSelect(
|
||||
(select) => ({
|
||||
automationData: select(storeName).getAutomationData(),
|
||||
@ -59,15 +62,17 @@ export function Automation(): JSX.Element {
|
||||
'mailpoet.automation.render_step',
|
||||
(stepData: StepData) =>
|
||||
stepData.type === 'root' ? (
|
||||
<AddTrigger step={stepData} />
|
||||
<AddTrigger step={stepData} context={context} />
|
||||
) : (
|
||||
<Step
|
||||
step={stepData}
|
||||
isSelected={selectedStep && stepData.id === selectedStep.id}
|
||||
context={context}
|
||||
/>
|
||||
),
|
||||
context,
|
||||
),
|
||||
[selectedStep],
|
||||
[selectedStep, context],
|
||||
);
|
||||
|
||||
const renderSeparator = useMemo(
|
||||
@ -77,8 +82,9 @@ export function Automation(): JSX.Element {
|
||||
(previousStepData: StepData) => (
|
||||
<Separator previousStepId={previousStepData.id} />
|
||||
),
|
||||
context,
|
||||
),
|
||||
[],
|
||||
[context],
|
||||
);
|
||||
|
||||
if (!automationData) {
|
||||
|
@ -9,9 +9,10 @@ import { StepMoreControlsType } from '../../../types/filters';
|
||||
|
||||
type Props = {
|
||||
step: StepData;
|
||||
context: 'edit' | 'view';
|
||||
};
|
||||
|
||||
export function StepMoreMenu({ step }: Props): JSX.Element {
|
||||
export function StepMoreMenu({ step, context }: Props): JSX.Element {
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
|
||||
const moreControls: StepMoreControlsType = Hooks.applyFilters(
|
||||
@ -45,6 +46,7 @@ export function StepMoreMenu({ step }: Props): JSX.Element {
|
||||
},
|
||||
},
|
||||
step,
|
||||
context,
|
||||
);
|
||||
|
||||
const slots = Object.values(moreControls).filter(
|
||||
|
@ -4,6 +4,7 @@ import { __unstableCompositeItem as CompositeItem } from '@wordpress/components'
|
||||
import { useDispatch, useRegistry, useSelect } from '@wordpress/data';
|
||||
import { blockMeta } from '@wordpress/icons';
|
||||
import { __, _x } from '@wordpress/i18n';
|
||||
import { Hooks } from 'wp-js-hooks';
|
||||
import { AutomationCompositeContext } from './context';
|
||||
import { StepFilters } from './step-filters';
|
||||
import { StepMoreMenu } from './step-more-menu';
|
||||
@ -12,6 +13,7 @@ import { Chip } from '../chip';
|
||||
import { ColoredIcon } from '../icons';
|
||||
import { stepSidebarKey, storeName } from '../../store';
|
||||
import { StepType } from '../../store/types';
|
||||
import { RenderStepFooterType, StepMoreType } from '../../../types/filters';
|
||||
|
||||
const getUnknownStepType = (step: StepData): StepType => {
|
||||
const isTrigger = step.type === 'trigger';
|
||||
@ -41,9 +43,10 @@ const getUnknownStepType = (step: StepData): StepType => {
|
||||
type Props = {
|
||||
step: StepData;
|
||||
isSelected: boolean;
|
||||
context: 'edit' | 'view';
|
||||
};
|
||||
|
||||
export function Step({ step, isSelected }: Props): JSX.Element {
|
||||
export function Step({ step, isSelected, context }: Props): JSX.Element {
|
||||
const { stepType, error } = useSelect(
|
||||
(select) => ({
|
||||
stepType: select(storeName).getStepType(step.key),
|
||||
@ -58,9 +61,26 @@ export function Step({ step, isSelected }: Props): JSX.Element {
|
||||
const compositeItemId = `step-${step.id}`;
|
||||
const stepTypeData = stepType ?? getUnknownStepType(step);
|
||||
|
||||
const footer: RenderStepFooterType = Hooks.applyFilters(
|
||||
'mailpoet.automation.step.footer',
|
||||
<div className="mailpoet-automation-editor-step-footer">
|
||||
<StepFilters step={step} />
|
||||
{error ? (
|
||||
<div className="mailpoet-automation-editor-step-error">
|
||||
<Chip variant="danger" size="small">
|
||||
{__('Not set', 'mailpoet')}
|
||||
</Chip>
|
||||
</div>
|
||||
) : null}
|
||||
</div>,
|
||||
step,
|
||||
context,
|
||||
isSelected,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="mailpoet-automation-editor-step-wrapper">
|
||||
<StepMoreMenu step={step} />
|
||||
<StepMoreMenu step={step} context={context} />
|
||||
<CompositeItem
|
||||
state={compositeState}
|
||||
role="treeitem"
|
||||
@ -72,11 +92,14 @@ export function Step({ step, isSelected }: Props): JSX.Element {
|
||||
id={compositeItemId}
|
||||
key={step.id}
|
||||
focusable
|
||||
onClick={() =>
|
||||
batch(() => {
|
||||
openSidebar(stepSidebarKey);
|
||||
selectStep(step);
|
||||
})
|
||||
onClick={
|
||||
context === 'edit'
|
||||
? () =>
|
||||
batch(() => {
|
||||
openSidebar(stepSidebarKey);
|
||||
selectStep(step);
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<div className="mailpoet-automation-editor-step-icon">
|
||||
@ -103,16 +126,16 @@ export function Step({ step, isSelected }: Props): JSX.Element {
|
||||
: stepTypeData.title(step, 'automation')}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mailpoet-automation-editor-step-footer">
|
||||
<StepFilters step={step} />
|
||||
{error && (
|
||||
<div className="mailpoet-automation-editor-step-error">
|
||||
<Chip variant="danger" size="small">
|
||||
{__('Not set', 'mailpoet')}
|
||||
</Chip>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{
|
||||
Hooks.applyFilters(
|
||||
'mailpoet.automation.step.more',
|
||||
null,
|
||||
step,
|
||||
context,
|
||||
isSelected,
|
||||
) as StepMoreType
|
||||
}
|
||||
{footer}
|
||||
</CompositeItem>
|
||||
</div>
|
||||
);
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import classnames from 'classnames';
|
||||
import {
|
||||
Button,
|
||||
NavigableMenu,
|
||||
@ -6,8 +7,10 @@ import {
|
||||
Tooltip,
|
||||
} from '@wordpress/components';
|
||||
import { dispatch, useDispatch, useSelect } from '@wordpress/data';
|
||||
import { Icon, check, cloud } from '@wordpress/icons';
|
||||
import { PinnedItems } from '@wordpress/interface';
|
||||
import { __ } from '@wordpress/i18n';
|
||||
import { displayShortcut } from '@wordpress/keycodes';
|
||||
import { ErrorBoundary } from 'common';
|
||||
import { DocumentActions } from './document_actions';
|
||||
import { Errors } from './errors';
|
||||
@ -67,21 +70,35 @@ function ActivateButton({ label }): JSX.Element {
|
||||
function UpdateButton(): JSX.Element {
|
||||
const { save } = useDispatch(storeName);
|
||||
|
||||
const { automation } = useSelect(
|
||||
const { automation, savedState } = useSelect(
|
||||
(select) => ({
|
||||
automation: select(storeName).getAutomationData(),
|
||||
savedState: select(storeName).getSavedState(),
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
const isDisabled = savedState === 'saving' || savedState === 'saved';
|
||||
|
||||
const label =
|
||||
savedState === 'saving'
|
||||
? __('Updating…', 'mailpoet')
|
||||
: __('Update', 'mailpoet');
|
||||
|
||||
if (automation.stats.totals.in_progress === 0) {
|
||||
return (
|
||||
<Button
|
||||
variant="primary"
|
||||
className="editor-post-publish-button"
|
||||
label={label}
|
||||
showTooltip
|
||||
shortcut={isDisabled ? undefined : displayShortcut.primary('s')}
|
||||
isBusy={savedState === 'saving'}
|
||||
disabled={isDisabled}
|
||||
aria-disabled={isDisabled}
|
||||
onClick={save}
|
||||
>
|
||||
{__('Update', 'mailpoet')}
|
||||
{label}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@ -106,11 +123,45 @@ function UpdateButton(): JSX.Element {
|
||||
}
|
||||
|
||||
function SaveDraftButton(): JSX.Element {
|
||||
const savedState = useSelect(
|
||||
(select) => select(storeName).getSavedState(),
|
||||
[],
|
||||
);
|
||||
const { save } = useDispatch(storeName);
|
||||
|
||||
const label = useMemo(() => {
|
||||
if (savedState === 'saving') {
|
||||
return __('Saving', 'mailpoet');
|
||||
}
|
||||
if (savedState === 'saved') {
|
||||
return __('Saved', 'mailpoet');
|
||||
}
|
||||
return __('Save draft', 'mailpoet');
|
||||
}, [savedState]);
|
||||
|
||||
const isDisabled = savedState === 'saving' || savedState === 'saved';
|
||||
|
||||
// use single Button instance for all states so that focus is not lost
|
||||
return (
|
||||
<Button variant="tertiary" onClick={save}>
|
||||
{__('Save draft', 'mailpoet')}
|
||||
<Button
|
||||
className={classnames([
|
||||
'mailpoet-automation-editor-saved-state',
|
||||
`is-${savedState}`,
|
||||
{
|
||||
'components-animate__loading': savedState === 'saving',
|
||||
},
|
||||
])}
|
||||
variant="tertiary"
|
||||
label={label}
|
||||
shortcut={isDisabled ? undefined : displayShortcut.primary('s')}
|
||||
showTooltip
|
||||
disabled={isDisabled}
|
||||
aria-disabled={isDisabled}
|
||||
onClick={save}
|
||||
>
|
||||
{savedState === 'saving' && <Icon icon={cloud} />}
|
||||
{savedState === 'saved' && <Icon icon={check} />}
|
||||
{label}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
@ -1,8 +1,10 @@
|
||||
import { MenuGroup, MenuItem } from '@wordpress/components';
|
||||
import { useSelect } from '@wordpress/data';
|
||||
import { displayShortcut } from '@wordpress/keycodes';
|
||||
import { __, _x } from '@wordpress/i18n';
|
||||
import { MoreMenuDropdown } from '@wordpress/interface';
|
||||
import { PreferenceToggleMenuItem } from '@wordpress/preferences';
|
||||
import { addQueryArgs } from '@wordpress/url';
|
||||
import { storeName } from '../../store';
|
||||
import { MailPoet } from '../../../../mailpoet';
|
||||
|
||||
@ -11,6 +13,10 @@ import { MailPoet } from '../../../../mailpoet';
|
||||
// https://github.com/WordPress/gutenberg/blob/0ee78b1bbe9c6f3e6df99f3b967132fa12bef77d/packages/edit-site/src/components/header/more-menu/index.js
|
||||
|
||||
export function MoreMenu(): JSX.Element {
|
||||
const automation = useSelect((select) =>
|
||||
select(storeName).getAutomationData(),
|
||||
);
|
||||
|
||||
return (
|
||||
<MoreMenuDropdown
|
||||
className="edit-site-more-menu"
|
||||
@ -32,6 +38,18 @@ export function MoreMenu(): JSX.Element {
|
||||
/>
|
||||
</MenuGroup>
|
||||
<MenuGroup>
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
window.location.href = addQueryArgs(
|
||||
MailPoet.urls.automationAnalytics,
|
||||
{
|
||||
id: automation.id,
|
||||
},
|
||||
);
|
||||
}}
|
||||
>
|
||||
{__('Analytics', 'mailpoet')}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
window.location.href = MailPoet.urls.automationListing;
|
||||
|
@ -12,12 +12,14 @@ import { stepSidebarKey, storeName, automationSidebarKey } from '../../store';
|
||||
// https://github.com/WordPress/gutenberg/blob/0ee78b1bbe9c6f3e6df99f3b967132fa12bef77d/packages/edit-site/src/components/keyboard-shortcuts/index.js
|
||||
|
||||
export function KeyboardShortcuts(): null {
|
||||
const { isSidebarOpened, selectedStep } = useSelect((select) => ({
|
||||
const { isSidebarOpened, selectedStep, savedState } = useSelect((select) => ({
|
||||
isSidebarOpened: select(storeName).isSidebarOpened,
|
||||
selectedStep: select(storeName).getSelectedStep,
|
||||
savedState: select(storeName).getSavedState(),
|
||||
}));
|
||||
|
||||
const { openSidebar, closeSidebar, toggleFeature } = useDispatch(storeName);
|
||||
const { openSidebar, closeSidebar, save, toggleFeature } =
|
||||
useDispatch(storeName);
|
||||
|
||||
const { registerShortcut } = useDispatch(keyboardShortcutsStore);
|
||||
|
||||
@ -41,6 +43,16 @@ export function KeyboardShortcuts(): null {
|
||||
character: ',',
|
||||
},
|
||||
});
|
||||
|
||||
void registerShortcut({
|
||||
name: 'mailpoet/automation-editor/save',
|
||||
category: 'global',
|
||||
description: __('Save your changes.', 'mailpoet'),
|
||||
keyCombination: {
|
||||
modifier: 'primary',
|
||||
character: 's',
|
||||
},
|
||||
});
|
||||
}, [registerShortcut]);
|
||||
|
||||
useShortcut('mailpoet/automation-editor/toggle-fullscreen', () => {
|
||||
@ -60,5 +72,13 @@ export function KeyboardShortcuts(): null {
|
||||
}
|
||||
});
|
||||
|
||||
useShortcut('mailpoet/automation-editor/save', (event) => {
|
||||
event.preventDefault();
|
||||
|
||||
if (savedState === 'unsaved') {
|
||||
save();
|
||||
}
|
||||
});
|
||||
|
||||
return null;
|
||||
}
|
||||
|
@ -18,9 +18,8 @@ export function StepName({
|
||||
<Dropdown
|
||||
className="mailpoet-step-name-dropdown"
|
||||
contentClassName="mailpoet-step-name-popover"
|
||||
position="bottom left"
|
||||
popoverProps={{
|
||||
placement: 'bottom-start',
|
||||
placement: 'bottom-end',
|
||||
}}
|
||||
renderToggle={({ isOpen, onToggle }) => (
|
||||
<PlainBodyTitle
|
||||
|
@ -70,7 +70,7 @@ function updatingActiveAutomationNotPossible() {
|
||||
}
|
||||
|
||||
function onUnload(event) {
|
||||
if (!globalSelect(storeName).getAutomationSaved()) {
|
||||
if (globalSelect(storeName).getSavedState() !== 'saved') {
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
event.returnValue = __(
|
||||
'There are unsaved changes that will be lost. Do you want to continue?',
|
||||
@ -154,7 +154,7 @@ function Editor(): JSX.Element {
|
||||
content={
|
||||
<>
|
||||
<EditorNotices />
|
||||
<Automation />
|
||||
<Automation context="edit" />
|
||||
</>
|
||||
}
|
||||
sidebar={<ComplementaryArea.Slot scope={storeName} />}
|
||||
|
@ -89,6 +89,11 @@ export function setAutomationName(name) {
|
||||
|
||||
export function* save() {
|
||||
const automation = select(storeName).getAutomationData();
|
||||
|
||||
yield {
|
||||
type: 'SAVING',
|
||||
};
|
||||
|
||||
const data = yield apiFetch({
|
||||
path: `/automations/${automation.id}`,
|
||||
method: 'PUT',
|
||||
@ -221,6 +226,13 @@ export function* trash(onTrashed: () => void = undefined) {
|
||||
} as const;
|
||||
}
|
||||
|
||||
export function updateAutomation(automation) {
|
||||
return {
|
||||
type: 'UPDATE_AUTOMATION',
|
||||
automation,
|
||||
} as const;
|
||||
}
|
||||
|
||||
export function registerStepType(stepType) {
|
||||
return {
|
||||
type: 'REGISTER_STEP_TYPE',
|
||||
|
@ -3,11 +3,11 @@ import { AutomationEditorWindow, State } from './types';
|
||||
declare let window: AutomationEditorWindow;
|
||||
|
||||
export const getInitialState = (): State => ({
|
||||
savedState: 'saved',
|
||||
registry: { ...window.mailpoet_automation_registry },
|
||||
context: { ...window.mailpoet_automation_context },
|
||||
stepTypes: {},
|
||||
automationData: { ...window.mailpoet_automation },
|
||||
automationSaved: true,
|
||||
selectedStep: undefined,
|
||||
inserterSidebar: {
|
||||
isOpened: false,
|
||||
|
@ -32,31 +32,36 @@ export function reducer(state: State, action): State {
|
||||
return {
|
||||
...state,
|
||||
automationData: action.automation,
|
||||
automationSaved: false,
|
||||
savedState: 'unsaved',
|
||||
};
|
||||
case 'SAVE':
|
||||
return {
|
||||
...state,
|
||||
automationData: action.automation,
|
||||
automationSaved: true,
|
||||
savedState: 'saved',
|
||||
};
|
||||
case 'ACTIVATE':
|
||||
return {
|
||||
...state,
|
||||
automationData: action.automation,
|
||||
automationSaved: true,
|
||||
savedState: 'saved',
|
||||
};
|
||||
case 'DEACTIVATE':
|
||||
return {
|
||||
...state,
|
||||
automationData: action.automation,
|
||||
automationSaved: true,
|
||||
savedState: 'saved',
|
||||
};
|
||||
case 'TRASH':
|
||||
return {
|
||||
...state,
|
||||
automationData: action.automation,
|
||||
automationSaved: true,
|
||||
savedState: 'saved',
|
||||
};
|
||||
case 'SAVING':
|
||||
return {
|
||||
...state,
|
||||
savedState: 'saving',
|
||||
};
|
||||
case 'REGISTER_STEP_TYPE':
|
||||
return {
|
||||
@ -96,7 +101,7 @@ export function reducer(state: State, action): State {
|
||||
[action.stepId]: step,
|
||||
},
|
||||
},
|
||||
automationSaved: false,
|
||||
savedState: 'unsaved',
|
||||
selectedStep: step,
|
||||
errors:
|
||||
stepErrors.length > 0
|
||||
@ -119,7 +124,7 @@ export function reducer(state: State, action): State {
|
||||
[action.key]: action.value,
|
||||
},
|
||||
},
|
||||
automationSaved: false,
|
||||
savedState: 'unsaved',
|
||||
};
|
||||
case 'SET_ERRORS':
|
||||
return {
|
||||
|
@ -9,6 +9,7 @@ import {
|
||||
State,
|
||||
StepErrors,
|
||||
StepType,
|
||||
State as EditorState,
|
||||
} from './types';
|
||||
import { Item } from '../components/inserter/item';
|
||||
import { Step, Automation } from '../components/automation/types';
|
||||
@ -76,8 +77,8 @@ export function getAutomationData(state: State): Automation {
|
||||
return state.automationData;
|
||||
}
|
||||
|
||||
export function getAutomationSaved(state: State): boolean {
|
||||
return state.automationSaved;
|
||||
export function getSavedState(state: State): State['savedState'] {
|
||||
return state.savedState;
|
||||
}
|
||||
|
||||
export function getSelectedStep(state: State): Step | undefined {
|
||||
@ -109,3 +110,10 @@ export const getStepSubjectKeys = (state: State, key: string): string[] => {
|
||||
if (!step) return [];
|
||||
return step.subject_keys;
|
||||
};
|
||||
|
||||
export function automationHasStep(state: EditorState, key: string): boolean {
|
||||
const steps = Object.values(state.automationData.steps).filter(
|
||||
(step) => step.key === key,
|
||||
);
|
||||
return steps.length > 0;
|
||||
}
|
||||
|
@ -92,11 +92,11 @@ export type Errors = {
|
||||
};
|
||||
|
||||
export type State = {
|
||||
savedState: 'unsaved' | 'saving' | 'saved';
|
||||
registry: Registry;
|
||||
context: Context;
|
||||
stepTypes: Record<string, StepType>;
|
||||
automationData: Automation;
|
||||
automationSaved: boolean;
|
||||
selectedStep: Step | undefined;
|
||||
inserterSidebar: {
|
||||
isOpened: boolean;
|
||||
|
@ -0,0 +1,60 @@
|
||||
import { __ } from '@wordpress/i18n';
|
||||
import { calculatePercentage } from '../../formatter/calculate_percentage';
|
||||
import { EmailStats } from '../../store';
|
||||
|
||||
function percentageBadgeCalculation(percentage: number): {
|
||||
badge: string;
|
||||
badgeType: string;
|
||||
} {
|
||||
if (percentage > 3) {
|
||||
return {
|
||||
badge: __('Excellent', 'mailpoet'),
|
||||
badgeType: 'mailpoet-analytics-badge-success',
|
||||
};
|
||||
}
|
||||
|
||||
if (percentage > 1) {
|
||||
return {
|
||||
badge: __('Good', 'mailpoet'),
|
||||
badgeType: 'mailpoet-analytics-badge-success',
|
||||
};
|
||||
}
|
||||
return {
|
||||
badge: __('Average', 'mailpoet'),
|
||||
badgeType: 'mailpoet-analytics-badge-warning',
|
||||
};
|
||||
}
|
||||
|
||||
type BadgeProps = {
|
||||
email: EmailStats | undefined;
|
||||
property: 'clicked' | 'opened';
|
||||
className?: string;
|
||||
};
|
||||
export function Badge({ email, property, className }: BadgeProps): JSX.Element {
|
||||
if (!email) {
|
||||
return <>0</>;
|
||||
}
|
||||
if (email.sent.current === 0) {
|
||||
return <>{`${email[property]}`}</>;
|
||||
}
|
||||
|
||||
// Shows the percentage of clicked emails compared to the number of sent emails
|
||||
const clickedPercentage = calculatePercentage(
|
||||
email[property],
|
||||
email.sent.current,
|
||||
);
|
||||
const clickedBadge = percentageBadgeCalculation(clickedPercentage);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`mailpoet-analytics-badge ${className ?? ''} ${
|
||||
clickedBadge.badgeType ?? ''
|
||||
}`}
|
||||
>
|
||||
<span className="mailpoet-analytics-badge-text">
|
||||
{clickedBadge.badge}
|
||||
</span>
|
||||
{`${email[property]}`}
|
||||
</div>
|
||||
);
|
||||
}
|
@ -4,11 +4,11 @@ import { addQueryArgs } from '@wordpress/url';
|
||||
import { useSelect } from '@wordpress/data';
|
||||
import { Filter } from './filter';
|
||||
import { MailPoet } from '../../../../../../mailpoet';
|
||||
import { storeName } from '../../store';
|
||||
import { storeName as editorStoreName } from '../../../../../editor/store/constants';
|
||||
|
||||
export function Header(): JSX.Element {
|
||||
const { automation } = useSelect((s) => ({
|
||||
automation: s(storeName).getAutomation(),
|
||||
automation: s(editorStoreName).getAutomationData(),
|
||||
}));
|
||||
return (
|
||||
<header className="mailpoet-analytics-header">
|
||||
|
@ -2,14 +2,20 @@ import { __, _x } from '@wordpress/i18n';
|
||||
import {
|
||||
SummaryList,
|
||||
SummaryListPlaceholder,
|
||||
SummaryNumber,
|
||||
} from '@woocommerce/components/build';
|
||||
SummaryNumber as WooSummaryNumber,
|
||||
} from '@woocommerce/components';
|
||||
import { select, useSelect } from '@wordpress/data';
|
||||
import { MailPoet } from '../../../../../../mailpoet';
|
||||
import { OverviewSection, storeName } from '../../store';
|
||||
import { storeName as editorStoreName } from '../../../../../editor/store';
|
||||
import { locale } from '../../../../../config';
|
||||
import { formattedPrice } from '../../formatter';
|
||||
|
||||
// WooSummaryNumber has return type annotated as Object and has all props mandatory
|
||||
const SummaryNumber = WooSummaryNumber as unknown as (
|
||||
...props: [Partial<Parameters<typeof WooSummaryNumber>[0]>]
|
||||
) => JSX.Element;
|
||||
|
||||
function getEmailPercentage(
|
||||
type: 'opened' | 'clicked',
|
||||
period: 'current' | 'previous' = 'current',
|
||||
@ -25,22 +31,21 @@ function getEmailPercentage(
|
||||
return 0;
|
||||
}
|
||||
|
||||
const percentage = (data[period] * 100) / sent[period] / 100;
|
||||
return percentage;
|
||||
return (data[period] * 100) / sent[period] / 100;
|
||||
}
|
||||
|
||||
function getEmailDelta(type: 'opened' | 'clicked'): number | undefined {
|
||||
const current = getEmailPercentage(type, 'current');
|
||||
const previous = getEmailPercentage(type, 'previous');
|
||||
if (current === undefined || previous === undefined) {
|
||||
return undefined;
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (previous === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const newValue = current > previous ? current - previous : previous - current;
|
||||
const newValue = current - previous;
|
||||
return (newValue / previous) * 100;
|
||||
}
|
||||
|
||||
@ -68,18 +73,17 @@ function getWooCommerceDelta(type: 'revenue' | 'orders'): number | undefined {
|
||||
if (current === undefined || previous === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
const newValue = current > previous ? current - previous : previous - current;
|
||||
const newValue = current - previous;
|
||||
if (newValue === 0 || previous === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return (newValue / previous) * 100;
|
||||
}
|
||||
|
||||
export function Overview(): JSX.Element | null {
|
||||
const { overview, hasEmails } = useSelect((s) => ({
|
||||
overview: s(storeName).getSection('overview'),
|
||||
hasEmails: s(storeName).automationHasEmails(),
|
||||
hasEmails: s(editorStoreName).automationHasStep('mailpoet:send-email'),
|
||||
})) as { overview: OverviewSection; hasEmails: boolean };
|
||||
|
||||
const percentageFormatter = new Intl.NumberFormat(locale.toString(), {
|
||||
@ -94,7 +98,7 @@ export function Overview(): JSX.Element | null {
|
||||
key="overview-opened"
|
||||
label={__('Opened', 'mailpoet')}
|
||||
value={percentageFormatter.format(getEmailPercentage('opened'))}
|
||||
delta={getEmailDelta('opened').toFixed(2) as unknown as number}
|
||||
delta={Number(getEmailDelta('opened').toFixed(2))}
|
||||
/>,
|
||||
);
|
||||
items.push(
|
||||
@ -102,7 +106,7 @@ export function Overview(): JSX.Element | null {
|
||||
key="overview-clicked"
|
||||
label={__('Clicked', 'mailpoet')}
|
||||
value={percentageFormatter.format(getEmailPercentage('clicked'))}
|
||||
delta={getEmailDelta('clicked').toFixed(2) as unknown as number}
|
||||
delta={Number(getEmailDelta('clicked').toFixed(2))}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
@ -111,7 +115,7 @@ export function Overview(): JSX.Element | null {
|
||||
<SummaryNumber
|
||||
key="overview-orders"
|
||||
label={_x('Orders', 'WooCommerce orders', 'mailpoet')}
|
||||
delta={getWooCommerceDelta('orders').toFixed(2) as unknown as number}
|
||||
delta={Number(getWooCommerceDelta('orders').toFixed(2))}
|
||||
value={numberFormatter.format(getWooCommerceTotal('orders'))}
|
||||
/>,
|
||||
);
|
||||
@ -119,7 +123,7 @@ export function Overview(): JSX.Element | null {
|
||||
<SummaryNumber
|
||||
key="overview-revenue"
|
||||
label={__('Revenue', 'mailpoet')}
|
||||
delta={getWooCommerceDelta('revenue').toFixed(2) as unknown as number}
|
||||
delta={Number(getWooCommerceDelta('revenue').toFixed(2))}
|
||||
value={formattedPrice(
|
||||
overview.data !== undefined ? overview.data.revenue.current : 0,
|
||||
)}
|
||||
|
@ -0,0 +1,60 @@
|
||||
import { _x } from '@wordpress/i18n';
|
||||
import { check, Icon } from '@wordpress/icons';
|
||||
import { Statistics as BaseStatistics } from '../../../../../../components/statistics';
|
||||
|
||||
const statisticItems = [
|
||||
{
|
||||
key: 'entered',
|
||||
// translators: Total number of subscribers who entered an automation
|
||||
label: _x('Total Entered', 'automation stats', 'mailpoet'),
|
||||
value: 0,
|
||||
},
|
||||
{
|
||||
key: 'processing',
|
||||
// translators: Total number of subscribers who are being processed in an automation
|
||||
label: _x('Total Processing', 'automation stats', 'mailpoet'),
|
||||
value: 0,
|
||||
},
|
||||
{
|
||||
key: 'exited',
|
||||
// translators: Total number of subscribers who exited an automation, no matter the result
|
||||
label: _x('Total Exited', 'automation stats', 'mailpoet'),
|
||||
value: 0,
|
||||
},
|
||||
];
|
||||
|
||||
function StepPlaceholder(): JSX.Element {
|
||||
return (
|
||||
<>
|
||||
<div className="mailpoet-automation-editor-step-wrapper mailpoet-automation-editor-step-wrapper-placeholder">
|
||||
<div className="mailpoet-automation-editor-step">
|
||||
<div className="mailpoet-automation-editor-step-icon" />
|
||||
<div className="mailpoet-automation-editor-step-content">
|
||||
<div className="mailpoet-automation-editor-step-title" />
|
||||
<div className="mailpoet-automation-editor-step-subtitle" />
|
||||
</div>
|
||||
<div className="mailpoet-automation-editor-step-footer" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mailpoet-automation-editor-separator" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function AutomationPlaceholder(): JSX.Element {
|
||||
return (
|
||||
<div className="mailpoet-automation-editor-automation-wrapper">
|
||||
<div className="mailpoet-automation-editor-stats mailpoet-automation-editor-stats-placeholder">
|
||||
<BaseStatistics items={statisticItems} />
|
||||
</div>
|
||||
<StepPlaceholder />
|
||||
<StepPlaceholder />
|
||||
<StepPlaceholder />
|
||||
<Icon
|
||||
className="mailpoet-automation-editor-automation-end"
|
||||
icon={check}
|
||||
/>
|
||||
<div />
|
||||
</div>
|
||||
);
|
||||
}
|
@ -0,0 +1,56 @@
|
||||
import { Hooks } from 'wp-js-hooks';
|
||||
import { Step as StepData } from '../../../../../../../editor/components/automation/types';
|
||||
import { StepFooter } from '../step_footer';
|
||||
import { SendEmailPanel } from '../steps/send_email';
|
||||
import { StatisticSeparator } from '../statistic_separator';
|
||||
import { moreControls } from './more_controls';
|
||||
|
||||
export function initHooks() {
|
||||
Hooks.addFilter(
|
||||
'mailpoet.automation.step.footer',
|
||||
'mailpoet',
|
||||
(element: JSX.Element | null, step: StepData, context: string) => {
|
||||
if (context !== 'view') {
|
||||
return element;
|
||||
}
|
||||
return <StepFooter step={step} />;
|
||||
},
|
||||
);
|
||||
|
||||
Hooks.addFilter(
|
||||
'mailpoet.automation.step.more',
|
||||
'mailpoet',
|
||||
(element: JSX.Element | null, step: StepData, context: string) => {
|
||||
if (context !== 'view') {
|
||||
return element;
|
||||
}
|
||||
|
||||
if (step.key === 'mailpoet:send-email') {
|
||||
return <SendEmailPanel step={step} />;
|
||||
}
|
||||
|
||||
return element;
|
||||
},
|
||||
);
|
||||
|
||||
Hooks.addFilter(
|
||||
'mailpoet.automation.step.more-controls',
|
||||
'mailpoet',
|
||||
moreControls,
|
||||
20,
|
||||
);
|
||||
|
||||
Hooks.addFilter(
|
||||
'mailpoet.automation.render_step_separator',
|
||||
'mailpoet',
|
||||
(filterValue: () => JSX.Element, context) => {
|
||||
if (context !== 'view') {
|
||||
return filterValue;
|
||||
}
|
||||
return function statisticSeperatorWrapper(previousStepData: StepData) {
|
||||
return <StatisticSeparator previousStepId={previousStepData.id} />;
|
||||
};
|
||||
},
|
||||
20,
|
||||
);
|
||||
}
|
@ -0,0 +1,82 @@
|
||||
import { select } from '@wordpress/data';
|
||||
import { __ } from '@wordpress/i18n';
|
||||
import { StepMoreControlsType } from '../../../../../../../types/filters';
|
||||
import { Step as StepData } from '../../../../../../../editor/components/automation/types';
|
||||
import { OverviewSection, storeName } from '../../../../store';
|
||||
import { openTab } from '../../../../navigation/open_tab';
|
||||
|
||||
export function moreControls(
|
||||
element: StepMoreControlsType | null,
|
||||
step: StepData,
|
||||
context: string,
|
||||
): StepMoreControlsType {
|
||||
const overview = select(storeName).getSection('overview') as OverviewSection;
|
||||
if (context !== 'view') {
|
||||
return element;
|
||||
}
|
||||
if (step.type === 'trigger') {
|
||||
return {};
|
||||
}
|
||||
const customControls: StepMoreControlsType = {};
|
||||
if (step.key === 'mailpoet:send-email') {
|
||||
const email =
|
||||
overview.data !== undefined
|
||||
? Object.values(overview.data.emails).find(
|
||||
(newsletter) => newsletter.id === step.args?.email_id,
|
||||
)
|
||||
: undefined;
|
||||
|
||||
customControls.statistics = {
|
||||
key: 'statistics',
|
||||
control: {
|
||||
icon: null,
|
||||
title: __('View statistics', 'mailpoet'),
|
||||
isDisabled: false,
|
||||
onClick: () => {
|
||||
window.open(
|
||||
`admin.php?page=mailpoet-newsletters#/stats/${
|
||||
step.args.email_id as number
|
||||
}`,
|
||||
'_blank',
|
||||
);
|
||||
},
|
||||
},
|
||||
slot: () => null,
|
||||
};
|
||||
if (email) {
|
||||
customControls.preview = {
|
||||
key: 'preview',
|
||||
control: {
|
||||
icon: null,
|
||||
title: __('Preview email', 'mailpoet'),
|
||||
isDisabled: false,
|
||||
onClick: () => {
|
||||
window.open(email.previewUrl, '_blank');
|
||||
},
|
||||
},
|
||||
slot: () => null,
|
||||
};
|
||||
}
|
||||
}
|
||||
const defaultControls = {
|
||||
subscribers: {
|
||||
key: 'view-subscribers',
|
||||
control: {
|
||||
icon: null,
|
||||
title: __('View subscribers', 'mailpoet'),
|
||||
isDisabled: false,
|
||||
onClick: () => {
|
||||
openTab('subscribers', {
|
||||
filters: { status: [], step: [step.id] },
|
||||
});
|
||||
},
|
||||
},
|
||||
slot: () => null,
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
...customControls,
|
||||
...defaultControls,
|
||||
};
|
||||
}
|
@ -1,3 +1,45 @@
|
||||
import { Notice } from '@wordpress/components';
|
||||
import { useSelect } from '@wordpress/data';
|
||||
import { __ } from '@wordpress/i18n';
|
||||
import { Automation } from '../../../../../../editor/components/automation';
|
||||
import { storeName } from '../../../store';
|
||||
import { AutomationPlaceholder } from './automation_placeholder';
|
||||
import { initHooks } from './hooks';
|
||||
|
||||
initHooks();
|
||||
export function AutomationFlow(): JSX.Element {
|
||||
return <p>Automation flow</p>;
|
||||
const { section } = useSelect(
|
||||
(s) => ({
|
||||
section: s(storeName).getSection('automation_flow'),
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
const isLoading = section.data === undefined;
|
||||
|
||||
if (isLoading) {
|
||||
return <AutomationPlaceholder />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{section.data.tree_is_inconsistent && (
|
||||
<div className="mailpoet-automation-editor-automation-notices">
|
||||
<Notice
|
||||
status="warning"
|
||||
isDismissible={false}
|
||||
className="mailpoet-automation-flow-notice"
|
||||
>
|
||||
<p>
|
||||
{__(
|
||||
'In this time period, the automation structure did change and therefore some numbers in the flow chart might not be accurate.',
|
||||
'mailpoet',
|
||||
)}
|
||||
</p>
|
||||
</Notice>
|
||||
</div>
|
||||
)}
|
||||
<Automation context="view" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@ -0,0 +1,85 @@
|
||||
import { __ } from '@wordpress/i18n';
|
||||
import { useSelect } from '@wordpress/data';
|
||||
import { AutomationFlowSection, storeName } from '../../../store';
|
||||
import { storeName as editorStoreName } from '../../../../../../editor/store';
|
||||
import { locale } from '../../../config';
|
||||
import { Automation } from '../../../../../../editor/components/automation/types';
|
||||
|
||||
type Props = {
|
||||
previousStepId: string;
|
||||
};
|
||||
export function StatisticSeparator({
|
||||
previousStepId,
|
||||
}: Props): JSX.Element | null {
|
||||
const { section, automation } = useSelect(
|
||||
(s) =>
|
||||
({
|
||||
section: s(storeName).getSection('automation_flow'),
|
||||
automation: s(editorStoreName).getAutomationData(),
|
||||
} as {
|
||||
section: AutomationFlowSection;
|
||||
automation: Automation;
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
const { data } = section;
|
||||
if (!data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const step = automation.steps[previousStepId];
|
||||
if (step?.type === 'trigger') {
|
||||
const formattedValue = Intl.NumberFormat(locale.toString(), {
|
||||
notation: 'compact',
|
||||
}).format(data.step_data.total);
|
||||
return (
|
||||
<div
|
||||
className={`mailpoet-automation-editor-separator mailpoet-automation-analytics-separator mailpoet-automation-analytics-separator-${previousStepId}`}
|
||||
>
|
||||
<p>
|
||||
<span className="mailpoet-automation-analytics-separator-values">
|
||||
{formattedValue}
|
||||
</span>
|
||||
<span className="mailpoet-automation-analytics-separator-text">
|
||||
{
|
||||
// translators: "entered" as in "100 people have entered this automation".
|
||||
__('entered', 'mailpoet')
|
||||
}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const flow = data.step_data?.flow;
|
||||
const value = flow !== undefined ? flow[previousStepId] ?? 0 : 0;
|
||||
const percent =
|
||||
data.step_data.total > 0
|
||||
? Math.round((value / data.step_data.total) * 100)
|
||||
: 0;
|
||||
const formattedValue = Intl.NumberFormat(locale.toString(), {
|
||||
notation: 'compact',
|
||||
}).format(value);
|
||||
const formattedPercent = Intl.NumberFormat(locale.toString(), {
|
||||
style: 'percent',
|
||||
}).format(percent / 100);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`mailpoet-automation-editor-separator mailpoet-automation-analytics-separator mailpoet-automation-analytics-separator-${previousStepId}`}
|
||||
>
|
||||
<p>
|
||||
<span className="mailpoet-automation-analytics-separator-values">
|
||||
{formattedPercent} ({formattedValue})
|
||||
</span>
|
||||
<span className="mailpoet-automation-analytics-separator-text">
|
||||
{
|
||||
// translators: "completed" as in "100 people have completed this step".
|
||||
__('completed', 'mailpoet')
|
||||
}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -0,0 +1,129 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Tooltip } from '@wordpress/components';
|
||||
import { useSelect } from '@wordpress/data';
|
||||
import { __ } from '@wordpress/i18n';
|
||||
import { addQueryArgs } from '@wordpress/url';
|
||||
import { AutomationFlowSection, storeName } from '../../../store';
|
||||
import { locale } from '../../../config';
|
||||
import { Step } from '../../../../../../editor/components/automation/types';
|
||||
import { openTab } from '../../../navigation/open_tab';
|
||||
import { isTransactional } from '../../../../steps/send_email/helper/is_transactional';
|
||||
|
||||
const compactFormatter = Intl.NumberFormat(locale.toString(), {
|
||||
notation: 'compact',
|
||||
});
|
||||
const percentFormatter = Intl.NumberFormat(locale.toString(), {
|
||||
style: 'percent',
|
||||
});
|
||||
|
||||
function FailedStep({ step }: { step: Step }): JSX.Element | null {
|
||||
const { section } = useSelect(
|
||||
(s) =>
|
||||
({
|
||||
section: s(storeName).getSection('automation_flow'),
|
||||
} as {
|
||||
section: AutomationFlowSection;
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
const { data } = section;
|
||||
|
||||
const failed = data.step_data?.failed;
|
||||
const value = failed !== undefined ? failed[step.id] ?? 0 : 0;
|
||||
|
||||
const failedStats = useMemo(() => {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
const percent =
|
||||
data.step_data.total > 0
|
||||
? Math.round((value / data.step_data.total) * 100)
|
||||
: 0;
|
||||
const formattedValue = compactFormatter.format(value);
|
||||
const formattedPercent = percentFormatter.format(percent / 100);
|
||||
|
||||
return (
|
||||
<div className="mailpoet-automation-analytics-step-failed">
|
||||
<p>
|
||||
{formattedPercent} ({formattedValue})
|
||||
<span>
|
||||
{
|
||||
// translators: "failed" as in "100 automation runs failed at this step".
|
||||
__('failed', 'mailpoet')
|
||||
}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}, [data.step_data.total, value]);
|
||||
|
||||
return step.key === 'mailpoet:send-email' && !isTransactional(step) ? (
|
||||
<Tooltip
|
||||
text={__(
|
||||
'Email sending could fail if the user didn’t consent to receive marketing emails.',
|
||||
'mailpoet',
|
||||
)}
|
||||
>
|
||||
{failedStats}
|
||||
</Tooltip>
|
||||
) : (
|
||||
failedStats
|
||||
);
|
||||
}
|
||||
|
||||
export function StepFooter({ step }: { step: Step }): JSX.Element | null {
|
||||
const { section } = useSelect(
|
||||
(s) =>
|
||||
({
|
||||
section: s(storeName).getSection('automation_flow'),
|
||||
} as {
|
||||
section: AutomationFlowSection;
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
const { data } = section;
|
||||
if (!data || step.type === 'trigger') {
|
||||
return null;
|
||||
}
|
||||
const waiting = data.step_data?.waiting;
|
||||
const value = waiting !== undefined ? waiting[step.id] ?? 0 : 0;
|
||||
const percent =
|
||||
data.step_data.total > 0
|
||||
? Math.round((value / data.step_data.total) * 100)
|
||||
: 0;
|
||||
|
||||
const formattedValue = compactFormatter.format(value);
|
||||
const formattedPercent = percentFormatter.format(percent / 100);
|
||||
return (
|
||||
<>
|
||||
<FailedStep step={step} />
|
||||
<Tooltip text={__('View subscribers', 'mailpoet')}>
|
||||
<div className="mailpoet-automation-analytics-step-footer">
|
||||
<p>
|
||||
<a
|
||||
href={addQueryArgs(window.location.href, {
|
||||
tab: 'automation-subscribers',
|
||||
})}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
openTab('subscribers', {
|
||||
filters: { status: [], step: [step.id] },
|
||||
});
|
||||
}}
|
||||
>
|
||||
{formattedPercent} ({formattedValue}){' '}
|
||||
<span>
|
||||
{
|
||||
// translators: "waiting" as in "100 people are waiting for this step".
|
||||
__('waiting', 'mailpoet')
|
||||
}
|
||||
</span>
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</>
|
||||
);
|
||||
}
|
@ -0,0 +1,126 @@
|
||||
import { Tooltip } from '@wordpress/components';
|
||||
import { useSelect } from '@wordpress/data';
|
||||
import { __ } from '@wordpress/i18n';
|
||||
import { addQueryArgs } from '@wordpress/url';
|
||||
import { Step } from '../../../../../../../../editor/components/automation/types';
|
||||
import { EmailStats, OverviewSection, storeName } from '../../../../../store';
|
||||
import { locale } from '../../../../../config';
|
||||
import { formattedPrice } from '../../../../../formatter';
|
||||
import { openTab } from '../../../../../navigation/open_tab';
|
||||
import { Badge } from '../../../../email_click_badge';
|
||||
import { MailPoet } from '../../../../../../../../../mailpoet';
|
||||
|
||||
type SendEmailPanelSectionProps = {
|
||||
label: string;
|
||||
value: string | JSX.Element;
|
||||
isLoading?: boolean;
|
||||
};
|
||||
function SendEmailPanelSection({
|
||||
label,
|
||||
value,
|
||||
isLoading,
|
||||
}: SendEmailPanelSectionProps): JSX.Element {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="mailpoet-automation-analytics-send-email-panel-section is-loading" />
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="mailpoet-automation-analytics-send-email-panel-section">
|
||||
<span className="mailpoet-automation-analytics-send-email-panel-label">
|
||||
{label}
|
||||
</span>
|
||||
<span className="mailpoet-automation-analytics-send-email-panel-value">
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type SendEmailPanelProps = {
|
||||
step: Step;
|
||||
};
|
||||
export function SendEmailPanel({ step }: SendEmailPanelProps): JSX.Element {
|
||||
const { section } = useSelect(
|
||||
(s) =>
|
||||
({
|
||||
section: s(storeName).getSection('overview'),
|
||||
} as {
|
||||
section: OverviewSection;
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
const isLoading = section.data === undefined;
|
||||
|
||||
const email: EmailStats | undefined = !isLoading
|
||||
? Object.values(section.data.emails).find(
|
||||
(item) => item.id === step.args.email_id,
|
||||
)
|
||||
: undefined;
|
||||
|
||||
const sentLink =
|
||||
email === undefined ? (
|
||||
`0`
|
||||
) : (
|
||||
<Tooltip text={__('View sending status', 'mailpoet')}>
|
||||
<a href={`?page=mailpoet-newsletters#/sending-status/${email.id}`}>
|
||||
{Intl.NumberFormat(locale.toString(), {
|
||||
notation: 'compact',
|
||||
}).format(email.sent.current)}
|
||||
</a>
|
||||
</Tooltip>
|
||||
);
|
||||
return (
|
||||
<div className="mailpoet-automation-analytics-send-email-panel">
|
||||
<SendEmailPanelSection
|
||||
label={__('Sent', 'mailpoet')}
|
||||
value={sentLink}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
<SendEmailPanelSection
|
||||
label={__('Opened', 'mailpoet')}
|
||||
value={Intl.NumberFormat(locale.toString(), {
|
||||
notation: 'compact',
|
||||
}).format(email?.opened ?? 0)}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
<SendEmailPanelSection
|
||||
label={__('Clicked', 'mailpoet')}
|
||||
value={<Badge email={email} property="clicked" />}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
{MailPoet.isWoocommerceActive && (
|
||||
<>
|
||||
<hr />
|
||||
<SendEmailPanelSection
|
||||
label={__('Orders', 'mailpoet')}
|
||||
value={
|
||||
<Tooltip text={__('View orders', 'mailpoet')}>
|
||||
<a
|
||||
href={addQueryArgs(window.location.href, {
|
||||
tab: 'automation-orders',
|
||||
})}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
openTab('orders', { filters: { emails: [`${email.id}`] } });
|
||||
}}
|
||||
>
|
||||
{Intl.NumberFormat(locale.toString(), {
|
||||
notation: 'compact',
|
||||
}).format(email?.orders ?? 0)}
|
||||
</a>
|
||||
</Tooltip>
|
||||
}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
<SendEmailPanelSection
|
||||
label={__('Revenue', 'mailpoet')}
|
||||
value={formattedPrice(email?.revenue ?? 0)}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
@ -1,27 +1,11 @@
|
||||
type CellProps = {
|
||||
value: number | string | JSX.Element;
|
||||
subValue?: number | string;
|
||||
badge?: string;
|
||||
badgeType?: string;
|
||||
className?: string;
|
||||
};
|
||||
export function Cell({
|
||||
value,
|
||||
subValue,
|
||||
badge,
|
||||
badgeType,
|
||||
className,
|
||||
}: CellProps): JSX.Element {
|
||||
const badgeElement = badge ? (
|
||||
<span className="mailpoet-analytics-badge">{badge}</span>
|
||||
) : null;
|
||||
export function Cell({ value, subValue, className }: CellProps): JSX.Element {
|
||||
const mainElement = (
|
||||
<div
|
||||
className={`mailpoet-analytics-main-value ${className ?? ''} ${
|
||||
badgeType ?? ''
|
||||
}`}
|
||||
>
|
||||
{badgeElement}
|
||||
<div className={`mailpoet-analytics-main-value ${className ?? ''}`}>
|
||||
{value}
|
||||
</div>
|
||||
);
|
||||
|
@ -1,10 +1,11 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { TableCard } from '@woocommerce/components/build';
|
||||
import { TableCard } from '@woocommerce/components';
|
||||
import { useSelect } from '@wordpress/data';
|
||||
import { __ } from '@wordpress/i18n';
|
||||
import { calculateSummary } from './summary';
|
||||
import { transformEmailsToRows } from './rows';
|
||||
import { EmailStats, OverviewSection, storeName } from '../../../store';
|
||||
import { MailPoet } from '../../../../../../../mailpoet';
|
||||
|
||||
const headers = [
|
||||
{
|
||||
@ -29,18 +30,26 @@ const headers = [
|
||||
isLeftAligned: false,
|
||||
isNumeric: true,
|
||||
},
|
||||
{
|
||||
key: 'orders',
|
||||
label: __('Orders', 'mailpoet'),
|
||||
isLeftAligned: false,
|
||||
isNumeric: true,
|
||||
},
|
||||
{
|
||||
key: 'revenue',
|
||||
label: __('Revenue', 'mailpoet'),
|
||||
isLeftAligned: false,
|
||||
isNumeric: true,
|
||||
},
|
||||
];
|
||||
|
||||
if (MailPoet.isWoocommerceActive) {
|
||||
headers.push(
|
||||
{
|
||||
key: 'orders',
|
||||
label: __('Orders', 'mailpoet'),
|
||||
isLeftAligned: false,
|
||||
isNumeric: true,
|
||||
},
|
||||
{
|
||||
key: 'revenue',
|
||||
label: __('Revenue', 'mailpoet'),
|
||||
isLeftAligned: false,
|
||||
isNumeric: true,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
headers.push(
|
||||
{
|
||||
key: 'unsubscribed',
|
||||
label: __('Unsubscribed', 'mailpoet'),
|
||||
@ -51,7 +60,7 @@ const headers = [
|
||||
key: 'actions',
|
||||
label: '',
|
||||
},
|
||||
];
|
||||
);
|
||||
|
||||
export function Emails(): JSX.Element {
|
||||
const { overview } = useSelect((s) => ({
|
||||
@ -81,7 +90,6 @@ export function Emails(): JSX.Element {
|
||||
return (
|
||||
<TableCard
|
||||
title=""
|
||||
caption=""
|
||||
onQueryChange={(type: string) => (param: number) => {
|
||||
if (type === 'paged') {
|
||||
setCurrentPage(param);
|
||||
@ -103,12 +111,11 @@ export function Emails(): JSX.Element {
|
||||
);
|
||||
}
|
||||
}}
|
||||
query={{ paged: currentPage, sort: { key: 'email', direction: 'asc' } }}
|
||||
query={{ paged: currentPage, orderby: 'email', order: 'asc' }}
|
||||
rows={rows}
|
||||
headers={headers}
|
||||
showMenu={false}
|
||||
rowsPerPage={rowsPerPage}
|
||||
onRowClick={() => {}}
|
||||
totalRows={
|
||||
overview.data !== undefined
|
||||
? Object.values(overview.data.emails).length
|
||||
|
@ -1,68 +1,30 @@
|
||||
import { Tooltip } from '@wordpress/components';
|
||||
import { __, sprintf } from '@wordpress/i18n';
|
||||
import { addQueryArgs } from '@wordpress/url';
|
||||
import { EmailStats } from '../../../store';
|
||||
import { Actions } from './actions';
|
||||
import { locale } from '../../../../../../config';
|
||||
import { Cell } from './cell';
|
||||
import { formattedPrice } from '../../../formatter';
|
||||
import { openTab } from '../../../navigation/open_tab';
|
||||
import { calculatePercentage } from '../../../formatter/calculate_percentage';
|
||||
import { Badge } from '../../email_click_badge';
|
||||
import { MailPoet } from '../../../../../../../mailpoet';
|
||||
|
||||
const percentageFormatter = Intl.NumberFormat(locale.toString(), {
|
||||
style: 'percent',
|
||||
maximumFractionDigits: 2,
|
||||
});
|
||||
|
||||
function calculatePercentage(
|
||||
value: number,
|
||||
base: number,
|
||||
canBeNegative = false,
|
||||
): number {
|
||||
if (base === 0) {
|
||||
return 0;
|
||||
}
|
||||
const percentage = (value * 100) / base;
|
||||
return canBeNegative ? percentage - 100 : percentage;
|
||||
}
|
||||
|
||||
function percentageBadgeCalculation(percentage: number): {
|
||||
badge: string;
|
||||
badgeType: string;
|
||||
} {
|
||||
if (percentage > 3) {
|
||||
return {
|
||||
badge: __('Excellent', 'mailpoet'),
|
||||
badgeType: 'mailpoet-analytics-badge-success',
|
||||
};
|
||||
}
|
||||
|
||||
if (percentage > 1) {
|
||||
return {
|
||||
badge: __('Good', 'mailpoet'),
|
||||
badgeType: 'mailpoet-analytics-badge-success',
|
||||
};
|
||||
}
|
||||
return {
|
||||
badge: __('Average', 'mailpoet'),
|
||||
badgeType: 'mailpoet-analytics-badge-warning',
|
||||
};
|
||||
}
|
||||
|
||||
export function transformEmailsToRows(emails: EmailStats[]) {
|
||||
const openOrders = () => {
|
||||
const tab: HTMLButtonElement | null = document.querySelector(
|
||||
'.mailpoet-analytics-tab-orders',
|
||||
);
|
||||
tab?.click();
|
||||
};
|
||||
|
||||
return emails.map((email) => {
|
||||
// Shows the percentage of clicked emails compared to the number of sent emails
|
||||
const clickedPercentage = calculatePercentage(
|
||||
email.clicked,
|
||||
email.sent.current,
|
||||
);
|
||||
const clickedBadge = percentageBadgeCalculation(clickedPercentage);
|
||||
|
||||
return [
|
||||
const rows = [
|
||||
{
|
||||
display: (
|
||||
<Cell
|
||||
@ -117,45 +79,53 @@ export function transformEmailsToRows(emails: EmailStats[]) {
|
||||
{
|
||||
display: (
|
||||
<Cell
|
||||
value={email.clicked}
|
||||
value={<Badge email={email} property="clicked" />}
|
||||
className={
|
||||
email.sent.current > 0
|
||||
? 'mailpoet-automation-analytics-email-clicked'
|
||||
: ''
|
||||
}
|
||||
subValue={percentageFormatter.format(clickedPercentage / 100)}
|
||||
badge={email.sent.current > 0 ? clickedBadge.badge : undefined}
|
||||
badgeType={
|
||||
email.sent.current > 0 ? clickedBadge.badgeType : undefined
|
||||
}
|
||||
/>
|
||||
),
|
||||
value: email.clicked,
|
||||
},
|
||||
{
|
||||
display: (
|
||||
<Cell
|
||||
value={
|
||||
<Tooltip text={__('View orders', 'mailpoet')}>
|
||||
<a
|
||||
href="#"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
openOrders();
|
||||
}}
|
||||
>
|
||||
{`${email.orders}`}
|
||||
</a>
|
||||
</Tooltip>
|
||||
}
|
||||
/>
|
||||
),
|
||||
value: email.orders,
|
||||
},
|
||||
{
|
||||
display: <Cell value={formattedPrice(email.revenue)} />,
|
||||
value: email.revenue,
|
||||
},
|
||||
];
|
||||
|
||||
if (MailPoet.isWoocommerceActive) {
|
||||
rows.push(
|
||||
{
|
||||
display: (
|
||||
<Cell
|
||||
value={
|
||||
<Tooltip text={__('View orders', 'mailpoet')}>
|
||||
<a
|
||||
href={addQueryArgs(window.location.href, {
|
||||
tab: 'automation-orders',
|
||||
})}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
openTab('orders', {
|
||||
filters: { emails: [`${email.id}`] },
|
||||
});
|
||||
}}
|
||||
>
|
||||
{`${email.orders}`}
|
||||
</a>
|
||||
</Tooltip>
|
||||
}
|
||||
/>
|
||||
),
|
||||
value: email.orders,
|
||||
},
|
||||
{
|
||||
display: <Cell value={formattedPrice(email.revenue)} />,
|
||||
value: email.revenue,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return rows.concat([
|
||||
{
|
||||
display: <Cell value={email.unsubscribed} />,
|
||||
value: email.unsubscribed,
|
||||
@ -164,6 +134,6 @@ export function transformEmailsToRows(emails: EmailStats[]) {
|
||||
display: <Actions id={email.id} previewUrl={email.previewUrl} />,
|
||||
value: null,
|
||||
},
|
||||
];
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
@ -2,6 +2,7 @@ import { __ } from '@wordpress/i18n';
|
||||
import { locale } from '../../../../../../config';
|
||||
import { EmailStats } from '../../../store';
|
||||
import { formattedPrice } from '../../../formatter';
|
||||
import { MailPoet } from '../../../../../../../mailpoet';
|
||||
|
||||
export function calculateSummary(rows: EmailStats[]) {
|
||||
if (rows.length === 0) {
|
||||
@ -43,16 +44,20 @@ export function calculateSummary(rows: EmailStats[]) {
|
||||
label: __('clicked', 'mailpoet'),
|
||||
value: compactFormatter.format(data.clicked),
|
||||
},
|
||||
{
|
||||
label: __('orders', 'mailpoet'),
|
||||
value: compactFormatter.format(data.orders),
|
||||
},
|
||||
{ label: __('revenue', 'mailpoet'), value: formattedPrice(data.revenue) },
|
||||
{
|
||||
label: __('unsubscribed', 'mailpoet'),
|
||||
value: compactFormatter.format(data.unsubscribed),
|
||||
},
|
||||
];
|
||||
if (MailPoet.isWoocommerceActive) {
|
||||
summary.push(
|
||||
{
|
||||
label: __('orders', 'mailpoet'),
|
||||
value: compactFormatter.format(data.orders),
|
||||
},
|
||||
{ label: __('revenue', 'mailpoet'), value: formattedPrice(data.revenue) },
|
||||
);
|
||||
}
|
||||
summary.push({
|
||||
label: __('unsubscribed', 'mailpoet'),
|
||||
value: compactFormatter.format(data.unsubscribed),
|
||||
});
|
||||
|
||||
return summary;
|
||||
}
|
||||
|
@ -8,13 +8,14 @@ import { AutomationFlow } from './automation_flow';
|
||||
import { Emails } from './emails';
|
||||
import { Orders } from './orders';
|
||||
import { Subscribers } from './subscribers';
|
||||
import { storeName } from '../../store';
|
||||
import { storeName as editorStoreName } from '../../../../../editor/store/constants';
|
||||
import { MailPoet } from '../../../../../../mailpoet';
|
||||
|
||||
export function Tabs(): JSX.Element {
|
||||
const history = useHistory();
|
||||
const location = useLocation();
|
||||
const { hasEmails } = useSelect((s) => ({
|
||||
hasEmails: s(storeName).automationHasEmails(),
|
||||
hasEmails: s(editorStoreName).automationHasStep('mailpoet:send-email'),
|
||||
}));
|
||||
const pageParams = useMemo(
|
||||
() => new URLSearchParams(location.search),
|
||||
@ -34,21 +35,27 @@ export function Tabs(): JSX.Element {
|
||||
className: 'mailpoet-analytics-tab-emails',
|
||||
title: __('Emails', 'mailpoet'),
|
||||
});
|
||||
tabs.push({
|
||||
name: 'automation-orders',
|
||||
className: 'mailpoet-analytics-tab-orders',
|
||||
// title is defined as string but allows for JSX.Element
|
||||
title: (
|
||||
<>
|
||||
{_x('Orders', 'WooCommerce orders', 'mailpoet')}{' '}
|
||||
<Icon icon={lockSmall} />
|
||||
</>
|
||||
) as unknown as string,
|
||||
});
|
||||
if (MailPoet.isWoocommerceActive) {
|
||||
tabs.push({
|
||||
name: 'automation-orders',
|
||||
className: 'mailpoet-analytics-tab-orders',
|
||||
// title is defined as string but allows for JSX.Element
|
||||
title: (
|
||||
<>
|
||||
{_x('Orders', 'WooCommerce orders', 'mailpoet')}{' '}
|
||||
<Icon icon={lockSmall} />
|
||||
</>
|
||||
) as unknown as string,
|
||||
});
|
||||
}
|
||||
tabs.push({
|
||||
name: 'automation-subscribers',
|
||||
className: 'mailpoet-analytics-tab-subscribers',
|
||||
title: __('Subscribers', 'mailpoet'),
|
||||
title: (
|
||||
<>
|
||||
{__('Subscribers', 'mailpoet')} <Icon icon={lockSmall} />
|
||||
</>
|
||||
) as unknown as string,
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -1,17 +1,36 @@
|
||||
import { CustomerData } from '../../../../store';
|
||||
import { useDispatch } from '@wordpress/data';
|
||||
import { CustomerData, storeName } from '../../../../store';
|
||||
|
||||
type Props = {
|
||||
customer: CustomerData;
|
||||
isSample?: boolean;
|
||||
};
|
||||
|
||||
export function CustomerCell({
|
||||
customer,
|
||||
}: {
|
||||
customer: CustomerData;
|
||||
}): JSX.Element {
|
||||
isSample = false,
|
||||
}: Props): JSX.Element {
|
||||
const { openPremiumModalForSampleData } = useDispatch(storeName);
|
||||
|
||||
const name = [customer.first_name, customer.last_name]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
|
||||
const label = name || customer.email;
|
||||
|
||||
return (
|
||||
<a
|
||||
className="mailpoet-analytics-orders__customer"
|
||||
href={`?page=mailpoet-subscribers#/edit/${customer.id}`}
|
||||
onClick={(event) => {
|
||||
if (isSample) {
|
||||
event.preventDefault();
|
||||
void openPremiumModalForSampleData();
|
||||
}
|
||||
}}
|
||||
href={isSample ? '' : `?page=mailpoet-subscribers#/edit/${customer.id}`}
|
||||
>
|
||||
<img src={customer.avatar} alt={customer.last_name} />
|
||||
{`${customer.first_name} ${customer.last_name}`}
|
||||
<img src={customer.avatar} alt={label} width="20" />
|
||||
{label}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
@ -1,21 +1,38 @@
|
||||
import { Tooltip } from '@wordpress/components';
|
||||
import { useSelect } from '@wordpress/data';
|
||||
import { useDispatch, useSelect } from '@wordpress/data';
|
||||
import { __ } from '@wordpress/i18n';
|
||||
import { addQueryArgs } from '@wordpress/url';
|
||||
import { OrderData, storeName } from '../../../../store';
|
||||
import { MailPoet } from '../../../../../../../../mailpoet';
|
||||
import { storeName as editorStoreName } from '../../../../../../../editor/store';
|
||||
|
||||
export function EmailCell({ order }: { order: OrderData }): JSX.Element {
|
||||
type Props = {
|
||||
order: OrderData;
|
||||
isSample?: boolean;
|
||||
};
|
||||
|
||||
export function EmailCell({ order, isSample }: Props): JSX.Element {
|
||||
const { automation } = useSelect((s) => ({
|
||||
automation: s(storeName).getAutomation(),
|
||||
automation: s(editorStoreName).getAutomationData(),
|
||||
}));
|
||||
const { openPremiumModalForSampleData } = useDispatch(storeName);
|
||||
|
||||
return (
|
||||
<Tooltip text={__('View in automation', 'mailpoet')}>
|
||||
<a
|
||||
href={addQueryArgs(MailPoet.urls.automationEditor, {
|
||||
id: automation.id,
|
||||
})}
|
||||
onClick={(event) => {
|
||||
if (isSample) {
|
||||
event.preventDefault();
|
||||
void openPremiumModalForSampleData();
|
||||
}
|
||||
}}
|
||||
href={
|
||||
isSample
|
||||
? ''
|
||||
: addQueryArgs(MailPoet.urls.automationEditor, {
|
||||
id: automation.id,
|
||||
})
|
||||
}
|
||||
>
|
||||
{`${order.email.subject}`}
|
||||
</a>
|
||||
|
@ -1,11 +1,27 @@
|
||||
import { useDispatch } from '@wordpress/data';
|
||||
import { Tooltip } from '@wordpress/components';
|
||||
import { __ } from '@wordpress/i18n';
|
||||
import { OrderDetails } from '../../../../store';
|
||||
import { OrderDetails, storeName } from '../../../../store';
|
||||
|
||||
type Props = {
|
||||
order: OrderDetails;
|
||||
isSample?: boolean;
|
||||
};
|
||||
|
||||
export function OrderCell({ order, isSample = false }: Props): JSX.Element {
|
||||
const { openPremiumModalForSampleData } = useDispatch(storeName);
|
||||
|
||||
export function OrderCell({ order }: { order: OrderDetails }): JSX.Element {
|
||||
return (
|
||||
<Tooltip text={__('Order details', 'mailpoet')}>
|
||||
<a href={`post.php?post=${order.id}&action=edit`}>{`${order.id}`}</a>
|
||||
<a
|
||||
onClick={(event) => {
|
||||
if (isSample) {
|
||||
event.preventDefault();
|
||||
void openPremiumModalForSampleData();
|
||||
}
|
||||
}}
|
||||
href={isSample ? '' : `post.php?post=${order.id}&action=edit`}
|
||||
>{`${order.id}`}</a>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
@ -1,15 +1,19 @@
|
||||
import { ViewMoreList } from '@woocommerce/components/build';
|
||||
import { Fragment } from '@wordpress/element';
|
||||
import { ViewMoreList as WooViewMoreList } from '@woocommerce/components';
|
||||
import { OrderDetails } from '../../../../store';
|
||||
|
||||
// WooViewMoreList has return type annotated as Object
|
||||
const ViewMoreList = WooViewMoreList as unknown as (
|
||||
...args: Parameters<typeof WooViewMoreList>
|
||||
) => JSX.Element;
|
||||
|
||||
export function ProductsCell({ order }: { order: OrderDetails }) {
|
||||
const items =
|
||||
order.products.length > 0
|
||||
? order.products.map((item) => (
|
||||
<Fragment key={`key-${item.id}`}>
|
||||
<span key={`key-${item.id}`}>
|
||||
{item.name}
|
||||
<span className="quantity">({item.quantity}×)</span>
|
||||
</Fragment>
|
||||
</span>
|
||||
))
|
||||
: [];
|
||||
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { dispatch, useSelect } from '@wordpress/data';
|
||||
import { __ } from '@wordpress/i18n';
|
||||
import { TableCard } from '@woocommerce/components/build';
|
||||
import { MailPoet } from '../../../../../../../mailpoet';
|
||||
import { TableCard } from '@woocommerce/components';
|
||||
import { Hooks } from 'wp-js-hooks';
|
||||
import { OrderSection, storeName } from '../../../store';
|
||||
import { transformOrdersToRows } from './rows';
|
||||
import { calculateSummary } from './summary';
|
||||
@ -48,31 +48,19 @@ export function Orders(): JSX.Element {
|
||||
ordersSection: s(storeName).getSection('orders') as OrderSection,
|
||||
}));
|
||||
|
||||
const orders =
|
||||
ordersSection.data !== undefined ? ordersSection.data.items : undefined;
|
||||
const rows = transformOrdersToRows(orders);
|
||||
const orders = ordersSection?.data?.items;
|
||||
const rows = transformOrdersToRows(ordersSection?.data);
|
||||
const summary = calculateSummary(orders ?? []);
|
||||
const beforeTable = Hooks.applyFilters(
|
||||
'mailpoet_analytics_orders_before_table',
|
||||
<Upgrade />,
|
||||
) as null | JSX.Element;
|
||||
|
||||
return (
|
||||
<div className="mailpoet-analytics-orders">
|
||||
{!MailPoet.premiumActive && (
|
||||
<Upgrade
|
||||
text={
|
||||
<span>
|
||||
<strong>{__("You're viewing sample data.", 'mailpoet')}</strong>
|
||||
|
||||
{__(
|
||||
'To use data from your email activity, upgrade to a premium plan.',
|
||||
'mailpoet',
|
||||
)}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{beforeTable}
|
||||
<TableCard
|
||||
title=""
|
||||
caption=""
|
||||
onQueryChange={(type: string) => (param: unknown) => {
|
||||
let customQuery = {};
|
||||
if (type === 'paged') {
|
||||
@ -110,7 +98,6 @@ export function Orders(): JSX.Element {
|
||||
headers={headers}
|
||||
showMenu={false}
|
||||
rowsPerPage={ordersSection.customQuery.limit}
|
||||
onRowClick={() => {}}
|
||||
totalRows={
|
||||
ordersSection.data !== undefined ? ordersSection.data.results : 0
|
||||
}
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { OrderData } from '../../../store';
|
||||
import { OrderSection } from '../../../store';
|
||||
import { OrderCell } from './cells/order';
|
||||
import { CustomerCell } from './cells/customer';
|
||||
import { ProductsCell } from './cells/products';
|
||||
@ -7,7 +7,8 @@ import { OrderStatusCell } from './cells/order_status';
|
||||
import { formattedPrice } from '../../../formatter';
|
||||
import { MailPoet } from '../../../../../../../mailpoet';
|
||||
|
||||
export function transformOrdersToRows(orders: OrderData[] | undefined) {
|
||||
export function transformOrdersToRows(data: OrderSection['data'] | undefined) {
|
||||
const orders = data?.items;
|
||||
return orders === undefined
|
||||
? []
|
||||
: orders.map((order) => [
|
||||
@ -16,11 +17,15 @@ export function transformOrdersToRows(orders: OrderData[] | undefined) {
|
||||
value: order.date,
|
||||
},
|
||||
{
|
||||
display: <OrderCell order={order.details} />,
|
||||
display: (
|
||||
<OrderCell order={order.details} isSample={data?.isSample} />
|
||||
),
|
||||
value: order.details.id,
|
||||
},
|
||||
{
|
||||
display: <CustomerCell customer={order.customer} />,
|
||||
display: (
|
||||
<CustomerCell customer={order.customer} isSample={data?.isSample} />
|
||||
),
|
||||
value: order.customer.last_name,
|
||||
},
|
||||
{
|
||||
@ -28,7 +33,7 @@ export function transformOrdersToRows(orders: OrderData[] | undefined) {
|
||||
value: null,
|
||||
},
|
||||
{
|
||||
display: <EmailCell order={order} />,
|
||||
display: <EmailCell order={order} isSample={data?.isSample} />,
|
||||
value: order.email.subject,
|
||||
},
|
||||
{
|
||||
|
@ -1,23 +1,61 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Notice } from '@wordpress/components/build';
|
||||
import { Button } from '@wordpress/components';
|
||||
import { __ } from '@wordpress/i18n';
|
||||
import { addQueryArgs } from '@wordpress/url';
|
||||
import { MailPoet } from '../../../../../../../mailpoet';
|
||||
import {
|
||||
UpgradeInfo,
|
||||
useUpgradeInfo,
|
||||
} from '../../../../../../../common/premium_modal/upgrade_info';
|
||||
|
||||
function getUpgradeLink(): string {
|
||||
const utmArgs = {
|
||||
utm_source: 'plugin',
|
||||
// This duplicates some functionality of the PremiumModal component.
|
||||
// We could consider extracting it to a more reusable logic.
|
||||
type State = undefined | 'busy' | 'success' | 'error';
|
||||
|
||||
const getCta = (state: State, upgradeInfo: UpgradeInfo): string => {
|
||||
const { action, cta } = upgradeInfo;
|
||||
if (typeof action === 'string') {
|
||||
return cta;
|
||||
}
|
||||
if (state === 'busy') {
|
||||
return action.busy;
|
||||
}
|
||||
if (state === 'success') {
|
||||
return action.success;
|
||||
}
|
||||
return cta;
|
||||
};
|
||||
|
||||
export function Upgrade(): JSX.Element {
|
||||
const upgradeInfo = useUpgradeInfo({
|
||||
utm_medium: 'upsell_modal',
|
||||
utm_campaign: 'automation-analytics',
|
||||
};
|
||||
const url = MailPoet.hasValidApiKey
|
||||
? `https://account.mailpoet.com/orders/upgrade/${MailPoet.pluginPartialKey}`
|
||||
: `https://account.mailpoet.com/?s=${MailPoet.subscribersCount}&g=business&billing=monthly&email=${MailPoet.currentWpUserEmail}`;
|
||||
});
|
||||
|
||||
return addQueryArgs(url, utmArgs);
|
||||
}
|
||||
const [state, setState] = useState<State>();
|
||||
|
||||
useEffect(() => {
|
||||
setState(undefined);
|
||||
}, [upgradeInfo]);
|
||||
|
||||
const handleClick = useCallback(async () => {
|
||||
if (typeof upgradeInfo.action === 'string') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (state === 'success') {
|
||||
upgradeInfo.action.successHandler();
|
||||
return;
|
||||
}
|
||||
|
||||
setState('busy');
|
||||
try {
|
||||
await upgradeInfo.action.handler();
|
||||
setState('success');
|
||||
} catch (_) {
|
||||
setState('error');
|
||||
}
|
||||
}, [state, upgradeInfo.action]);
|
||||
|
||||
export function Upgrade({ text }: { text: string | JSX.Element }): JSX.Element {
|
||||
return (
|
||||
<Notice
|
||||
className="mailpoet-analytics-upgrade-banner"
|
||||
@ -25,11 +63,30 @@ export function Upgrade({ text }: { text: string | JSX.Element }): JSX.Element {
|
||||
isDismissible={false}
|
||||
>
|
||||
<span className="mailpoet-analytics-upgrade-banner__inner">
|
||||
{text}
|
||||
<span>
|
||||
<strong>{__("You're viewing sample data.", 'mailpoet')}</strong>{' '}
|
||||
{upgradeInfo.info}
|
||||
</span>
|
||||
|
||||
<Button href={getUpgradeLink()} isPrimary>
|
||||
{__('Upgrade', 'mailpoet')}
|
||||
</Button>
|
||||
{typeof upgradeInfo.action === 'string' ? (
|
||||
<Button
|
||||
variant="primary"
|
||||
href={upgradeInfo.action}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{upgradeInfo.cta}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleClick}
|
||||
isBusy={state === 'busy'}
|
||||
disabled={state === 'busy'}
|
||||
>
|
||||
{getCta(state, upgradeInfo)}
|
||||
</Button>
|
||||
)}
|
||||
</span>
|
||||
</Notice>
|
||||
);
|
||||
|
@ -0,0 +1,13 @@
|
||||
import { __ } from '@wordpress/i18n';
|
||||
|
||||
// Make sure this translation map is in sync with the backend in SubscriberStatistics
|
||||
export const statusMap = {
|
||||
running: __('In Progress', 'mailpoet'),
|
||||
cancelled: __('Cancelled', 'mailpoet'),
|
||||
complete: __('Completed', 'mailpoet'),
|
||||
failed: __('Failed', 'mailpoet'),
|
||||
};
|
||||
|
||||
export function StatusCell({ status }: { status: string }): JSX.Element {
|
||||
return <>{statusMap[status] ?? status}</>;
|
||||
}
|
@ -0,0 +1,49 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useSelect } from '@wordpress/data';
|
||||
import { storeName as editorStoreName } from '../../../../../../../editor/store';
|
||||
import { ColoredIcon } from '../../../../../../../editor/components/icons';
|
||||
import { Step } from '../../../../../../../editor/components/automation/types';
|
||||
import { LockedBadge } from '../../../../../../../../common/premium_modal/locked_badge';
|
||||
|
||||
export function StepCell({
|
||||
name,
|
||||
data,
|
||||
}: {
|
||||
name: string;
|
||||
data?: Step;
|
||||
}): JSX.Element {
|
||||
const { stepType } = useSelect((s) => ({
|
||||
stepType: data.key ? s(editorStoreName).getStepType(data.key) : undefined,
|
||||
}));
|
||||
|
||||
const info = useMemo(() => {
|
||||
const subtitle = stepType ? stepType.subtitle(data, 'other') : '';
|
||||
if (typeof subtitle === 'object' && subtitle.type === LockedBadge) {
|
||||
return undefined;
|
||||
}
|
||||
if (data?.key === 'mailpoet:send-email' && subtitle === name) {
|
||||
return data?.args?.subject as string | undefined;
|
||||
}
|
||||
return subtitle;
|
||||
}, [data, name, stepType]);
|
||||
|
||||
return (
|
||||
<div className="mailpoet-analytics-subscribers-step-cell">
|
||||
{stepType ? (
|
||||
<ColoredIcon
|
||||
width="12px"
|
||||
height="12px"
|
||||
background={stepType.background}
|
||||
foreground={stepType.foreground}
|
||||
icon={stepType.icon}
|
||||
/>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
<div>
|
||||
<p>{name}</p>
|
||||
{info && <span>{info}</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -1,3 +1,98 @@
|
||||
import { dispatch, useSelect } from '@wordpress/data';
|
||||
import { __ } from '@wordpress/i18n';
|
||||
import { TableCard } from '@woocommerce/components';
|
||||
import { Hooks } from 'wp-js-hooks';
|
||||
import { storeName, SubscriberSection } from '../../../store';
|
||||
import { transformSubscribersToRows } from './rows';
|
||||
import { Upgrade } from '../orders/upgrade';
|
||||
|
||||
const headers = [
|
||||
{
|
||||
key: 'last_name',
|
||||
isSortable: true,
|
||||
label: __('Subscriber', 'mailpoet'),
|
||||
},
|
||||
{
|
||||
key: 'step',
|
||||
isSortable: true,
|
||||
label: __('Automation step', 'mailpoet'),
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
isSortable: true,
|
||||
label: __('Status', 'mailpoet'),
|
||||
},
|
||||
{
|
||||
key: 'updated_at',
|
||||
isSortable: true,
|
||||
label: __('Updated on', 'mailpoet'),
|
||||
},
|
||||
];
|
||||
|
||||
export function Subscribers(): JSX.Element {
|
||||
return <p>Subscribers</p>;
|
||||
const { subscriberSection } = useSelect((s) => ({
|
||||
subscriberSection: s(storeName).getSection(
|
||||
'subscribers',
|
||||
) as SubscriberSection,
|
||||
}));
|
||||
|
||||
const rows = transformSubscribersToRows(subscriberSection.data);
|
||||
|
||||
const beforeTable = Hooks.applyFilters(
|
||||
'mailpoet_analytics_subscribers_before_table',
|
||||
<Upgrade />,
|
||||
) as null | JSX.Element;
|
||||
|
||||
return (
|
||||
<div className="mailpoet-analytics-subscribers">
|
||||
{beforeTable}
|
||||
|
||||
<TableCard
|
||||
title=""
|
||||
onQueryChange={(type: string) => (param: unknown) => {
|
||||
let customQuery = {};
|
||||
if (type === 'paged') {
|
||||
customQuery = { page: param };
|
||||
} else if (type === 'per_page') {
|
||||
customQuery = {
|
||||
page: 1,
|
||||
limit: param,
|
||||
};
|
||||
} else if (type === 'sort') {
|
||||
customQuery = {
|
||||
page: 1,
|
||||
order_by: param,
|
||||
order:
|
||||
subscriberSection.customQuery.order_by === param &&
|
||||
subscriberSection.customQuery.order === 'asc'
|
||||
? 'desc'
|
||||
: 'asc',
|
||||
};
|
||||
}
|
||||
dispatch(storeName).updateSection({
|
||||
...subscriberSection,
|
||||
customQuery: {
|
||||
...subscriberSection.customQuery,
|
||||
...customQuery,
|
||||
},
|
||||
});
|
||||
}}
|
||||
query={{
|
||||
paged: subscriberSection.customQuery.page,
|
||||
orderby: subscriberSection.customQuery.order_by,
|
||||
order: subscriberSection.customQuery.order,
|
||||
}}
|
||||
rows={rows}
|
||||
headers={headers}
|
||||
showMenu={false}
|
||||
rowsPerPage={subscriberSection.customQuery.limit}
|
||||
totalRows={
|
||||
subscriberSection.data !== undefined
|
||||
? subscriberSection.data.results
|
||||
: 0
|
||||
}
|
||||
isLoading={subscriberSection.data?.items === undefined}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -0,0 +1,39 @@
|
||||
import { SubscriberSection } from '../../../store';
|
||||
import { CustomerCell } from '../orders/cells/customer';
|
||||
import { MailPoet } from '../../../../../../../mailpoet';
|
||||
import { StatusCell } from './cells/status';
|
||||
import { StepCell } from './cells/step';
|
||||
|
||||
export function transformSubscribersToRows(data: SubscriberSection['data']) {
|
||||
const subscribers = data?.items;
|
||||
return subscribers === undefined
|
||||
? []
|
||||
: subscribers.map((subscriber) => [
|
||||
{
|
||||
display: (
|
||||
<CustomerCell
|
||||
customer={subscriber.subscriber}
|
||||
isSample={data.isSample}
|
||||
/>
|
||||
),
|
||||
value: subscriber.subscriber.last_name,
|
||||
},
|
||||
{
|
||||
display: (
|
||||
<StepCell
|
||||
name={subscriber.run.step.name}
|
||||
data={data.steps[subscriber.run.step.id]}
|
||||
/>
|
||||
),
|
||||
value: subscriber.run.step.name,
|
||||
},
|
||||
{
|
||||
display: <StatusCell status={subscriber.run.status} />,
|
||||
value: subscriber.run.status,
|
||||
},
|
||||
{
|
||||
display: MailPoet.Date.format(new Date(subscriber.date)),
|
||||
value: subscriber.date,
|
||||
},
|
||||
]);
|
||||
}
|
@ -0,0 +1,11 @@
|
||||
export function calculatePercentage(
|
||||
value: number,
|
||||
base: number,
|
||||
canBeNegative = false,
|
||||
): number {
|
||||
if (base === 0) {
|
||||
return 0;
|
||||
}
|
||||
const percentage = (value * 100) / base;
|
||||
return canBeNegative ? percentage - 100 : percentage;
|
||||
}
|
@ -1,4 +1,4 @@
|
||||
import CurrencyFactory from '@woocommerce/currency/build';
|
||||
import CurrencyFactory from '@woocommerce/currency';
|
||||
import { MailPoet } from '../../../../../mailpoet';
|
||||
|
||||
export function formattedPrice(price: number): string {
|
||||
|
@ -1,21 +1,39 @@
|
||||
import ReactDOM from 'react-dom';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import { dispatch, select } from '@wordpress/data';
|
||||
import { dispatch, select, useSelect } from '@wordpress/data';
|
||||
import { TopBarWithBeamer } from '../../../../common/top_bar/top_bar';
|
||||
import { Notices } from '../../../listing/components/notices';
|
||||
import { Header } from './components/header';
|
||||
import { Overview } from './components/overview';
|
||||
import { Tabs } from './components/tabs';
|
||||
import { createStore, Section, storeName } from './store';
|
||||
import { createStore as editorStoreCreate } from '../../../editor/store';
|
||||
import { registerApiErrorHandler } from '../../../listing/api-error-handler';
|
||||
import { initializeApi } from './api';
|
||||
import { initialize as initializeCoreIntegration } from '../../core';
|
||||
import { initialize as initializeMailPoetIntegration } from '../index';
|
||||
import { initialize as initializeWooCommerceIntegration } from '../../woocommerce';
|
||||
import { PremiumModal } from '../../../../common/premium_modal';
|
||||
|
||||
function Analytics(): JSX.Element {
|
||||
const premiumModal = useSelect((s) => s(storeName).getPremiumModal());
|
||||
const { closePremiumModal } = dispatch(storeName);
|
||||
|
||||
return (
|
||||
<div className="mailpoet-automation-analytics">
|
||||
<Header />
|
||||
<Overview />
|
||||
<Tabs />
|
||||
{premiumModal && (
|
||||
<PremiumModal
|
||||
onRequestClose={closePremiumModal}
|
||||
tracking={{
|
||||
utm_campaign: premiumModal.utmCampaign ?? 'automation_analytics',
|
||||
}}
|
||||
>
|
||||
{premiumModal.content}
|
||||
</PremiumModal>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -45,6 +63,10 @@ window.addEventListener('DOMContentLoaded', () => {
|
||||
return;
|
||||
}
|
||||
createStore();
|
||||
editorStoreCreate();
|
||||
initializeCoreIntegration();
|
||||
initializeMailPoetIntegration();
|
||||
initializeWooCommerceIntegration();
|
||||
registerApiErrorHandler();
|
||||
boot();
|
||||
ReactDOM.render(<App />, root);
|
||||
|
@ -0,0 +1,31 @@
|
||||
import { dispatch, select } from '@wordpress/data';
|
||||
import { CurrentView, storeName } from '../store';
|
||||
|
||||
type ValidTabs = 'automation-flow' | 'emails' | 'orders' | 'subscribers';
|
||||
export function openTab(tab: ValidTabs, currentView?: CurrentView): void {
|
||||
if (currentView) {
|
||||
const section = select(storeName).getSection(tab);
|
||||
const payload = {
|
||||
...section,
|
||||
customQuery: {
|
||||
...section.customQuery,
|
||||
filter: {
|
||||
...currentView.filters,
|
||||
},
|
||||
},
|
||||
currentView,
|
||||
};
|
||||
dispatch(storeName).updateSection(payload);
|
||||
}
|
||||
|
||||
const classMap: Record<ValidTabs, string> = {
|
||||
'automation-flow': 'mailpoet-analytics-tab-flow',
|
||||
emails: 'mailpoet-analytics-tab-emails',
|
||||
orders: 'mailpoet-analytics-tab-orders',
|
||||
subscribers: 'mailpoet-analytics-tab-subscribers',
|
||||
};
|
||||
const tabElement: HTMLButtonElement | null = document.querySelector(
|
||||
`.${classMap[tab]}`,
|
||||
);
|
||||
tabElement?.click();
|
||||
}
|
@ -1,9 +1,13 @@
|
||||
import { dispatch, select } from '@wordpress/data';
|
||||
import { getCurrentDates } from '@woocommerce/date';
|
||||
import { __ } from '@wordpress/i18n';
|
||||
import { addQueryArgs } from '@wordpress/url';
|
||||
import { apiFetch } from '@wordpress/data-controls';
|
||||
import { Query, Section, SectionData } from '../types';
|
||||
import { Hooks } from 'wp-js-hooks';
|
||||
import { CurrentView, Query, Section, SectionData } from '../types';
|
||||
import { storeName } from '../constants';
|
||||
import { getSampleData } from '../samples';
|
||||
import { storeName as editorStoreName } from '../../../../../editor/store/constants';
|
||||
|
||||
export function setQuery(query: Query) {
|
||||
const sections = select(storeName).getSections();
|
||||
@ -34,66 +38,106 @@ export function resetSectionData(section: Section) {
|
||||
};
|
||||
}
|
||||
|
||||
export function* updateSection(
|
||||
section: Section,
|
||||
queryParam: Query | undefined = undefined,
|
||||
) {
|
||||
dispatch(storeName).resetSectionData(section);
|
||||
const query = queryParam ?? select(storeName).getCurrentQuery();
|
||||
const defaultDateRange = 'period=month&compare=previous_year';
|
||||
|
||||
const { primary: primaryDate, secondary: secondaryDate } = getCurrentDates(
|
||||
query,
|
||||
defaultDateRange,
|
||||
);
|
||||
|
||||
const formatDate = (date: Date, endOfDay = false): string => {
|
||||
const dateString = `${date.getFullYear()}-${
|
||||
date.getMonth() < 9 ? '0' : ''
|
||||
}${date.getMonth() + 1}-${date.getDate() < 10 ? '0' : ''}${date.getDate()}`;
|
||||
const newDate = new Date(
|
||||
`${dateString}T${endOfDay ? '23:59:59.999' : '00:00:00.000'}Z`,
|
||||
);
|
||||
return newDate.toISOString();
|
||||
};
|
||||
|
||||
const dates = section.withPreviousData
|
||||
? {
|
||||
primary: {
|
||||
after: formatDate(primaryDate.after.toDate()),
|
||||
before: formatDate(primaryDate.before.toDate(), true),
|
||||
},
|
||||
secondary: {
|
||||
after: formatDate(secondaryDate.after.toDate()),
|
||||
before: formatDate(secondaryDate.before.toDate(), true),
|
||||
},
|
||||
}
|
||||
: {
|
||||
primary: {
|
||||
after: formatDate(primaryDate.after.toDate()),
|
||||
before: formatDate(primaryDate.before.toDate(), true),
|
||||
},
|
||||
};
|
||||
const id = select(storeName).getAutomation().id;
|
||||
|
||||
const customQuery = section.customQuery ?? {};
|
||||
|
||||
const args = { id, query: { ...dates, ...customQuery } };
|
||||
const path = addQueryArgs(section.endpoint, args);
|
||||
const method = 'GET';
|
||||
const response: {
|
||||
data: SectionData;
|
||||
} = yield apiFetch({
|
||||
path,
|
||||
method,
|
||||
});
|
||||
|
||||
export function updateCurrentView(sectionId: string, currentView: CurrentView) {
|
||||
const currentSection = select(storeName).getSection(sectionId);
|
||||
const payload = {
|
||||
...section,
|
||||
data: response?.data || undefined,
|
||||
...currentSection,
|
||||
currentView,
|
||||
};
|
||||
return {
|
||||
type: 'SET_SECTION_DATA',
|
||||
payload,
|
||||
};
|
||||
}
|
||||
|
||||
export function* updateSection(
|
||||
section: Section,
|
||||
queryParam: Query | undefined = undefined,
|
||||
) {
|
||||
dispatch(storeName).resetSectionData(section);
|
||||
|
||||
const sampleData = Hooks.applyFilters(
|
||||
'mailpoet_analytics_section_sample_data',
|
||||
getSampleData(section.id),
|
||||
section.id,
|
||||
) as SectionData;
|
||||
|
||||
if (sampleData) {
|
||||
return {
|
||||
type: 'SET_SECTION_DATA',
|
||||
payload: { ...section, data: sampleData },
|
||||
};
|
||||
}
|
||||
|
||||
const formatDate = (date: Date, endOfDay = false): string => {
|
||||
const newDate = new Date(date.getTime());
|
||||
if (endOfDay) {
|
||||
newDate.setUTCHours(23, 59, 59, 999);
|
||||
} else {
|
||||
newDate.setUTCHours(0, 0, 0, 0);
|
||||
}
|
||||
return newDate.toISOString();
|
||||
};
|
||||
|
||||
const query = queryParam ?? select(storeName).getCurrentQuery();
|
||||
const defaultDateRange = 'period=month&compare=previous_year';
|
||||
const { primary: primaryDate, secondary: secondaryDate } = getCurrentDates(
|
||||
query,
|
||||
defaultDateRange,
|
||||
);
|
||||
|
||||
const dates = {
|
||||
primary: {
|
||||
after: formatDate(primaryDate.after.toDate()),
|
||||
before: formatDate(primaryDate.before.toDate(), true),
|
||||
},
|
||||
...(section.withPreviousData
|
||||
? {
|
||||
secondary: {
|
||||
after: formatDate(secondaryDate.after.toDate()),
|
||||
before: formatDate(secondaryDate.before.toDate(), true),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
|
||||
const id = select(editorStoreName).getAutomationData().id;
|
||||
const customQuery = section.customQuery ?? {};
|
||||
const args = { id, query: { ...dates, ...customQuery } };
|
||||
|
||||
const response: { data: SectionData } = yield apiFetch({
|
||||
path: addQueryArgs(section.endpoint, args),
|
||||
method: 'GET',
|
||||
});
|
||||
|
||||
if (section?.updateCallback) {
|
||||
section.updateCallback(response?.data);
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'SET_SECTION_DATA',
|
||||
payload: { ...section, data: response?.data },
|
||||
};
|
||||
}
|
||||
|
||||
export function openPremiumModal(content: JSX.Element, utmCampaign?: string) {
|
||||
return {
|
||||
type: 'OPEN_PREMIUM_MODAL',
|
||||
content,
|
||||
utmCampaign,
|
||||
};
|
||||
}
|
||||
|
||||
export function openPremiumModalForSampleData() {
|
||||
return {
|
||||
type: 'OPEN_PREMIUM_MODAL',
|
||||
content: __("You're viewing sample data.", 'mailpoet'),
|
||||
utmCampaign: 'automation_analytics_sample_data',
|
||||
};
|
||||
}
|
||||
|
||||
export function closePremiumModal() {
|
||||
return {
|
||||
type: 'CLOSE_PREMIUM_MODAL',
|
||||
};
|
||||
}
|
||||
|
@ -1,33 +1,80 @@
|
||||
import { dispatch } from '@wordpress/data';
|
||||
import { __ } from '@wordpress/i18n';
|
||||
import { AutomationAnalyticsWindow, State } from './types';
|
||||
import { Section, State } from './types';
|
||||
import { storeName as editorStoreName } from '../../../../editor/store';
|
||||
import { MailPoet } from '../../../../../mailpoet';
|
||||
|
||||
declare let window: AutomationAnalyticsWindow;
|
||||
|
||||
export const getInitialState = (): State => ({
|
||||
automation: window.mailpoet_automation,
|
||||
sections: {
|
||||
overview: {
|
||||
id: 'overview',
|
||||
name: __('Overview', 'mailpoet'),
|
||||
data: undefined,
|
||||
customQuery: undefined,
|
||||
withPreviousData: true,
|
||||
endpoint: '/automation/analytics/overview',
|
||||
},
|
||||
orders: {
|
||||
id: 'orders',
|
||||
name: __('Orders', 'mailpoet'),
|
||||
data: undefined,
|
||||
customQuery: {
|
||||
order: 'asc',
|
||||
order_by: 'created_at',
|
||||
limit: 25,
|
||||
page: 1,
|
||||
},
|
||||
withPreviousData: false,
|
||||
endpoint: '/automation/analytics/orders',
|
||||
const sections: Record<string, Section> = {
|
||||
automation_flow: {
|
||||
id: 'automation_flow',
|
||||
name: __('Automation flow', 'mailpoet'),
|
||||
data: undefined,
|
||||
withPreviousData: false,
|
||||
endpoint: '/automation/analytics/automation_flow',
|
||||
updateCallback: (data): void => {
|
||||
if (!data || !data?.automation) {
|
||||
return;
|
||||
}
|
||||
const { automation } = data;
|
||||
dispatch(editorStoreName).updateAutomation(automation);
|
||||
},
|
||||
},
|
||||
overview: {
|
||||
id: 'overview',
|
||||
name: __('Overview', 'mailpoet'),
|
||||
data: undefined,
|
||||
withPreviousData: true,
|
||||
endpoint: '/automation/analytics/overview',
|
||||
},
|
||||
subscribers: {
|
||||
id: 'subscribers',
|
||||
name: __('Subscribers', 'mailpoet'),
|
||||
data: undefined,
|
||||
currentView: {
|
||||
search: '',
|
||||
filters: {
|
||||
step: [],
|
||||
status: [],
|
||||
},
|
||||
},
|
||||
customQuery: {
|
||||
order: 'asc',
|
||||
order_by: 'updated_at',
|
||||
limit: 25,
|
||||
page: 1,
|
||||
filter: undefined,
|
||||
search: undefined,
|
||||
},
|
||||
withPreviousData: false,
|
||||
endpoint: '/automation/analytics/subscribers',
|
||||
},
|
||||
};
|
||||
|
||||
if (MailPoet.isWoocommerceActive) {
|
||||
sections.orders = {
|
||||
id: 'orders',
|
||||
name: __('Orders', 'mailpoet'),
|
||||
data: undefined,
|
||||
currentView: {
|
||||
filters: {
|
||||
emails: [],
|
||||
},
|
||||
},
|
||||
customQuery: {
|
||||
order: 'asc',
|
||||
order_by: 'created_at',
|
||||
limit: 25,
|
||||
page: 1,
|
||||
filter: undefined,
|
||||
search: undefined,
|
||||
},
|
||||
withPreviousData: false,
|
||||
endpoint: '/automation/analytics/orders',
|
||||
};
|
||||
}
|
||||
|
||||
export const getInitialState = (): State => ({
|
||||
sections,
|
||||
query: {
|
||||
compare: 'previous_period',
|
||||
period: 'quarter',
|
||||
|
@ -15,6 +15,19 @@ export function reducer(state: State, action): State {
|
||||
[action.payload.id]: action.payload,
|
||||
},
|
||||
};
|
||||
case 'OPEN_PREMIUM_MODAL':
|
||||
return {
|
||||
...state,
|
||||
premiumModal: {
|
||||
content: action.content,
|
||||
utmCampaign: action.utmCampaign,
|
||||
},
|
||||
};
|
||||
case 'CLOSE_PREMIUM_MODAL':
|
||||
return {
|
||||
...state,
|
||||
premiumModal: undefined,
|
||||
};
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
|
@ -0,0 +1,14 @@
|
||||
import { orders } from './orders';
|
||||
import { subscribers } from './subscribers';
|
||||
import { SectionData } from '../types';
|
||||
|
||||
export const getSampleData = (sectionId: string): SectionData | undefined => {
|
||||
switch (sectionId) {
|
||||
case 'orders':
|
||||
return orders;
|
||||
case 'subscribers':
|
||||
return subscribers;
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
};
|
@ -0,0 +1,124 @@
|
||||
import { __ } from '@wordpress/i18n';
|
||||
import { OrderSection } from '../types';
|
||||
|
||||
const year = new Date().getFullYear();
|
||||
const month = new Date().getMonth();
|
||||
const datePrefix = `${year}-${month.toString().padStart(2, '0')}`;
|
||||
|
||||
const emptyAvatarUrl =
|
||||
'https://www.gravatar.com/avatar/205e460b479e2e5b48aec07710c08d50?s=40&d=mp&r=g&f=y';
|
||||
|
||||
const products = {
|
||||
// translators: a sample product name
|
||||
mug: { id: 1, name: __('Mug', 'mailpoet'), quantity: 2 }, // 19.99
|
||||
|
||||
// translators: a sample product name
|
||||
cup: { id: 2, name: __('Cup', 'mailpoet'), quantity: 1 }, // 14.5
|
||||
|
||||
// translators: a sample product name
|
||||
socks: { id: 3, name: __('Funny socks', 'mailpoet'), quantity: 1 }, // 9.99
|
||||
|
||||
// translators: a sample product name
|
||||
magnet: { id: 4, name: __('Branded magnet', 'mailpoet'), quantity: 1 }, // 3.99
|
||||
|
||||
// translators: a sample product name
|
||||
pens: { id: 5, name: __('Pens 10x', 'mailpoet'), quantity: 1 }, // 7.50
|
||||
|
||||
// translators: a sample product name
|
||||
bottle: { id: 6, name: __('Thermo bottle', 'mailpoet'), quantity: 1 }, // 25
|
||||
|
||||
// translators: a sample product name
|
||||
subscription: { id: 7, name: __('Subscription', 'mailpoet'), quantity: 1 }, // 12.99
|
||||
} as const;
|
||||
|
||||
const subjects = {
|
||||
// translators: a sample abandoned cart email subject
|
||||
abandonedCart: __('Did you forget something?', 'mailpoet'),
|
||||
|
||||
// translators: a sample email subject
|
||||
holidaySale: __('Holiday Sale!', 'mailpoet'),
|
||||
} as const;
|
||||
|
||||
export const orders: OrderSection['data'] = {
|
||||
isSample: true,
|
||||
results: 4,
|
||||
items: [
|
||||
{
|
||||
date: `${datePrefix}-26T14:22:02.000Z`,
|
||||
email: { id: 1, subject: subjects.abandonedCart },
|
||||
customer: {
|
||||
id: 1,
|
||||
email: 'sue.shei@email.com',
|
||||
first_name: 'Sue',
|
||||
last_name: 'Shei',
|
||||
avatar: emptyAvatarUrl,
|
||||
},
|
||||
details: {
|
||||
id: 543,
|
||||
status: { id: 'completed', name: __('Completed', 'mailpoet') },
|
||||
total: 61.46,
|
||||
products: [
|
||||
products.mug,
|
||||
products.socks,
|
||||
products.magnet,
|
||||
products.pens,
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
date: `${datePrefix}-22T07:13:11.000Z`,
|
||||
email: { id: 2, subject: subjects.holidaySale },
|
||||
customer: {
|
||||
id: 2,
|
||||
email: 'jim.sechen@email.com',
|
||||
first_name: null,
|
||||
last_name: null,
|
||||
avatar: emptyAvatarUrl,
|
||||
},
|
||||
details: {
|
||||
id: 498,
|
||||
status: {
|
||||
id: 'pending-payment',
|
||||
name: __('Pending payment', 'mailpoet'),
|
||||
},
|
||||
total: 12.99,
|
||||
products: [products.subscription],
|
||||
},
|
||||
},
|
||||
{
|
||||
date: `${datePrefix}-16T19:07:44.000Z`,
|
||||
email: { id: 1, subject: subjects.abandonedCart },
|
||||
customer: {
|
||||
id: 3,
|
||||
email: 'caspian.meringue@email.com',
|
||||
first_name: 'Caspian',
|
||||
last_name: 'Meringue',
|
||||
avatar: emptyAvatarUrl,
|
||||
},
|
||||
details: {
|
||||
id: 486,
|
||||
status: { id: 'on-hold', name: __('On hold', 'mailpoet') },
|
||||
total: 14.5,
|
||||
products: [products.cup],
|
||||
},
|
||||
},
|
||||
{
|
||||
date: `${datePrefix}-11T23:52:18.000Z`,
|
||||
email: { id: 1, subject: subjects.abandonedCart },
|
||||
customer: {
|
||||
id: 4,
|
||||
email: 'natalya.fant@email.com',
|
||||
first_name: 'Natalya',
|
||||
last_name: 'Fant',
|
||||
avatar: emptyAvatarUrl,
|
||||
},
|
||||
details: {
|
||||
id: 481,
|
||||
status: { id: 'processing', name: __('Processing', 'mailpoet') },
|
||||
total: 32.5,
|
||||
products: [products.socks, products.bottle],
|
||||
},
|
||||
},
|
||||
],
|
||||
emails: [],
|
||||
};
|
@ -0,0 +1,111 @@
|
||||
import { __ } from '@wordpress/i18n';
|
||||
import { SubscriberSection } from '../types';
|
||||
import { statusMap } from '../../components/tabs/subscribers/cells/status';
|
||||
|
||||
const year = new Date().getFullYear();
|
||||
const month = new Date().getMonth();
|
||||
const datePrefix = `${year}-${(month + 1).toString().padStart(2, '0')}`;
|
||||
|
||||
const emptyAvatarUrl =
|
||||
'https://www.gravatar.com/avatar/205e460b479e2e5b48aec07710c08d50?s=40&d=mp&r=g&f=y';
|
||||
|
||||
const subjects = {
|
||||
// translators: a sample abandoned cart email subject
|
||||
abandonedCart: __('Did you forget something?', 'mailpoet'),
|
||||
|
||||
// translators: a sample email subject
|
||||
holidaySale: __('Holiday Sale!', 'mailpoet'),
|
||||
} as const;
|
||||
|
||||
export const subscribers: SubscriberSection['data'] = {
|
||||
isSample: true,
|
||||
results: 4,
|
||||
items: [
|
||||
{
|
||||
date: `${datePrefix}-26T14:22:02.000Z`,
|
||||
subscriber: {
|
||||
id: 1,
|
||||
email: 'kathlin.nelson@email.com',
|
||||
first_name: 'Kathlin',
|
||||
last_name: 'Nelson',
|
||||
avatar: emptyAvatarUrl,
|
||||
},
|
||||
run: {
|
||||
id: 1,
|
||||
status: statusMap.complete,
|
||||
step: { id: 'send-email', name: __('Send email', 'mailpoet') },
|
||||
},
|
||||
},
|
||||
{
|
||||
date: `${datePrefix}-26T14:22:02.000Z`,
|
||||
subscriber: {
|
||||
id: 2,
|
||||
email: 'eric.borgol@email.com',
|
||||
first_name: 'Eric',
|
||||
last_name: 'Borgol',
|
||||
avatar: emptyAvatarUrl,
|
||||
},
|
||||
run: {
|
||||
id: 2,
|
||||
status: statusMap.running,
|
||||
step: { id: 'delay', name: __('Delay', 'mailpoet') },
|
||||
},
|
||||
},
|
||||
{
|
||||
date: `${datePrefix}-26T14:22:02.000Z`,
|
||||
subscriber: {
|
||||
id: 3,
|
||||
email: 'elainelu@email.com',
|
||||
first_name: null,
|
||||
last_name: null,
|
||||
avatar: emptyAvatarUrl,
|
||||
},
|
||||
run: {
|
||||
id: 3,
|
||||
status: statusMap.complete,
|
||||
step: { id: 'send-email', name: __('Send email', 'mailpoet') },
|
||||
},
|
||||
},
|
||||
{
|
||||
date: `${datePrefix}-26T14:22:02.000Z`,
|
||||
subscriber: {
|
||||
id: 4,
|
||||
email: 'brian.nelson@email.com',
|
||||
first_name: 'Brian',
|
||||
last_name: 'Norman',
|
||||
avatar: emptyAvatarUrl,
|
||||
},
|
||||
run: {
|
||||
id: 4,
|
||||
status: statusMap.complete,
|
||||
step: {
|
||||
id: 'update-subscriber',
|
||||
name: __('Update subscriber', 'mailpoet'),
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
steps: {
|
||||
'send-email': {
|
||||
id: 'send-email',
|
||||
type: 'action',
|
||||
key: 'mailpoet:send-email',
|
||||
args: { subject: subjects.abandonedCart },
|
||||
next_steps: [],
|
||||
},
|
||||
delay: {
|
||||
id: 'delay',
|
||||
type: 'action',
|
||||
key: 'core:delay',
|
||||
args: { delay: 2, delay_type: 'WEEKS' },
|
||||
next_steps: [],
|
||||
},
|
||||
'update-subscriber': {
|
||||
id: 'update-subscriber',
|
||||
type: 'action',
|
||||
key: 'mailpoet:update-subscriber',
|
||||
args: {},
|
||||
next_steps: [],
|
||||
},
|
||||
},
|
||||
};
|
@ -12,13 +12,6 @@ export function getSection(state: State, id: string): Section | undefined {
|
||||
return state.sections[id] ?? undefined;
|
||||
}
|
||||
|
||||
export function getAutomation(state: State) {
|
||||
return state.automation;
|
||||
}
|
||||
|
||||
export function automationHasEmails(state: State): boolean {
|
||||
const emailSteps = Object.values(state.automation.steps).filter(
|
||||
(step) => step.key === 'mailpoet:send-email',
|
||||
);
|
||||
return emailSteps.length > 0;
|
||||
export function getPremiumModal(state: State): State['premiumModal'] {
|
||||
return state.premiumModal;
|
||||
}
|
||||
|
@ -1,12 +1,7 @@
|
||||
import { AutomationStatus } from '../../../../listing/automation';
|
||||
import { Step } from '../../../../editor/components/automation/types';
|
||||
|
||||
export type Automation = {
|
||||
id: number;
|
||||
name: string;
|
||||
status: AutomationStatus;
|
||||
steps: Record<string, Step>;
|
||||
};
|
||||
import {
|
||||
Automation,
|
||||
Step,
|
||||
} from '../../../../editor/components/automation/types';
|
||||
|
||||
export type CurrentAndPrevious = {
|
||||
current: number;
|
||||
@ -43,15 +38,24 @@ type CustomQuery = {
|
||||
order_by: string;
|
||||
limit: number;
|
||||
page: number;
|
||||
filter: Record<string, string[]> | undefined;
|
||||
search: string | undefined;
|
||||
};
|
||||
|
||||
export type CurrentView = {
|
||||
filters: Record<string, string[]>;
|
||||
search?: string;
|
||||
};
|
||||
|
||||
export type Section = {
|
||||
id: string;
|
||||
name: string;
|
||||
endpoint: string;
|
||||
customQuery: CustomQuery | undefined;
|
||||
customQuery?: CustomQuery;
|
||||
currentView?: CurrentView;
|
||||
withPreviousData: boolean;
|
||||
data: undefined | SectionData;
|
||||
updateCallback?: (data: SectionData | undefined) => void;
|
||||
};
|
||||
|
||||
export type OverviewSection = Section & {
|
||||
@ -101,17 +105,81 @@ export type OrderData = {
|
||||
type OrderSectionData = SectionData & {
|
||||
results: number;
|
||||
items: OrderData[];
|
||||
emails: {
|
||||
id: string;
|
||||
name: string;
|
||||
}[];
|
||||
isSample?: boolean;
|
||||
};
|
||||
|
||||
export type OrderSection = Section & {
|
||||
data: undefined | OrderSectionData;
|
||||
};
|
||||
export type State = {
|
||||
automation: Automation;
|
||||
sections: Record<string, Section>;
|
||||
query: Query;
|
||||
currentView: {
|
||||
filters: {
|
||||
emails: string[];
|
||||
};
|
||||
};
|
||||
updateCallback: () => void;
|
||||
};
|
||||
|
||||
export type AutomationAnalyticsWindow = {
|
||||
mailpoet_automation: Automation;
|
||||
export type SubscriberData = {
|
||||
date: string;
|
||||
subscriber: {
|
||||
id: number;
|
||||
email: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
avatar: string;
|
||||
};
|
||||
run: {
|
||||
id: number;
|
||||
status: string;
|
||||
step: {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
type SubscriberSectionData = SectionData & {
|
||||
results: number;
|
||||
items: SubscriberData[];
|
||||
steps: Record<string, Step>;
|
||||
isSample?: boolean;
|
||||
};
|
||||
|
||||
export type SubscriberSection = Section & {
|
||||
data: undefined | SubscriberSectionData;
|
||||
currentView: {
|
||||
search: string;
|
||||
filters: {
|
||||
step: string[];
|
||||
status: string[];
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
export type StepFlowData = {
|
||||
total: number;
|
||||
waiting: Record<string, number> | undefined;
|
||||
failed: Record<string, number> | undefined;
|
||||
flow: Record<string, number> | undefined;
|
||||
};
|
||||
|
||||
export type AutomationFlowSectionData = SectionData & {
|
||||
automation: Automation;
|
||||
step_data: StepFlowData;
|
||||
tree_is_inconsistent: boolean;
|
||||
};
|
||||
|
||||
export type AutomationFlowSection = Section & {
|
||||
data: undefined | AutomationFlowSectionData;
|
||||
};
|
||||
export type State = {
|
||||
sections: Record<string, Section>;
|
||||
query: Query;
|
||||
premiumModal?: {
|
||||
content: string | JSX.Element;
|
||||
utmCampaign?: string;
|
||||
};
|
||||
};
|
||||
|
@ -8,6 +8,7 @@ type Segment = FormTokenItem & {
|
||||
|
||||
export type Context = {
|
||||
segments?: Segment[];
|
||||
userRoles?: FormTokenItem[];
|
||||
};
|
||||
|
||||
export const getContext = (): Context =>
|
||||
|
@ -31,11 +31,11 @@ export function EditNewsletter(): JSX.Element {
|
||||
useState(false);
|
||||
const [fetchingPreviewLink, setFetchingPreviewLink] = useState(false);
|
||||
|
||||
const { selectedStep, automationId, automationSaved, errors } = useSelect(
|
||||
const { selectedStep, automationId, savedState, errors } = useSelect(
|
||||
(select) => ({
|
||||
selectedStep: select(storeName).getSelectedStep(),
|
||||
automationId: select(storeName).getAutomationData().id,
|
||||
automationSaved: select(storeName).getAutomationSaved(),
|
||||
savedState: select(storeName).getSavedState(),
|
||||
errors: select(storeName).getStepError(
|
||||
select(storeName).getSelectedStep().id,
|
||||
),
|
||||
@ -77,10 +77,10 @@ export function EditNewsletter(): JSX.Element {
|
||||
// This component is rendered only when no email ID is set. Once we have the ID
|
||||
// and the automation is saved, we can safely redirect to the email design flow.
|
||||
useEffect(() => {
|
||||
if (redirectToTemplateSelection && emailId && automationSaved) {
|
||||
if (redirectToTemplateSelection && emailId && savedState === 'saved') {
|
||||
window.location.href = `admin.php?page=mailpoet-newsletters&context=automation#/template/${emailId}`;
|
||||
}
|
||||
}, [emailId, automationSaved, redirectToTemplateSelection]);
|
||||
}, [emailId, savedState, redirectToTemplateSelection]);
|
||||
|
||||
if (!emailId || redirectToTemplateSelection) {
|
||||
return (
|
||||
|
@ -43,8 +43,9 @@ export const step: StepType = {
|
||||
'mailpoet',
|
||||
),
|
||||
/\[link\](.*?)\[\/link\]/g,
|
||||
(match) => (
|
||||
(match, i) => (
|
||||
<a
|
||||
key={i}
|
||||
rel="noreferrer"
|
||||
href="https://kb.mailpoet.com/article/397-how-to-set-up-an-automation"
|
||||
target="_blank"
|
||||
@ -70,8 +71,9 @@ export const step: StepType = {
|
||||
'mailpoet',
|
||||
),
|
||||
/\[link\](.*?)\[\/link\]/g,
|
||||
(match) => (
|
||||
(match, i) => (
|
||||
<a
|
||||
key={i}
|
||||
rel="noreferrer"
|
||||
href="https://kb.mailpoet.com/article/397-how-to-set-up-an-automation"
|
||||
target="_blank"
|
||||
|
@ -1,17 +0,0 @@
|
||||
import { FormTokenItem } from '../../../../../editor/components';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
mailpoet_user_roles: Record<string, string>;
|
||||
}
|
||||
}
|
||||
|
||||
export const userRoles: FormTokenItem[] = Object.keys(
|
||||
window.mailpoet_user_roles,
|
||||
).map((id: string): FormTokenItem => {
|
||||
const role = {
|
||||
id,
|
||||
name: window.mailpoet_user_roles[id],
|
||||
};
|
||||
return role;
|
||||
});
|
@ -7,7 +7,7 @@ import {
|
||||
PlainBodyTitle,
|
||||
FormTokenField,
|
||||
} from '../../../../../editor/components';
|
||||
import { userRoles } from './role';
|
||||
import { getContext } from '../../../context';
|
||||
|
||||
function SettingsInfoText(): JSX.Element {
|
||||
return (
|
||||
@ -39,7 +39,7 @@ export function RolePanel(): JSX.Element {
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
const userRoles = getContext().userRoles;
|
||||
const rawSelected = selectedStep.args?.roles
|
||||
? (selectedStep.args.roles as string[])
|
||||
: [];
|
||||
|
@ -0,0 +1,23 @@
|
||||
import { Button } from '@wordpress/components';
|
||||
import { __ } from '@wordpress/i18n';
|
||||
import { addQueryArgs } from '@wordpress/url';
|
||||
import { Automation } from '../../automation';
|
||||
import { MailPoet } from '../../../../mailpoet';
|
||||
|
||||
type Props = {
|
||||
automation: Automation;
|
||||
label?: string;
|
||||
};
|
||||
|
||||
export function Analytics({ automation, label }: Props): JSX.Element {
|
||||
return (
|
||||
<Button
|
||||
variant="link"
|
||||
href={addQueryArgs(MailPoet.urls.automationAnalytics, {
|
||||
id: automation.id,
|
||||
})}
|
||||
>
|
||||
{label ?? __('Analytics', 'mailpoet')}
|
||||
</Button>
|
||||
);
|
||||
}
|
@ -5,6 +5,7 @@ import { moreVertical } from '@wordpress/icons';
|
||||
import { useDeleteButton, useRestoreButton, useTrashButton } from '../menu';
|
||||
import { Automation } from '../../automation';
|
||||
import { EditAutomation } from '../actions';
|
||||
import { Analytics } from '../actions/analytics';
|
||||
|
||||
type Props = {
|
||||
automation: Automation;
|
||||
@ -21,6 +22,7 @@ export function Actions({ automation }: Props): JSX.Element {
|
||||
|
||||
return (
|
||||
<div className="mailpoet-automation-listing-cell-actions">
|
||||
<Analytics automation={automation} />
|
||||
<EditAutomation automation={automation} />
|
||||
{menuItems.map(({ control, slot }) => (
|
||||
<Fragment key={control.title}>{slot}</Fragment>
|
||||
|
@ -1,8 +1,14 @@
|
||||
import { TableCard } from '@woocommerce/components/build';
|
||||
import { TableCard } from '@woocommerce/components';
|
||||
import { Button, Flex, TabPanel } from '@wordpress/components';
|
||||
import { useDispatch, useSelect } from '@wordpress/data';
|
||||
import { __, _x } from '@wordpress/i18n';
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo } from 'react';
|
||||
import {
|
||||
ComponentProps,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
} from 'react';
|
||||
import { useHistory, useLocation } from 'react-router-dom';
|
||||
import { plusIcon } from 'common/button/icon/plus';
|
||||
import { getRow } from './get-row';
|
||||
@ -151,7 +157,12 @@ export function AutomationListing(): JSX.Element {
|
||||
className="mailpoet-automation-listing"
|
||||
title=""
|
||||
isLoading={!automations}
|
||||
headers={tableHeaders}
|
||||
headers={
|
||||
// typed as mutable so doesn't accept our const (readonly) type
|
||||
tableHeaders as unknown as ComponentProps<
|
||||
typeof TableCard
|
||||
>['headers']
|
||||
}
|
||||
rows={rows}
|
||||
rowKey={(_, i) => filteredAutomations[i].id}
|
||||
rowsPerPage={rowsPerPage}
|
||||
|
@ -32,6 +32,12 @@ export type AddStepCallbackType = (item?: Item) => void;
|
||||
// mailpoet.automation.render_step
|
||||
export type RenderStepType = (step: Step) => JSX.Element;
|
||||
|
||||
// mailpoet.automation.step.more
|
||||
export type StepMoreType = JSX.Element | null;
|
||||
|
||||
// mailpoet.automation.step.footer
|
||||
export type RenderStepFooterType = JSX.Element | null;
|
||||
|
||||
// mailpoet.automation.render_step_separator
|
||||
export type RenderStepSeparatorType = (step: Step) => JSX.Element;
|
||||
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { ReactNode } from 'react';
|
||||
import jQuery from 'jquery';
|
||||
import { ReactNode, useState } from 'react';
|
||||
import { __ } from '@wordpress/i18n';
|
||||
import { MailPoet } from 'mailpoet';
|
||||
import { PremiumRequired } from 'common/premium_required/premium_required';
|
||||
@ -11,6 +12,7 @@ type Props = {
|
||||
};
|
||||
|
||||
const {
|
||||
adminPluginsUrl,
|
||||
subscribersLimitReached,
|
||||
subscribersLimit,
|
||||
subscribersCount,
|
||||
@ -49,7 +51,9 @@ export function PremiumBannerWithUpgrade({
|
||||
let bannerMessage: ReactNode;
|
||||
let ctaButton: ReactNode;
|
||||
|
||||
if (anyValidKey && !premiumActive) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
if (hasValidPremiumKey && (!isPremiumPluginInstalled || !premiumActive)) {
|
||||
bannerMessage = getBannerMessage(
|
||||
__(
|
||||
'Your current MailPoet plan includes advanced features, but they require the MailPoet Premium plugin to be installed and activated.',
|
||||
@ -57,16 +61,49 @@ export function PremiumBannerWithUpgrade({
|
||||
),
|
||||
);
|
||||
|
||||
ctaButton = isPremiumPluginInstalled
|
||||
? getCtaButton(
|
||||
__('Activate MailPoet Premium plugin', 'mailpoet'),
|
||||
premiumPluginActivationUrl,
|
||||
'_self',
|
||||
)
|
||||
: getCtaButton(
|
||||
__('Download MailPoet Premium plugin', 'mailpoet'),
|
||||
premiumPluginDownloadUrl,
|
||||
);
|
||||
ctaButton = isPremiumPluginInstalled ? (
|
||||
<Button
|
||||
withSpinner={loading}
|
||||
href={premiumPluginActivationUrl}
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
|
||||
jQuery
|
||||
.get(premiumPluginActivationUrl)
|
||||
.then((response) => {
|
||||
if (response.includes('Plugin activated')) {
|
||||
window.location.reload();
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
setLoading(false);
|
||||
MailPoet.Notice.error(
|
||||
ReactStringReplace(
|
||||
__(
|
||||
'We were unable to activate the premium plugin, please try visiting the [link]plugin page link[/link] to activate it manually.',
|
||||
'mailpoet',
|
||||
),
|
||||
/\[link\](.*?)\[\/link\]/g,
|
||||
(match) =>
|
||||
`<a rel="noreferrer" href=${adminPluginsUrl}>${match}</a>`,
|
||||
).join(''),
|
||||
{ isDismissible: false },
|
||||
);
|
||||
});
|
||||
}}
|
||||
>
|
||||
{loading
|
||||
? __('Activating MailPoet premium...', 'mailpoet')
|
||||
: __('Activate MailPoet Premium plugin', 'mailpoet')}
|
||||
</Button>
|
||||
) : (
|
||||
getCtaButton(
|
||||
__('Download MailPoet Premium plugin', 'mailpoet'),
|
||||
premiumPluginDownloadUrl,
|
||||
)
|
||||
);
|
||||
} else if (subscribersLimitReached) {
|
||||
bannerMessage = getBannerMessage(
|
||||
__(
|
||||
|
@ -1,11 +1,11 @@
|
||||
import { MailPoet } from 'mailpoet';
|
||||
import { __, _x } from '@wordpress/i18n';
|
||||
import { Button, Flex } from '@wordpress/components';
|
||||
import { useSelect, useDispatch } from '@wordpress/data';
|
||||
import { Categories } from 'common/categories/categories';
|
||||
import { Background } from 'common/background/background';
|
||||
import { Loading } from 'common/loading';
|
||||
import { TemplateBox } from 'common/template_box/template_box';
|
||||
import { Heading } from 'common/typography/heading/heading';
|
||||
import { Button } from 'common';
|
||||
import { TopBarWithBeamer } from 'common/top_bar/top_bar';
|
||||
import { Notice } from 'notices/notice';
|
||||
import { TemplateData } from './store/types';
|
||||
import { storeName } from './store/constants';
|
||||
@ -14,23 +14,43 @@ export function Selection(): JSX.Element {
|
||||
const categories = [
|
||||
{
|
||||
name: 'popup',
|
||||
label: MailPoet.I18n.t('popupCategory'),
|
||||
label: _x(
|
||||
'Pop-up',
|
||||
'This is a text on a widget that leads to settings for form placement - form type is pop-up, it will be displayed on page in a small modal window',
|
||||
'mailpoet',
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'slide_in',
|
||||
label: MailPoet.I18n.t('slideInCategory'),
|
||||
label: _x(
|
||||
'Slide–in',
|
||||
'This is a text on a widget that leads to settings for form placement - form type is slide in',
|
||||
'mailpoet',
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'fixed_bar',
|
||||
label: MailPoet.I18n.t('fixedBarCategory'),
|
||||
label: _x(
|
||||
'Fixed bar',
|
||||
'This is a text on a widget that leads to settings for form placement - form type is fixed bar',
|
||||
'mailpoet',
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'below_posts',
|
||||
label: MailPoet.I18n.t('belowPagesCategory'),
|
||||
label: _x(
|
||||
'Below pages',
|
||||
'This is a text on a widget that leads to settings for form placement',
|
||||
'mailpoet',
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'others',
|
||||
label: MailPoet.I18n.t('othersCategory'),
|
||||
label: _x(
|
||||
'Others (widget)',
|
||||
'Placement of the form using theme widget',
|
||||
'mailpoet',
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
@ -70,25 +90,34 @@ export function Selection(): JSX.Element {
|
||||
),
|
||||
),
|
||||
)}
|
||||
<div className="mailpoet-template-selection-header">
|
||||
<Heading level={4}>{MailPoet.I18n.t('selectTemplate')}</Heading>
|
||||
<Button
|
||||
automationId="create_blank_form"
|
||||
onClick={(): void => {
|
||||
void selectTemplate('initial_form', 'Blank template');
|
||||
}}
|
||||
>
|
||||
{MailPoet.I18n.t('createBlankTemplate')}
|
||||
</Button>
|
||||
</div>
|
||||
<TopBarWithBeamer />
|
||||
{selectTemplateFailed && (
|
||||
<Notice type="error" scroll renderInPlace>
|
||||
<p>{MailPoet.I18n.t('createFormError')}</p>
|
||||
<p>
|
||||
{__(
|
||||
'Sorry, there was an error, please try again later.',
|
||||
'mailpoet',
|
||||
)}
|
||||
</p>
|
||||
</Notice>
|
||||
)}
|
||||
<div data-automation-id="template_selection_list">
|
||||
<Background color="#fff" />
|
||||
<div className="mailpoet-templates">
|
||||
<div className="mailpoet-form-templates">
|
||||
<Flex className="mailpoet-form-template-selection-header">
|
||||
<h1 className="wp-heading-inline">
|
||||
{__('Start with a template', 'mailpoet')}
|
||||
</h1>
|
||||
<Button
|
||||
data-automation-id="create_blank_form"
|
||||
variant="secondary"
|
||||
onClick={(): void => {
|
||||
void selectTemplate('initial_form', 'Blank template');
|
||||
}}
|
||||
>
|
||||
{__('Or, start with a blank form', 'mailpoet')}
|
||||
</Button>
|
||||
</Flex>
|
||||
<Categories
|
||||
categories={categories}
|
||||
active={selectedCategory}
|
||||
@ -115,6 +144,19 @@ export function Selection(): JSX.Element {
|
||||
</div>
|
||||
</TemplateBox>
|
||||
))}
|
||||
<div className="mailpoet-form-template-selection-footer">
|
||||
<p>
|
||||
{__('Can’t find a template that suits your needs?', 'mailpoet')}
|
||||
</p>
|
||||
<Button
|
||||
variant="link"
|
||||
onClick={(): void => {
|
||||
void selectTemplate('initial_form', 'Blank template');
|
||||
}}
|
||||
>
|
||||
{__('Start with a blank form', 'mailpoet')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{loading && <Loading />}
|
||||
|
5
mailpoet/assets/js/src/global.d.ts
vendored
5
mailpoet/assets/js/src/global.d.ts
vendored
@ -6,6 +6,7 @@ declare module 'wp-js-hooks' {
|
||||
name: string,
|
||||
namespace: string,
|
||||
callback: (...args: any[]) => any,
|
||||
priority?: number,
|
||||
) => void;
|
||||
applyFilters: (name: string, ...args: any[]) => any;
|
||||
};
|
||||
@ -125,7 +126,7 @@ interface Window {
|
||||
mailpoet_api_version: string;
|
||||
mailpoet_email_regex: RegExp;
|
||||
mailpoet_wp_segment_state: string;
|
||||
mailpoet_wp_week_starts_on: number;
|
||||
mailpoet_wp_week_starts_on: 0 | 1 | 2 | 3 | 4 | 5 | 6;
|
||||
mailpoet_subscribers_counts_cache_created_at: string;
|
||||
mailpoet_shortcode_links: string[];
|
||||
mailpoet_tracking_config: Partial<{
|
||||
@ -146,7 +147,6 @@ interface Window {
|
||||
mailpoet_current_date?: string;
|
||||
mailpoet_tomorrow_date?: string;
|
||||
mailpoet_schedule_time_of_day?: string;
|
||||
mailpoet_date_display_format?: string;
|
||||
mailpoet_date_storage_format?: string;
|
||||
mailpoet_current_date_time?: string;
|
||||
mailpoet_urls: Record<string, string>;
|
||||
@ -260,4 +260,5 @@ interface Window {
|
||||
subscribers: string;
|
||||
type: 'default' | 'wp_users' | 'woocommerce_users' | 'dynamic';
|
||||
}>;
|
||||
mailpoet_admin_plugins_url: string;
|
||||
}
|
||||
|
@ -83,6 +83,7 @@ export const MailPoet = {
|
||||
window.mailpoet_transactional_emails_opt_in_notice_dismissed,
|
||||
mailFunctionEnabled: window.mailpoet_mail_function_enabled,
|
||||
corrupt_newsletters: window.corrupt_newsletters ?? [],
|
||||
adminPluginsUrl: window.mailpoet_admin_plugins_url,
|
||||
} as const;
|
||||
|
||||
declare global {
|
||||
|
1
mailpoet/assets/js/src/mock-empty-module.js
Normal file
1
mailpoet/assets/js/src/mock-empty-module.js
Normal file
@ -0,0 +1 @@
|
||||
module.exports = {};
|
@ -14,7 +14,7 @@ import { ErrorBoundary } from 'common';
|
||||
import { NewsletterGeneralStats } from './newsletter_general_stats';
|
||||
import { NewsletterType } from './newsletter_type';
|
||||
import { NewsletterStatsInfo } from './newsletter_stats_info';
|
||||
import { PremiumBanner } from './premium_banner.jsx';
|
||||
import { PremiumBanner } from './premium_banner';
|
||||
|
||||
type Props = {
|
||||
match: {
|
||||
|
@ -3,6 +3,7 @@ import { MailPoet } from 'mailpoet';
|
||||
import { Button } from 'common/button/button';
|
||||
import { PremiumRequired } from 'common/premium_required/premium_required';
|
||||
import { withBoundary } from '../../common';
|
||||
import { PremiumBannerWithUpgrade } from '../../common/premium_banner_with_upgrade/premium_banner_with_upgrade';
|
||||
|
||||
function SkipDisplayingDetailedStats() {
|
||||
const ctaButton = (
|
||||
@ -35,8 +36,7 @@ function SkipDisplayingDetailedStats() {
|
||||
|
||||
return (
|
||||
<div className="mailpoet-stats-premium-required">
|
||||
<PremiumRequired
|
||||
title={__('This is a Premium feature', 'mailpoet')}
|
||||
<PremiumBannerWithUpgrade
|
||||
message={description}
|
||||
actionButton={ctaButton}
|
||||
/>
|
||||
@ -60,8 +60,8 @@ function PremiumBanner() {
|
||||
'Congratulations, you now have [subscribersCount] subscribers! Our free version is limited to [subscribersLimit] subscribers. You need to upgrade now to be able to continue using MailPoet.',
|
||||
'mailpoet',
|
||||
)
|
||||
.replace('[subscribersLimit]', window.mailpoet_subscribers_limit)
|
||||
.replace('[subscribersCount]', window.mailpoet_subscribers_count);
|
||||
.replace('[subscribersLimit]', MailPoet.subscribersLimit.toString())
|
||||
.replace('[subscribersCount]', MailPoet.subscribersCount.toString());
|
||||
const upgradeLink = hasValidApiKey
|
||||
? MailPoet.MailPoetComUrlFactory.getUpgradeUrl(MailPoet.pluginPartialKey)
|
||||
: MailPoet.MailPoetComUrlFactory.getPurchasePlanUrl(
|
@ -119,7 +119,9 @@ const stepsListingHeading = (
|
||||
{' '}
|
||||
</h1>
|
||||
<div className="mailpoet-flex-grow" />
|
||||
{emailType !== 'automation' && <TutorialIcon />}
|
||||
{!['automation', 'automation_transactional'].includes(emailType) && (
|
||||
<TutorialIcon />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
@ -1,12 +1,12 @@
|
||||
import { Component } from 'react';
|
||||
import { Component, SyntheticEvent } from 'react';
|
||||
import { __, _x } from '@wordpress/i18n';
|
||||
import PropTypes from 'prop-types';
|
||||
import { registerLocale } from 'react-datepicker';
|
||||
import locale from 'date-fns/locale/en-US';
|
||||
import buildLocalizeFn from 'date-fns/locale/_lib/buildLocalizeFn';
|
||||
|
||||
import { Datepicker } from 'common/datepicker/datepicker.tsx';
|
||||
import { Datepicker } from 'common/datepicker/datepicker';
|
||||
import { MailPoet } from 'mailpoet';
|
||||
import { DateOptions } from 'date';
|
||||
|
||||
const monthValues = {
|
||||
abbreviated: [
|
||||
@ -82,9 +82,33 @@ locale.options.weekStartsOn =
|
||||
|
||||
registerLocale('mailpoet', locale);
|
||||
|
||||
class DateText extends Component {
|
||||
onChange = (value, event) => {
|
||||
const changeEvent = event;
|
||||
type DateTextEvent = SyntheticEvent<HTMLInputElement> & {
|
||||
target: EventTarget & {
|
||||
name?: string;
|
||||
value?: string;
|
||||
};
|
||||
};
|
||||
|
||||
type DateTextProps = {
|
||||
displayFormat: string;
|
||||
onChange: (date: DateTextEvent) => void;
|
||||
storageFormat: string;
|
||||
value: string;
|
||||
disabled: boolean;
|
||||
validation: {
|
||||
'data-parsley-required': boolean;
|
||||
'data-parsley-required-message': string;
|
||||
'data-parsley-type': string;
|
||||
'data-parsley-errors-container': string;
|
||||
maxLength: number;
|
||||
};
|
||||
maxDate: Date;
|
||||
name?: string;
|
||||
};
|
||||
|
||||
class DateText extends Component<DateTextProps> {
|
||||
onChange = (value: Date, event) => {
|
||||
const changeEvent: DateTextEvent = event;
|
||||
// Swap display format to storage format
|
||||
const storageDate = this.getStorageDate(value);
|
||||
|
||||
@ -95,24 +119,26 @@ class DateText extends Component {
|
||||
|
||||
getFieldName = () => this.props.name || 'date';
|
||||
|
||||
getDisplayDateFormat = (format) => {
|
||||
getDisplayDateFormat = (format: string) => {
|
||||
const convertedFormat = MailPoet.Date.convertFormat(format);
|
||||
// Convert moment format to date-fns, see: https://git.io/fxCyr
|
||||
return convertedFormat
|
||||
.replace(/D/g, 'd')
|
||||
.replace(/Y/g, 'y')
|
||||
.replace(/A/g, 'a')
|
||||
.replace(/o/g, 'Y') // MailPoet.Date.convertFormat converts 'S' to 'o'
|
||||
.replace(/\[/g, '')
|
||||
.replace(/\]/g, '');
|
||||
};
|
||||
|
||||
getDate = (date) => {
|
||||
getDate = (date: string) => {
|
||||
const formatting = {
|
||||
parseFormat: this.props.storageFormat,
|
||||
};
|
||||
} as DateOptions;
|
||||
return MailPoet.Date.toDate(date, formatting);
|
||||
};
|
||||
|
||||
getStorageDate = (date) => {
|
||||
getStorageDate = (date: Date) => {
|
||||
const formatting = {
|
||||
format: this.props.storageFormat,
|
||||
};
|
||||
@ -136,26 +162,4 @@ class DateText extends Component {
|
||||
}
|
||||
}
|
||||
|
||||
DateText.propTypes = {
|
||||
displayFormat: PropTypes.string.isRequired,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
name: PropTypes.string,
|
||||
storageFormat: PropTypes.string.isRequired,
|
||||
value: PropTypes.string.isRequired,
|
||||
disabled: PropTypes.bool.isRequired,
|
||||
validation: PropTypes.shape({
|
||||
'data-parsley-required': PropTypes.bool,
|
||||
'data-parsley-required-message': PropTypes.string,
|
||||
'data-parsley-type': PropTypes.string,
|
||||
'data-parsley-errors-container': PropTypes.string,
|
||||
maxLength: PropTypes.number,
|
||||
}).isRequired,
|
||||
maxDate: PropTypes.instanceOf(Date),
|
||||
};
|
||||
|
||||
DateText.defaultProps = {
|
||||
name: 'date',
|
||||
maxDate: null,
|
||||
};
|
||||
DateText.displayName = 'DateText';
|
||||
export { DateText };
|
@ -2,7 +2,7 @@ import { Component } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { Grid } from 'common/grid';
|
||||
import { DateText } from 'newsletters/send/date_text.jsx';
|
||||
import { DateText } from 'newsletters/send/date_text';
|
||||
import { TimeSelect } from 'newsletters/send/time_select.jsx';
|
||||
import { ErrorBoundary } from '../../common';
|
||||
|
||||
|
@ -15,7 +15,7 @@ import { Field } from '../../form/types';
|
||||
const currentTime = window.mailpoet_current_time || '00:00';
|
||||
const tomorrowDateTime = `${window.mailpoet_tomorrow_date} 08:00:00`;
|
||||
const timeOfDayItems = window.mailpoet_schedule_time_of_day;
|
||||
const dateDisplayFormat = window.mailpoet_date_display_format;
|
||||
const dateDisplayFormat = window.mailpoet_date_format;
|
||||
const dateStorageFormat = window.mailpoet_date_storage_format;
|
||||
|
||||
type StandardSchedulingProps = {
|
||||
|
@ -7,15 +7,21 @@ function SubscribersLimitNotice(): JSX.Element {
|
||||
const hasValidApiKey = MailPoet.hasValidApiKey;
|
||||
const subscribersLimit = MailPoet.subscribersLimit.toLocaleString();
|
||||
let title = MailPoet.I18n.t('subscribersLimitNoticeTitleUnknownLimit');
|
||||
let youReachedTheLimit = '';
|
||||
let subscribersLimitReached = MailPoet.I18n.t(
|
||||
'subscribersLimitReachedUnknownLimit',
|
||||
);
|
||||
let planLimit = '';
|
||||
if (MailPoet.subscribersLimit) {
|
||||
title = MailPoet.I18n.t('subscribersLimitNoticeTitle').replace(
|
||||
'[subscribersLimit]',
|
||||
subscribersLimit,
|
||||
);
|
||||
youReachedTheLimit = MailPoet.I18n.t(
|
||||
planLimit = MailPoet.I18n.t(
|
||||
hasValidApiKey ? 'yourPlanLimit' : 'freeVersionLimit',
|
||||
).replace('[subscribersLimit]', subscribersLimit);
|
||||
subscribersLimitReached = MailPoet.I18n.t(
|
||||
'subscribersLimitReached',
|
||||
).replace('[subscribersLimit]', subscribersLimit);
|
||||
}
|
||||
const upgradeLink = hasValidApiKey
|
||||
? MailPoet.MailPoetComUrlFactory.getUpgradeUrl(MailPoet.pluginPartialKey)
|
||||
@ -48,13 +54,13 @@ function SubscribersLimitNotice(): JSX.Element {
|
||||
<Notice type="error" timeout={false} closable={false} renderInPlace>
|
||||
<h3>{title}</h3>
|
||||
<p>
|
||||
{youReachedTheLimit} {MailPoet.I18n.t('youNeedToUpgrade')}
|
||||
{MailPoet.wpSegmentState === 'active' ? (
|
||||
<>
|
||||
<br />
|
||||
{youCanDisableWpSegmentMessage}
|
||||
</>
|
||||
) : null}
|
||||
{subscribersLimitReached} {planLimit}{' '}
|
||||
{MailPoet.I18n.t('youNeedToUpgrade')}
|
||||
<br />
|
||||
{MailPoet.wpSegmentState === 'active'
|
||||
? youCanDisableWpSegmentMessage
|
||||
: null}{' '}
|
||||
{MailPoet.I18n.t('actToSeamlessService')}
|
||||
</p>
|
||||
<p>
|
||||
<a
|
||||
|
@ -11,6 +11,7 @@ import { storeName } from '../store';
|
||||
import { EmailOpenStatisticsFields } from './fields/email/email_statistics_opens';
|
||||
import { EmailClickStatisticsFields } from './fields/email/email_statistics_clicks';
|
||||
import { EmailOpensAbsoluteCountFields } from './fields/email/email_opens_absolute_count';
|
||||
import { validateDaysPeriod } from './fields/days_period_field';
|
||||
|
||||
export function validateEmail(formItems: EmailFormItem): boolean {
|
||||
// check if the action has the right type
|
||||
@ -35,7 +36,9 @@ export function validateEmail(formItems: EmailFormItem): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
return !!formItems.days && !!formItems.opens && !!formItems.operator;
|
||||
return (
|
||||
validateDaysPeriod(formItems) && !!formItems.opens && !!formItems.operator
|
||||
);
|
||||
}
|
||||
|
||||
const componentsMap = {
|
||||
|
@ -15,15 +15,23 @@ export enum DateOperator {
|
||||
BEFORE = 'before',
|
||||
AFTER = 'after',
|
||||
ON = 'on',
|
||||
ON_OR_BEFORE = 'onOrBefore',
|
||||
ON_OR_AFTER = 'onOrAfter',
|
||||
NOT_ON = 'notOn',
|
||||
IN_THE_LAST = 'inTheLast',
|
||||
NOT_IN_THE_LAST = 'notInTheLast',
|
||||
}
|
||||
|
||||
export type DateFilterProps = FilterProps & {
|
||||
defaultOperator: DateOperator;
|
||||
};
|
||||
|
||||
const availableOperators = [
|
||||
DateOperator.BEFORE,
|
||||
DateOperator.AFTER,
|
||||
DateOperator.ON,
|
||||
DateOperator.ON_OR_AFTER,
|
||||
DateOperator.ON_OR_BEFORE,
|
||||
DateOperator.NOT_ON,
|
||||
DateOperator.IN_THE_LAST,
|
||||
DateOperator.NOT_IN_THE_LAST,
|
||||
@ -49,7 +57,10 @@ const parseDate = (value: string): Date | undefined => {
|
||||
return date;
|
||||
};
|
||||
|
||||
export function DateFields({ filterIndex }: FilterProps): JSX.Element {
|
||||
function DateFields({
|
||||
filterIndex,
|
||||
defaultOperator,
|
||||
}: DateFilterProps): JSX.Element {
|
||||
const segment: DateFormItem = useSelect(
|
||||
(select) => select(storeName).getSegmentFilter(filterIndex),
|
||||
[filterIndex],
|
||||
@ -60,12 +71,14 @@ export function DateFields({ filterIndex }: FilterProps): JSX.Element {
|
||||
|
||||
useEffect(() => {
|
||||
if (!availableOperators.includes(segment.operator as DateOperator)) {
|
||||
void updateSegmentFilter({ operator: DateOperator.BEFORE }, filterIndex);
|
||||
void updateSegmentFilter({ operator: defaultOperator }, filterIndex);
|
||||
}
|
||||
if (
|
||||
(segment.operator === DateOperator.BEFORE ||
|
||||
segment.operator === DateOperator.AFTER ||
|
||||
segment.operator === DateOperator.ON ||
|
||||
segment.operator === DateOperator.ON_OR_AFTER ||
|
||||
segment.operator === DateOperator.ON_OR_BEFORE ||
|
||||
segment.operator === DateOperator.NOT_ON) &&
|
||||
(parseDate(segment.value) === undefined ||
|
||||
!/^\d+-\d+-\d+$/.test(segment.value))
|
||||
@ -83,7 +96,7 @@ export function DateFields({ filterIndex }: FilterProps): JSX.Element {
|
||||
) {
|
||||
void updateSegmentFilter({ value: '' }, filterIndex);
|
||||
}
|
||||
}, [updateSegmentFilter, segment, filterIndex]);
|
||||
}, [updateSegmentFilter, segment, filterIndex, defaultOperator]);
|
||||
|
||||
return (
|
||||
<Grid.CenteredRow>
|
||||
@ -95,9 +108,15 @@ export function DateFields({ filterIndex }: FilterProps): JSX.Element {
|
||||
}}
|
||||
>
|
||||
<option value={DateOperator.BEFORE}>{MailPoet.I18n.t('before')}</option>
|
||||
<option value={DateOperator.AFTER}>{MailPoet.I18n.t('after')}</option>
|
||||
<option value={DateOperator.ON_OR_BEFORE}>
|
||||
{MailPoet.I18n.t('onOrBefore')}
|
||||
</option>
|
||||
<option value={DateOperator.ON}>{MailPoet.I18n.t('on')}</option>
|
||||
<option value={DateOperator.NOT_ON}>{MailPoet.I18n.t('notOn')}</option>
|
||||
<option value={DateOperator.ON_OR_AFTER}>
|
||||
{MailPoet.I18n.t('onOrAfter')}
|
||||
</option>
|
||||
<option value={DateOperator.AFTER}>{MailPoet.I18n.t('after')}</option>
|
||||
<option value={DateOperator.IN_THE_LAST}>
|
||||
{MailPoet.I18n.t('inTheLast')}
|
||||
</option>
|
||||
@ -108,6 +127,8 @@ export function DateFields({ filterIndex }: FilterProps): JSX.Element {
|
||||
{(segment.operator === DateOperator.BEFORE ||
|
||||
segment.operator === DateOperator.AFTER ||
|
||||
segment.operator === DateOperator.ON ||
|
||||
segment.operator === DateOperator.ON_OR_AFTER ||
|
||||
segment.operator === DateOperator.ON_OR_BEFORE ||
|
||||
segment.operator === DateOperator.NOT_ON) && (
|
||||
<Datepicker
|
||||
dateFormat="MMM d, yyyy"
|
||||
@ -151,6 +172,8 @@ export function validateDateField(formItems: DateFormItem): boolean {
|
||||
DateOperator.AFTER,
|
||||
DateOperator.ON,
|
||||
DateOperator.NOT_ON,
|
||||
DateOperator.ON_OR_BEFORE,
|
||||
DateOperator.ON_OR_AFTER,
|
||||
].includes(formItems.operator as DateOperator)
|
||||
) {
|
||||
const re = /^\d+-\d+-\d+$/;
|
||||
@ -168,3 +191,14 @@ export function validateDateField(formItems: DateFormItem): boolean {
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function withDefaults(defaultOperator: DateOperator) {
|
||||
return function dateFieldWithDefaults(props: FilterProps): JSX.Element {
|
||||
return <DateFields {...props} defaultOperator={defaultOperator} />;
|
||||
};
|
||||
}
|
||||
|
||||
export const DateFieldsDefaultBefore = withDefaults(DateOperator.BEFORE);
|
||||
export const DateFieldsDefaultInTheLast = withDefaults(
|
||||
DateOperator.IN_THE_LAST,
|
||||
);
|
||||
|
@ -0,0 +1,88 @@
|
||||
import { useDispatch, useSelect } from '@wordpress/data';
|
||||
import { Input } from 'common';
|
||||
import { MailPoet } from 'mailpoet';
|
||||
import { Select } from 'common/form/select/select';
|
||||
import { DaysPeriodItem, FilterProps, Timeframe } from 'segments/dynamic/types';
|
||||
import { storeName } from 'segments/dynamic/store';
|
||||
import { useEffect } from 'react';
|
||||
import { isInEnum } from '../../../../utils';
|
||||
|
||||
function replaceElementsInDaysSentence(
|
||||
fn: (value) => JSX.Element,
|
||||
): JSX.Element[] {
|
||||
return MailPoet.I18n.t('emailActionOpensDaysSentence')
|
||||
.split(/({days})|({timeframe})/gim)
|
||||
.map(fn);
|
||||
}
|
||||
|
||||
export function DaysPeriodField({ filterIndex }: FilterProps): JSX.Element {
|
||||
const segment: DaysPeriodItem = useSelect(
|
||||
(select) => select(storeName).getSegmentFilter(filterIndex),
|
||||
[filterIndex],
|
||||
);
|
||||
const { updateSegmentFilterFromEvent, updateSegmentFilter } =
|
||||
useDispatch(storeName);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isInEnum(segment.timeframe, Timeframe)) {
|
||||
void updateSegmentFilter(
|
||||
{ timeframe: Timeframe.IN_THE_LAST },
|
||||
filterIndex,
|
||||
);
|
||||
}
|
||||
}, [segment, updateSegmentFilter, filterIndex]);
|
||||
|
||||
const isInTheLast = segment.timeframe === Timeframe.IN_THE_LAST;
|
||||
|
||||
return (
|
||||
<>
|
||||
{replaceElementsInDaysSentence((match) => {
|
||||
if (isInTheLast && match === '{days}') {
|
||||
return (
|
||||
<Input
|
||||
key="input"
|
||||
type="number"
|
||||
value={segment.days || ''}
|
||||
data-automation-id="segment-number-of-days"
|
||||
onChange={(e) => {
|
||||
void updateSegmentFilterFromEvent('days', filterIndex, e);
|
||||
}}
|
||||
min={1}
|
||||
step={1}
|
||||
placeholder={MailPoet.I18n.t('daysPlaceholder')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (match === '{timeframe}') {
|
||||
return (
|
||||
<Select
|
||||
key="timeframe-select"
|
||||
value={segment.timeframe}
|
||||
onChange={(e) => {
|
||||
void updateSegmentFilterFromEvent('timeframe', filterIndex, e);
|
||||
}}
|
||||
>
|
||||
<option value="inTheLast">{MailPoet.I18n.t('inTheLast')}</option>
|
||||
<option value="allTime">{MailPoet.I18n.t('overAllTime')}</option>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
if (
|
||||
isInTheLast &&
|
||||
typeof match === 'string' &&
|
||||
match.trim().length > 1
|
||||
) {
|
||||
return <div key={match}>{match}</div>;
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function validateDaysPeriod(formItems: DaysPeriodItem): boolean {
|
||||
if (formItems.timeframe === Timeframe.ALL_TIME) {
|
||||
return true;
|
||||
}
|
||||
return !!formItems.days;
|
||||
}
|
@ -8,14 +8,7 @@ import { MailPoet } from 'mailpoet';
|
||||
|
||||
import { EmailFormItem, FilterProps } from '../../../types';
|
||||
import { storeName } from '../../../store';
|
||||
|
||||
function replaceElementsInDaysSentence(
|
||||
fn: (value) => JSX.Element,
|
||||
): JSX.Element[] {
|
||||
return MailPoet.I18n.t('emailActionOpensDaysSentence')
|
||||
.split(/({days})/gim)
|
||||
.map(fn);
|
||||
}
|
||||
import { DaysPeriodField } from '../days_period_field';
|
||||
|
||||
function replaceEmailActionOpensSentence(
|
||||
fn: (value) => JSX.Element,
|
||||
@ -85,27 +78,7 @@ export function EmailOpensAbsoluteCountFields({
|
||||
})}
|
||||
</Grid.CenteredRow>
|
||||
<Grid.CenteredRow>
|
||||
{replaceElementsInDaysSentence((match) => {
|
||||
if (match === '{days}') {
|
||||
return (
|
||||
<Input
|
||||
key="input"
|
||||
type="number"
|
||||
value={segment.days || ''}
|
||||
data-automation-id="segment-number-of-days"
|
||||
onChange={(e) => {
|
||||
void updateSegmentFilterFromEvent('days', filterIndex, e);
|
||||
}}
|
||||
min="0"
|
||||
placeholder={MailPoet.I18n.t('emailActionDays')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (typeof match === 'string' && match.trim().length > 1) {
|
||||
return <div key={match}>{match}</div>;
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
<DaysPeriodField filterIndex={filterIndex} />
|
||||
</Grid.CenteredRow>
|
||||
</>
|
||||
);
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user