Compare commits

...

274 Commits

Author SHA1 Message Date
7fade4e4a0 Release 4.24.0 2023-08-14 17:13:24 +02:00
3566cc022d Redirect users from DocsBot to proper support page based on their plan
[MAILPOET-5529]
2023-08-14 16:45:11 +02:00
9f73073874 Updated readme.txt and texts mentioning Helpscout
[MAILPOET-5529]
2023-08-14 16:45:11 +02:00
15f9025b67 Replace HelpScout beacon with DocsBot
[MAILPOET-5529]
2023-08-14 16:45:11 +02:00
cea9927779 Remove HS twig extension
[MAILPOET-5529]
2023-08-14 16:45:11 +02:00
d4347d1fc5 Fix double bottom border in automation header
[MAILPOET-4968]
2023-08-14 16:41:29 +02:00
af5d3ab1d9 Add saving state button fade-in/out animation
[MAILPOET-4968]
2023-08-14 16:41:29 +02:00
c09410af94 Include parent category IDs in customer order fields
[MAILPOET-5377]
2023-08-14 16:41:29 +02:00
6414cc832c Include parent category IDs in order category field
[MAILPOET-5377]
2023-08-14 16:41:29 +02:00
29f32a52b8 Add keyboard shortcut for automation saving
[MAILPOET-4968]
2023-08-14 16:41:29 +02:00
a7d67ed09b Unify update button behavior with WP post editor
[MAILPOET-4968]
2023-08-14 16:41:29 +02:00
bd0158fe86 Unify saving button behavior with WP post editor
[MAILPOET-4968]
2023-08-14 16:41:29 +02:00
db713f4db8 Make automation save state an enum
[MAILPOET-4968]
2023-08-14 16:41:29 +02:00
0629bb2878 Fix step name popover positioning and a deprecation warning
[MAILPOET-5242]
2023-08-14 16:41:29 +02:00
a51280091a Fix missing key prop warnings
[MAILPOET-5242]
2023-08-14 16:41:29 +02:00
65dc4a8e32 Fix tutorial being shown for transactional automation emails
[MAILPOET-5242]
2023-08-14 16:41:29 +02:00
8e640acaf1 Fix is-first-order for guest customers
[MAILPOET-5459]
2023-08-14 13:59:38 +02:00
aac40e2a24 Add mapping test for used coupon code data
MAILPOET-5007
2023-08-14 13:49:55 +02:00
a18dddedf3 Convert FilterDataMapperTest to integration test
It was becoming cumbersome to update the tests with new mocks every time
 the constructor changes in FilterDataMapper, which will become more
 common as more of the validation logic gets moved into the filter
 classes themselves.

MAILPOET-5007
2023-08-14 13:49:55 +02:00
957b317222 Add frontend filter for used coupon codes
MAILPOET-5007
2023-08-14 13:49:55 +02:00
ecde4c10e3 Populate store with coupon data
MAILPOET-5007
2023-08-14 13:49:55 +02:00
adc052fc55 Add coupon code filter to mapper/factory
MAILPOET-5007
2023-08-14 13:49:55 +02:00
a446a13354 Add backend logic for used coupon code filter
MAILPOET-5007
2023-08-14 13:49:55 +02:00
916547d29d Flush test filters and add unique names
This prevents an error I was getting when trying to create/run multiple
filters in the same test

MAILPOET-5007
2023-08-14 13:49:55 +02:00
530fc0acd3 Allow setting price when creating test coupons
MAILPOET-5007
2023-08-14 13:49:55 +02:00
d4d244fa0e Ensure coupon stats are updated in tests
MAILPOET-5007
2023-08-14 13:49:55 +02:00
45b907f3d9 Do not create Woo templates when WooCommerce is not active
[MAILPOET-5384]
2023-08-14 12:54:09 +02:00
13202ed191 Keep "coming soon" templates as last
[MAILPOET-5384]
2023-08-14 12:54:09 +02:00
34ce26c58a Align checkboxes in the Welcome Wizard with text
[MAILPOET-5498]
2023-08-14 11:33:18 +02:00
26c45e2b7b Remove the note badge from the Welcome Wizard
[MAILPOET-5498]
2023-08-14 11:33:18 +02:00
afd0e6ab34 Fix text click failing due to admin bar
[MAILPOET-5492]
2023-08-14 10:48:24 +02:00
c9e95c6c66 Add links to analytics page
[MAILPOET-5093]
2023-08-14 10:48:24 +02:00
6d6cb98dd6 Do not load site editor styles in analytics
[MAILPOET-5492]
2023-08-14 10:48:24 +02:00
dd9474bdd6 Improve subscribers step cell layout
[MAILPOET-5492]
2023-08-14 10:48:24 +02:00
b188bfc2b8 Reduce table spacing on smaller screens
[MAILPOET-5492]
2023-08-14 10:48:24 +02:00
da2bfdb299 Fix CSS paths to make mixins work
[MAILPOET-5492]
2023-08-14 10:48:24 +02:00
7887615711 Make table sort header buttons full width
[MAILPOET-5492]
2023-08-14 10:48:24 +02:00
59cff3ab5c Fix header font weights 2023-08-14 10:48:24 +02:00
2d2bfea188 Avoid unnecessary premium status checks
[MAILPOET-5492]
2023-08-14 10:48:24 +02:00
b5b1c14137 Use upgrade info logic for premium upsell banner
[MAILPOET-5492]
2023-08-14 10:48:24 +02:00
7af66364f4 Align filter controls with submit/reset buttons
[MAILPOET-5492]
2023-08-14 10:48:24 +02:00
b615c37926 Open premium modal when clicking on links in sample data
[MAILPOET-5492]
2023-08-14 10:48:24 +02:00
3a4d3067a7 Add premium modal and store functionality for analytics
[MAILPOET-5492]
2023-08-14 10:48:24 +02:00
7c1dd656d3 Remove subscriber controller and endpoint
[MAILPOET-5492]
2023-08-14 10:48:24 +02:00
221e0ffc77 Unify step icon size with subscriber icon size
[MAILPOET-5492]
2023-08-14 10:48:24 +02:00
687743004e Use retina-friendly avatar sizes
[MAILPOET-5492]
2023-08-14 10:48:24 +02:00
6b83a73c76 Simplify status cell
[MAILPOET-5492]
2023-08-14 10:48:24 +02:00
f61480a114 Use step types to render step subtitles
[MAILPOET-5492]
2023-08-14 10:48:24 +02:00
af12dc3fd3 Add sample data for subscribers section 2023-08-14 10:48:24 +02:00
6a50515e77 Load step data with subscriber stats 2023-08-14 10:48:24 +02:00
6e47d4ae53 Simplify code
[MAILPOET-5492]
2023-08-14 10:48:24 +02:00
c39def9071 Use JS date methods to modify date
[MAILPOET-5492]
2023-08-14 10:48:24 +02:00
330d1e2f34 Remove order controller and endpoint
[MAILPOET-5492]
2023-08-14 10:48:24 +02:00
57909b80a8 Add sample data for orders section
[MAILPOET-5492]
2023-08-14 10:48:24 +02:00
170ff27faa Improve table text wrapping
[MAILPOET-5492]
2023-08-14 10:48:24 +02:00
2a981c87dc Override styles withing mailpoet namespace to avoid side-effects
[MAILPOET-5492]
2023-08-14 10:48:24 +02:00
5192b7898c Fix view more list text color
[MAILPOET-5492]
2023-08-14 10:48:24 +02:00
6e5c69f35b Display view more list inline
[MAILPOET-5492]
2023-08-14 10:48:24 +02:00
01c7f42d74 Fix long badge wrapping
[MAILPOET-5492]
2023-08-14 10:48:24 +02:00
f7bf01a1c5 Fallback to email when customer has no name
[MAILPOET-5492]
2023-08-14 10:48:24 +02:00
0ec59e16a4 Update wp-cli-bundle to version 2.8.1
Doing this update as the previous version that we were using produced
deprecation warnings when running it with PHP 8.2.

[MAILPOET-4876]
2023-08-14 11:30:59 +03:00
efc6e7fef7 Refactor engagement summary
MAILPOET-5410
2023-08-11 16:39:57 +02:00
021d8774e7 Wrap bulk updates in a transaction
MAILPOET-5410
2023-08-11 16:39:57 +02:00
4c80949efd Simplify import
MAILPOET-5410
2023-08-11 16:39:57 +02:00
327f78b902 Remove unused and unhelpful return value
MAILPOET-5410
2023-08-11 16:39:57 +02:00
ee40743a96 Fix tests
MAILPOET-5410
2023-08-11 16:39:57 +02:00
5f84d5af1c Add engagement summary to subscriber stats
MAILPOET-5410
2023-08-11 16:39:57 +02:00
c404188832 Add subheading to subscriber stats page
MAILPOET-5410
2023-08-11 16:39:57 +02:00
f0f24552f0 Update woo revenue stats to only look at past year
MAILPOET-5410
2023-08-11 16:39:57 +02:00
62ac4d5e27 Make stats consistent with engagement score
This makes SubscriberStatisticsRepository the source of truth for
engagement score data instead of having separate queries.

As part of this change, we will now only be displaying the last year's
worth of data when viewing a report for an individual subscriber.

It also updates engagement score calculation to only count human opens,
not machine opens.

MAILPOET-5410
2023-08-11 16:39:57 +02:00
99630f85aa Reset engagement score for inactive subscribers
If someone hasn't received at least 3 emails in the last year, we can't
trust that their level of engagement would be the same now as it was
over a year ago.

MAILPOET-5410
2023-08-11 16:39:57 +02:00
f8e0ba118c Nullify engagement score updated at after sends
This will cause the cron job that recalculates engagement score to pick
up these users the next time it runs. This ensures scores don't get
stale.

MAILPOET-5410
2023-08-11 16:39:57 +02:00
3ec4505445 Add bulk update engagement score updated at method
MAILPOET-5410
2023-08-11 16:39:57 +02:00
b15065f2a6 Fix flaky test
'StaleElementReferenceException' seems to happen because the selector gets destroyed and created again.
While the placeholder is visible the querySelector finds an element. This commit waits now until the
placeholder DOM structure is removed before trying to access the values using the selecors.

[MAILPOET-5526]
2023-08-11 08:33:01 +01:00
1096921da7 Release 4.23.0 2023-08-08 14:34:31 +03:00
a5a7966663 Fix filterData reference in DynamicSegmentFilterData
[MAILPOET-5505]
2023-08-07 10:21:33 -05:00
ee882b99e9 Use admin_email as default sender.
[MAILPOET-5498]
2023-08-07 13:22:53 +02:00
d7e283dea9 Handle empty strings as blank in custom fields
MAILPOET-4996
2023-08-07 12:03:49 +02:00
f96a1d4892 Add operator to checkbox custom field
MAILPOET-4996
2023-08-07 12:03:49 +02:00
117080b0da Add correct operator select to date month
MAILPOET-4996
2023-08-07 12:03:49 +02:00
83703eadfa Fix incorrect validation check
MAILPOET-4996
2023-08-07 12:03:49 +02:00
a7d260ac2e Refactor strings to constants/enums
MAILPOET-4996
2023-08-07 12:03:49 +02:00
fe318f5a30 Add 'not contains' option for text custom fields
MAILPOET-4996
2023-08-07 12:03:49 +02:00
aece1b60a9 Frontend updates for blank/not blank custom field filters
MAILPOET-4996
2023-08-07 12:03:49 +02:00
a1b51aecf0 Update backend logic to support (not)blank
MAILPOET-4996
2023-08-07 12:03:49 +02:00
f522c0786c Rename Timeframes enum to be singular
MAILPOET-5413
2023-08-07 11:06:27 +02:00
f15d2f1cda Fix JS warning and default rating, use enums
MAILPOET-5413
2023-08-07 11:06:27 +02:00
725012ae56 Use constants instead of strings for timeframe
MAILPOET-5413
2023-08-07 11:06:27 +02:00
aab3801aee Fix validateDaysPeriod helper
We should only ignore the formItems.days if the timeframe is one that
doesn't require any days.

MAILPOET-5413
2023-08-07 11:06:27 +02:00
ce9cbdc45d Add backend logic for number of reviews filter
MAILPOET-5413
2023-08-07 11:06:27 +02:00
c3bb4d1433 Add frontend for number of reviews filter
MAILPOET-5413
2023-08-07 11:06:27 +02:00
3ab674d5fa Bump webpack from 5.74.0 to 5.76.0
Bumps [webpack](https://github.com/webpack/webpack) from 5.74.0 to 5.76.0.
- [Release notes](https://github.com/webpack/webpack/releases)
- [Commits](https://github.com/webpack/webpack/compare/v5.74.0...v5.76.0)

---
updated-dependencies:
- dependency-name: webpack
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-08-04 19:47:14 +03:00
161af43f4d Change subscribers limit notice
[MAILPOET-5444]
2023-08-03 12:49:40 +02:00
edaefea5b7 Use Subscribers feature instead of Installer
[MAILPOET-5429]
2023-08-03 12:31:02 +02:00
d31483db2d Reuse existing component and fixed behavior checking premium accessibility
[MAILPOET-5429]
2023-08-03 12:31:02 +02:00
d2d0afa9f5 Add better handling of a failed ajax request
[MAILPOET-5429]
2023-08-03 12:31:02 +02:00
1333b33d42 Use ajax call for the premium plugin activation in the premium banner
[MAILPOET-5429]
2023-08-03 12:31:02 +02:00
1053f062e3 Change info notice to success notice
[MAILPOET-5429]
2023-08-03 12:31:02 +02:00
947b788bb6 Refactor premium banner to tsx
[MAILPOET-5429]
2023-08-03 12:31:02 +02:00
ed26cb8962 Improve rendering premium banner when premium plugin is downloaded or inactive
[MAILPOET-5429]
2023-08-03 12:31:02 +02:00
7d6e69e639 Add notice when premium features for key are available
[MAILPOET-5429]
2023-08-03 12:31:02 +02:00
1e33574c76 Release 4.22.2 2023-08-03 01:44:39 +03:00
27e346eed0 Fetch action directly from filter data
This fixes an issue where pre-existing subscribedDate filters were
causing errors because they didn't have an `action` stored in their
serialized data.

It was never necessary to store the action redundantly in the serialized
 filter data in the first place, so we're now fetching the action
 directly from the filter data entity itself.

MAILPOET-5500
2023-08-02 22:46:18 +02:00
bf8e1e1b0c Release 4.22.1 2023-08-01 17:01:48 +03:00
e2dc6855a8 Fix logCurlInformation causing error when called with one parameter
[MAILPOET-5493]
2023-07-31 14:22:03 +02:00
20152569dd Use the Flex component in the form template header
For better consistency with automation and clearer design, I replaced the header div element with the Flex component.
[MAILPOET-5376]
2023-07-31 12:28:46 +02:00
88e10be66d Add footer to form template selection
[MAILPOET-5376]
2023-07-31 12:28:46 +02:00
be1595862c Use @wordpress/i18n for form template selection
[MAILPOET-5376]
2023-07-31 12:28:46 +02:00
3a5f2624c8 Unify form template selection header with automation
I used WordPress components when it was possible and changed used block from container to content.
[MAILPOET-5376]
2023-07-31 12:28:46 +02:00
44d869af70 Simplify automation run data factory 2023-07-31 09:44:56 +02:00
7b4e862014 Simplify delta counts 2023-07-31 09:44:56 +02:00
f048a43026 Ensure state of model does not spill over
[MAILPOET-5405]
2023-07-31 09:44:56 +02:00
7092b5713e Invent values if no orders found
[MAILPOET-5405]
2023-07-31 09:44:56 +02:00
1c56c3d87b Enable negative growth in WooCommerce delta
[MAILPOET-5405]
2023-07-31 09:44:56 +02:00
0aaf0f335e Return early when no emails found
[MAILPOET-5405]
2023-07-31 09:44:56 +02:00
5ead6a5b39 Remove unused variable
[MAILPOET-5405]
2023-07-31 09:44:56 +02:00
6d94cf6c16 Add Acceptance test
[MAILPOET-5405]
2023-07-31 09:44:56 +02:00
1c370f6136 Allow to alter created at date
[MAILPOET-5405]
2023-07-31 09:44:56 +02:00
d4dc3bb301 Show decrease as a negative value
[MAILPOET-5405]
2023-07-31 09:44:56 +02:00
963ecdb3b5 Allow for adding multiple queues and created_at alteration
[MAILPOET-5405]
2023-07-31 09:44:56 +02:00
f81b75a0a8 Add withSendEmailStep method
[MAILPOET-5405]
2023-07-31 09:44:56 +02:00
7cec3233fc Add AutomationRun factory
[MAILPOET-5405]
2023-07-31 09:44:56 +02:00
8bd03b7a10 Add method to delete an automation run
[MAILPOET-5405]
2023-07-31 09:44:56 +02:00
ecf4d9cfdb Make StepSeparator individual selectable
[MAILPOET-5405]
2023-07-31 09:44:56 +02:00
635d4d33d5 Load and show order related data only when WooCommerce is active
[MAILPOET-5405]
2023-07-31 09:44:56 +02:00
f0fd089b9c Fix state update during render in DaysPeriodField
Moved state update in DaysPeriodField from render phase to useEffect to
adhere to React's rules of Hooks.

This change resolves the warning of a state update in
WordpressRoleFields during the rendering of DaysPeriodField.

MAILPOET-4991
2023-07-30 19:20:09 +02:00
8ab1df5ffa Extract timeframe strings to an enum
This introduces a new helper to check if some value exists in an enum. I
 was running into an issue where I couldn't check for Object.values
 (Timeframes).includes(segment.filter) because segments.filter is a
 string and isn't assignable to Timeframes. I've run into this issue
 before so I thought it would be nice to add a helper function.

MAILPOET-4991
2023-07-30 19:20:09 +02:00
5eeee574b2 Extract strings to constants
MAILPOET-4991
2023-07-30 19:20:09 +02:00
feb0297fb9 Implement lifetime option for total spent
MAILPOET-4991
2023-07-30 19:20:09 +02:00
d04c84d6d4 Implement lifetime option for number of orders
MAILPOET-4991
2023-07-30 19:20:09 +02:00
64094387eb Implement lifetime option for average spent filter
MAILPOET-4991
2023-07-30 19:20:09 +02:00
14a2603ac3 Implement lifetime option for email opens
MAILPOET-4991
2023-07-30 19:20:09 +02:00
25a4515ebb Default days to 0 for allTime timeframe
MAILPOET-4991
2023-07-30 19:20:09 +02:00
0e6c885ad1 Ensure filter mapper always includes timeframe
MAILPOET-4991
2023-07-30 19:20:09 +02:00
2d4967b64c Add lifetime option to days period react component
MAILPOET-4991
2023-07-30 19:20:09 +02:00
2d3a8fadf7 Include scenario to cover issue we had in the past
[MAILPOET-5334]
2023-07-27 13:34:18 +02:00
9f9b2c6dab Add scenario multiple lists
[MAILPOET-5334]
2023-07-27 13:34:18 +02:00
872bd80224 Update 3rd party label and note in the welcome wizard
[MAILPOET-5375]
2023-07-26 15:44:35 +02:00
3607dd9d71 Use @wordpress/i18n in the welcome wizard
Welcome Wizard was prepared for using WP translations, so I decided to use them because I am going to change some translations.
[MAILPOET-5375]
2023-07-26 15:44:35 +02:00
2d5e93ffb8 Refactor usage tracking step component to tsx
[MAILPOET-5375]
2023-07-26 15:44:35 +02:00
33e7e2d5f1 Fix typos in migration levels related strings
[MAILPOET-5416]
2023-07-26 14:01:39 +02:00
acd5846f70 Change migration level constants to lowercase
[MAILPOET-5416]
2023-07-26 14:01:39 +02:00
ba728c5612 Use phpstan type alias for migration types
[MAILPOET-5416]
2023-07-26 14:01:39 +02:00
871d9fd9f8 Small code and php doc enhancements in MailPoet/Migrator
[MAILPOET-5416]
2023-07-26 14:01:39 +02:00
725d81077f Add check for duplicate migrations names when loading migrations
[MAILPOET-5416]
2023-07-26 14:01:39 +02:00
ec8b5260dd Improve command for creating new migrations
Use required parameter instead of optional option
[MAILPOET-5416]
2023-07-26 14:01:39 +02:00
b4d0b35a1d Small code improvements based on review feedback
[MAILPOET-5416]
2023-07-26 14:01:39 +02:00
5605259159 Move tests for specific migrations into subdirectories
[MAILPOET-5416]
2023-07-26 14:01:39 +02:00
93c29512df Fix AppMigrationTemplate description
[MAILPOET-5416]
2023-07-26 14:01:39 +02:00
74a5dde19f Make migration runner aware of the level of running migration
[MAILPOET-5416]
2023-07-26 14:01:39 +02:00
94d8d28253 Refactor Migrations Repository to return also migrations level
[MAILPOET-5416]
2023-07-26 14:01:39 +02:00
7954f9d74f Move newly added migrations (after rebase) to correct folders
[MAILPOET-5416]
2023-07-26 14:01:39 +02:00
8454925e9a Update functionality for generating new migrations to support levels
[MAILPOET-5416]
2023-07-26 14:01:39 +02:00
466782790a Remove access to higher level services in Db level migrations
[MAILPOET-5416]
2023-07-26 14:01:39 +02:00
35b7e9177c Eliminate settings controller from the initial migration
[MAILPOET-5416]
2023-07-26 14:01:39 +02:00
f0a8f3bfd3 Eliminate direct access to EntityManager in Db migrations
As we don't want to allow working directly with EntityManager in
the Db level migrations this commit replaces direct access with helper
methods from the parent class.

We don't want to allow working with EntityManager since it is considered
high level service and causes errors when DB structure is not up to date.
[MAILPOET-5416]
2023-07-26 14:01:39 +02:00
435152281a Extract App level method from initial Db migration to a new App level migration
All these extracted methods contain DB version check so for existing
sites the migration will be just added as completed to the migrations table.
[MAILPOET-5416]
2023-07-26 14:01:39 +02:00
7f363a96f5 Refactor Migration_20230111_120000 to work without SettingsController
[MAILPOET-5416]
2023-07-26 14:01:39 +02:00
9684285105 Move clearly App level migrations to App directory
[MAILPOET-5416]
2023-07-26 14:01:39 +02:00
5fd02b2bab Add support for App and Db migrations into Repository and Runner
[MAILPOET-5416]
2023-07-26 14:01:39 +02:00
5c4dcb77c5 Move all current migrations to Dd folder
[MAILPOET-5416]
2023-07-26 14:01:39 +02:00
aca358d276 Add App level migration and template
[MAILPOET-5416]
2023-07-26 14:01:39 +02:00
092fcb78d0 Rename Migration to DbMigration
We want to distinguish Db and App level migrations. This is the first step.
[MAILPOET-5416]
2023-07-26 14:01:39 +02:00
85733f1540 Slightly improved test for verifying menu items
[MAILPOET-5333]
2023-07-26 12:02:47 +03:00
941675bdde Remove unused variable
MAILPOET-4989
2023-07-26 10:53:55 +02:00
fea8db19bc Remove nullability for constructor params
MAILPOET-4989
2023-07-26 10:53:55 +02:00
4d5b4885fe Revert valid operator methods back to instance methods
MAILPOET-4989
2023-07-26 10:53:55 +02:00
8de46db560 Add note about the different naming format
The subscribedDate filter used to be completely separate and it's now
been consolidated into SubscriberDateField. Since existing filters exist
 in the wild that have the subscribedDate type, we're not able to
 update the value to be consistent with the other types.

MAILPOET-4989
2023-07-26 10:53:55 +02:00
ea5e4a37d2 Allow different default operators for DateFields
MAILPOET-4989
2023-07-26 10:53:55 +02:00
edbb40ee42 Add last sending date filter
MAILPOET-4989
2023-07-26 10:53:55 +02:00
3ded9be927 Refactor subscribedDate filter to use SubscriberDateField
MAILPOET-4989
2023-07-26 10:53:55 +02:00
f27f469b17 Add backend logic for subscriber date filters
MAILPOET-4989
2023-07-26 10:53:55 +02:00
ef7d19e49d Add frontend for subscriber engagement filters
MAILPOET-4989
2023-07-26 10:53:55 +02:00
7b461d904b Update selector and titles in checks
[MAILPOET-5477]
2023-07-25 13:42:14 +02:00
630b60e62e Improve ManageSubscribers test and update SubscribersListing
[MAILPOET-5332]
2023-07-25 13:41:54 +02:00
f3bccaff7f Release 4.22.0 2023-07-25 13:06:57 +02:00
a1574f82c9 Add reseting settings controller cache in between acceptance tests
The settings controller is used internally in the Settings factory.
It has an in-memory cache, so on the test runner side, we need to reset
the cache after every test to ensure tests don't influence each other.
[MAILPOET-5480]
2023-07-25 08:59:12 +02:00
278fbca955 Ensure correct settings before form is created
The settings is read in the Form factory when we create the form
so we need to make sure it is set before the form is created.
[MAILPOET-5480]
2023-07-25 08:59:12 +02:00
105fbe1de5 Show tooltip for failed marketing emails 2023-07-24 16:19:30 +02:00
2fabee6c3f Ensure the current section is updated and not overwritten by an older state
[PREMIUM-232]
2023-07-24 16:19:30 +02:00
5d5232da90 Use Hooks to add filters
[PREMIUM-232]
2023-07-24 16:19:30 +02:00
0f94288213 Add premiumFeaturesEnabled to evaluate whether premium features are accessible
[PREMIUM-232]
2023-07-24 16:19:30 +02:00
47f4858806 Simplify sorting callbacks
[PREMIUM-232]
2023-07-24 16:19:30 +02:00
66e05a0063 Show failed runs per step
[MAILPOET-5460]
2023-07-24 16:19:30 +02:00
0e3b7b20e7 Open specific email in order tab
[MAILPOET-5452]
2023-07-24 16:19:30 +02:00
1574c87e73 Open subscribers with specific step
[MAILPOET-5452]
2023-07-24 16:19:30 +02:00
5f07143661 Open subscribers with specific step
[MAILPOET-5452]
2023-07-24 16:19:30 +02:00
273a4729f6 Open specific email in orders tab
[MAILPOET-5452]
2023-07-24 16:19:30 +02:00
8ec491a721 Extend open_tab method to alter current view of section
[MAILPOET-5452]
2023-07-24 16:19:30 +02:00
c2dc986420 Add loading state because options need to be loaded
[MAILPOET-5452]
2023-07-24 16:19:30 +02:00
abf535a7bb Move filter state to Section
[MAILPOET-5452]
2023-07-24 16:19:30 +02:00
be669a5f61 Return available emails in free endpoint controller
[PREMIUM-232]
2023-07-24 16:19:30 +02:00
1ede595dbb Add <Filter /> to orders tab
[PREMIUM-232]
2023-07-24 16:19:30 +02:00
24aae85132 export ClearAllFilters component
[PREMIUM-232]
2023-07-24 16:19:30 +02:00
0a3068462f Extend orders endpoint return definition
We need to get the emails, which are available for the filter. The endpoint will provide
those emails

[PREMIUM-232]
2023-07-24 16:19:30 +02:00
1c6b3975ed Add distance between clear and submit buton in filter
[PREMIUM-232]
2023-07-24 16:19:30 +02:00
8753050eb5 Allow for multiselect and use <TreeSelect /> for filter
[PREMIUM-232]
2023-07-24 16:19:30 +02:00
fceb48014a Fix prop type errors 2023-07-24 15:52:21 +02:00
78214d5389 Fix errors after rebase 2023-07-24 15:52:21 +02:00
533e19865c Patch invalid imports 2023-07-24 15:52:21 +02:00
5bcdd8965e Patch invalid CSS 2023-07-24 15:52:21 +02:00
cd2bceae4b Mock @woocommerce/settings and @automattic/tour-kit with an empty module 2023-07-24 15:52:21 +02:00
53a815601e Fix errors reported by TS 2023-07-24 15:52:21 +02:00
431f53b19d Remove importing from /build 2023-07-24 15:52:21 +02:00
d8cf0121bf Update @woocommerce/components 2023-07-24 15:52:21 +02:00
77ff76f77f Remove engine strict check 2023-07-24 15:52:21 +02:00
06e08d9265 Remove old patch 2023-07-24 15:52:21 +02:00
79ad0cc15b Refactor DateText component form the send page to Typescript
[MAILPOET-5472]
2023-07-24 10:47:06 +02:00
4b6dd0dc13 Improve getDisplayDateFormat to support all WP date format strings
The 3rd party date picker component used on the send page for scheduling newsletter
uses date-fns. The date-fns works with different date formatting strings so we need to convert
WordPress date format to compatible one with date-fns.

This commit improves the conversion function to support all possible WP formats.

I fed all possible date format characters I found in https://wordpress.org/documentation/article/customize-date-and-time-format
(a A g h G H i s T c r U d j S l D m n F M Y y) to the custom date format setting
and I added replacement for all that broke the date scheduling component.

[MAILPOET-5472]
2023-07-24 10:47:06 +02:00
fcdbb65091 Use global date format string for date in newsletter scheduler
I found that we already have a global variable mailpoet_date_format defined in layout.html
so I removed duplicate mailpoet_date_display_format and used the global one directly.

The global one is set correctly using wp_date_format()|escape('js')
instead of incorrect wp_datetime_format()|escape('js')

[MAILPOET-5472]
2023-07-24 10:47:06 +02:00
4a02d5cfaf Use @wordpress/i18n to fix missing translations
MAILPOET-5468
2023-07-20 23:00:25 +02:00
b07c041e35 Improve the test switching language
[MAILPOET-5331]
2023-07-19 16:38:42 +02:00
89c3492bc4 Fix formatting issue
MAILPOET-5012
2023-07-19 16:06:19 +02:00
7db7e78c71 Add backend logic for onOr(Before|After) filters
MAILPOET-5012
2023-07-19 16:06:19 +02:00
0e4987f7ab Update frontend for onOr(Before|After) filters
MAILPOET-5012
2023-07-19 16:06:19 +02:00
7739d9df9b Update integration test namespace
[MAILPOET-4991]
2023-07-19 15:24:46 +02:00
1198c52808 Add migration to change the update name of parameters of a few filters
In a previous commit, the name of the parameter of a few filters was
changed to `days`. This commit adds a migration to change the name of
parameter of existing filters when needed. The following parameters
should be renamed to days:

- number_of_orders_days
- total_spent_days
- single_order_value_days
- average_spent_days

I opted to leave the original parameter instead of deleting it, just to
be safe in case a given site needs to rollback to a previous version.
Once a change is made to a filter by the user, the old parameter will be
deleted.

[MAILPOET-4991]
2023-07-19 15:24:46 +02:00
9a218a706b Refactor single order value and total spent to use DaysPeriodField
This commit refactors the React components SingleOrderValueFields and
TotalSpentFields to use the new DaysPeriodField component instead of
its own code to generate the days period selector.

[MAILPOET-4991]
2023-07-19 15:24:46 +02:00
8a2c435b9c Refactor average spent and # of orders to use DaysPeriodField
This commit refactors the React components AverageSpentFields and
NumberOfOrdersFields to use the new DaysPeriodField component instead of
a its own code to generate the days period selector.

[MAILPOET-4991]
2023-07-19 15:24:46 +02:00
c0df921998 Extract the days period selector into its own component
This commit extracts the days period selector from the
EmailOpensAbsoluteCountFields component into its own component called
DaysPeriodField. In the next commit, I will change other field
components to use DaysPeriodField its of duplicating the code to
generate the days period selector.

[MAILPOET-4991]
2023-07-19 15:24:46 +02:00
408f0e35dd fix linting error
[PREMIUM-225]
2023-07-19 14:57:26 +02:00
fd2e0f8500 Add filter and search to Query entities
[PREMIUM-225]
2023-07-19 14:57:26 +02:00
9afe3655b0 Remove permission check and fall back to default
[PREMIUM-225]
2023-07-19 14:57:26 +02:00
29e471a8d6 Add filters
[PREMIUM-225]
2023-07-19 14:57:26 +02:00
730944e2fc Add filter and search to CustomQuery
[PREMIUM-225]
2023-07-19 14:57:26 +02:00
1f68c8d02c Add lock and grey out subscribers tab
[PREMIUM-225]
2023-07-19 14:57:26 +02:00
e05e62035e Add subscriber table
[PREMIUM-225]
2023-07-19 14:57:26 +02:00
e95b8dcc49 Add subscriber data to store
[PREMIUM-225]
2023-07-19 14:57:26 +02:00
700d21445a Add subscriber types
[PREMIUM-225]
2023-07-19 14:57:26 +02:00
a33422123a Add subscriber backend
[PREMIUM-225]
2023-07-19 14:57:26 +02:00
5841c09a31 Add data to SendEmail step
[MAILPOET-5091]
2023-07-19 14:57:26 +02:00
8242fbcbbb Ensure we do not divide by zero
[MAILPOET-5091]
2023-07-18 13:03:16 +02:00
a9084d5326 email property can be undefined in Badge
[MAILPOET-5091]
2023-07-18 13:03:16 +02:00
e194a4d04b Add indicies to automation_runs and automation_run_logs table
[MAILPOET-5091]
2023-07-18 13:03:16 +02:00
b566959c4a Use new <Badge /> element in email statistic panel
[MAILPOET-5091]
2023-07-18 13:03:16 +02:00
0490a2b9a8 Extract new <Badge /> element from <Cell />
[MAILPOET-5091]
2023-07-18 13:03:16 +02:00
36df49836c Extract calculatePercentage method
[MAILPOET-5091]
2023-07-18 13:03:16 +02:00
7be598c40f Add individual items for step more control
[MAILPOET-5091]
2023-07-18 13:03:16 +02:00
07b6d61c28 Move hooks
[MAILPOET-5091]
2023-07-18 13:03:16 +02:00
4895e9eefd Link into subscribers tab
[MAILPOET-5091]
2023-07-18 13:03:16 +02:00
fc68729ff6 Show notice when tree is inconsistent
[MAILPOET-5091]
2023-07-18 13:03:16 +02:00
cfd53cc495 Add data to SendEmail step
[MAILPOET-5091]
2023-07-18 13:03:16 +02:00
768ed43f9e Create new tab navigation helper
[MAILPOET-5091]
2023-07-18 13:03:16 +02:00
a5e00a08ef Add a hook to allow for individual content in the step
[MAILPOET-5091]
2023-07-18 13:03:16 +02:00
00d7d7c7bb Reorder sections
[MAILPOET-5091]
2023-07-18 13:03:16 +02:00
fb057af6f8 Fix ordering of next_step
We are actually waiting for the next_step to be executed and therefore we do not need to map the step to the previous one.

[MAILPOET-5091]
2023-07-18 13:03:16 +02:00
ad5ee0bebe Show completed values in seperator step
[MAILPOET-5091]
2023-07-18 13:03:16 +02:00
4199822aff Return flow data
[MAILPOET-5091]
2023-07-18 13:03:16 +02:00
2a6af4a77b Transform log data for response
[MAILPOET-5091]
2023-07-18 13:03:16 +02:00
8d8fcf3164 Query log statistics for automation in a timeframe
[MAILPOET-5091]
2023-07-18 13:03:16 +02:00
dca7a0d974 Add StepFooter for analytics
[MAILPOET-5091]
2023-07-18 13:03:16 +02:00
790385a0c7 Adjust type of flow data 2023-07-18 13:03:16 +02:00
7f3de49baa Filter step footer
[MAILPOET-5091]
2023-07-18 13:03:16 +02:00
74657f990d Use context information in filter
[MAILPOET-5091]
2023-07-18 13:03:16 +02:00
c160c04819 Add context to filters
[MAILPOET-5091]
2023-07-18 13:03:16 +02:00
fd04d005d0 Return Step data
[MAILPOET-5091]
2023-07-18 13:03:16 +02:00
8d9133b79e Add StepStatisticController to map step data
[MAILPOET-5091]
2023-07-18 13:03:16 +02:00
d2fce2014a Query statistic how many runs in a timeframe are at which step
[MAILPOET-5091]
2023-07-18 13:03:16 +02:00
ae57f81c14 Query short statistics for automation
[MAILPOET-5091]
2023-07-18 13:03:16 +02:00
b80875d268 Enable querying statistics in a given timeframe
[MAILPOET-5091]
2023-07-18 13:03:16 +02:00
99f5d64d61 Allow for adding already generated statistics
[MAILPOET-5091]
2023-07-18 13:03:16 +02:00
4c0bd1815b Query Automation in correct timespan
[MAILPOET-5091]
2023-07-18 13:03:16 +02:00
8b81016814 Add getAutomationsInTimespan method to controller
[MAILPOET-5091]
2023-07-18 13:03:16 +02:00
5e4631ec9d Add AutomationPlaceholder
[MAILPOET-5091]
2023-07-18 13:03:16 +02:00
9363e8714a Add AutomationFlow section and allow for custom update callbacks
[MAILPOET-5091]
2023-07-18 13:03:16 +02:00
bd17cf98bf Use editorState for information about the automation
[MAILPOET-5091]
2023-07-18 13:03:16 +02:00
854f0e5315 Raise priority of StatisticSeparator filter
[MAILPOET-5091]
2023-07-18 13:03:16 +02:00
d112bb81cb Render Automation in analytics
[MAILPOET-5091]
2023-07-18 13:03:16 +02:00
82270e074e Initialize editor store in analytics
[MAILPOET-5091]
2023-07-18 13:03:16 +02:00
439ccf4a1b Add action to update whole automation
[MAILPOET-5091]
2023-07-18 13:03:16 +02:00
23b15f419c Add view context to Automation component
[MAILPOET-5091]
2023-07-18 13:03:16 +02:00
19de902c3f Load editor styles in analytics
[MAILPOET-5091]
2023-07-18 13:03:16 +02:00
f7689232b2 Move user roles from global window to context
[MAILPOET-5091]
2023-07-18 13:03:16 +02:00
88702d65d8 Release 4.21.0 2023-07-18 11:41:26 +01:00
314 changed files with 10423 additions and 3476 deletions

22
.pnpmfile.cjs Normal file
View 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,
},
};

View File

@ -1 +1,2 @@
engine-strict=true
# we can set this back to "true" once @woocommerce/components have less restrictive definitions
engine-strict=false

View File

@ -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");

View File

@ -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;

View File

@ -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;
}
}

View File

@ -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%;
}

View File

@ -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;
}

View File

@ -27,6 +27,6 @@
}
}
.woocommerce-summary__item {
.mailpoet-automation-analytics .woocommerce-summary__item {
box-sizing: border-box;
}

View File

@ -2,3 +2,5 @@
@import 'general';
@import 'email';
@import 'orders';
@import 'automation_flow';
@import 'subscribers';

View File

@ -8,4 +8,5 @@
line-height: 16px;
margin-right: 4px;
padding: 4px 8px;
white-space: nowrap;
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -0,0 +1,3 @@
#mailpoet_automation_editor .interface-interface-skeleton__header {
border-bottom: none;
}

View File

@ -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;
}
}

View File

@ -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';

View File

@ -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;
}

View File

@ -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;

View File

@ -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 {

View File

@ -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';

View File

@ -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

View File

@ -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')}

View File

@ -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) {

View File

@ -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(

View File

@ -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>
);

View File

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

View File

@ -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;

View File

@ -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;
}

View File

@ -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

View File

@ -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} />}

View File

@ -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',

View File

@ -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,

View File

@ -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 {

View File

@ -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;
}

View File

@ -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;

View File

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

View File

@ -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">

View File

@ -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,
)}

View File

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

View File

@ -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,
);
}

View File

@ -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,
};
}

View File

@ -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" />
</>
);
}

View File

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

View File

@ -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 didnt 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>
</>
);
}

View File

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

View File

@ -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>
);

View File

@ -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

View File

@ -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,
},
];
]);
});
}

View File

@ -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;
}

View File

@ -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,
});
}

View File

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

View File

@ -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>

View File

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

View File

@ -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}&nbsp;
<span className="quantity">({item.quantity}&times;)</span>
</Fragment>
</span>
))
: [];

View File

@ -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>
&nbsp;
{__(
'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
}

View File

@ -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,
},
{

View File

@ -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>
);

View File

@ -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}</>;
}

View File

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

View File

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

View File

@ -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,
},
]);
}

View File

@ -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;
}

View File

@ -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 {

View File

@ -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);

View File

@ -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();
}

View File

@ -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',
};
}

View File

@ -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',

View File

@ -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;
}

View File

@ -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;
}
};

View File

@ -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: [],
};

View File

@ -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: [],
},
},
};

View File

@ -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;
}

View File

@ -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;
};
};

View File

@ -8,6 +8,7 @@ type Segment = FormTokenItem & {
export type Context = {
segments?: Segment[];
userRoles?: FormTokenItem[];
};
export const getContext = (): Context =>

View File

@ -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 (

View File

@ -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"

View File

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

View File

@ -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[])
: [];

View File

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

View File

@ -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>

View File

@ -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}

View File

@ -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;

View File

@ -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(
__(

View File

@ -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(
'Slidein',
'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>
{__('Cant 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 />}

View File

@ -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;
}

View File

@ -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 {

View File

@ -0,0 +1 @@
module.exports = {};

View File

@ -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: {

View File

@ -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(

View File

@ -119,7 +119,9 @@ const stepsListingHeading = (
{' '}
</h1>
<div className="mailpoet-flex-grow" />
{emailType !== 'automation' && <TutorialIcon />}
{!['automation', 'automation_transactional'].includes(emailType) && (
<TutorialIcon />
)}
</div>
);
};

View File

@ -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 };

View File

@ -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';

View File

@ -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 = {

View File

@ -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

View File

@ -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 = {

View File

@ -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,
);

View File

@ -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;
}

View File

@ -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