mirror of
https://github.com/knadh/listmonk.git
synced 2024-09-20 07:16:33 +08:00
Add campaign analytics APIs and UI
This commit is contained in:
parent
3135bfc12a
commit
61e88681ed
104
cmd/campaigns.go
104
cmd/campaigns.go
|
@ -14,6 +14,7 @@ import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gofrs/uuid"
|
"github.com/gofrs/uuid"
|
||||||
|
"github.com/jmoiron/sqlx"
|
||||||
"github.com/knadh/listmonk/internal/subimporter"
|
"github.com/knadh/listmonk/internal/subimporter"
|
||||||
"github.com/knadh/listmonk/models"
|
"github.com/knadh/listmonk/models"
|
||||||
"github.com/labstack/echo"
|
"github.com/labstack/echo"
|
||||||
|
@ -49,6 +50,17 @@ type campaignContentReq struct {
|
||||||
To string `json:"to"`
|
To string `json:"to"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type campCountStats struct {
|
||||||
|
CampaignID int `db:"campaign_id" json:"campaign_id"`
|
||||||
|
Count int `db:"count" json:"count"`
|
||||||
|
Timestamp time.Time `db:"timestamp" json:"timestamp"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type campTopLinks struct {
|
||||||
|
URL string `db:"url" json:"url"`
|
||||||
|
Count int `db:"count" json:"count"`
|
||||||
|
}
|
||||||
|
|
||||||
type campaignStats struct {
|
type campaignStats struct {
|
||||||
ID int `db:"id" json:"id"`
|
ID int `db:"id" json:"id"`
|
||||||
Status string `db:"status" json:"status"`
|
Status string `db:"status" json:"status"`
|
||||||
|
@ -96,23 +108,11 @@ func handleGetCampaigns(c echo.Context) error {
|
||||||
if id > 0 {
|
if id > 0 {
|
||||||
single = true
|
single = true
|
||||||
}
|
}
|
||||||
if query != "" {
|
|
||||||
query = `%` +
|
|
||||||
string(regexFullTextQuery.ReplaceAll([]byte(query), []byte("&"))) + `%`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort params.
|
queryStr, stmt := makeCampaignQuery(query, orderBy, order, app.queries.QueryCampaigns)
|
||||||
if !strSliceContains(orderBy, campaignQuerySortFields) {
|
|
||||||
orderBy = "created_at"
|
|
||||||
}
|
|
||||||
if order != sortAsc && order != sortDesc {
|
|
||||||
order = sortDesc
|
|
||||||
}
|
|
||||||
|
|
||||||
stmt := fmt.Sprintf(app.queries.QueryCampaigns, orderBy, order)
|
|
||||||
|
|
||||||
// Unsafe to ignore scanning fields not present in models.Campaigns.
|
// Unsafe to ignore scanning fields not present in models.Campaigns.
|
||||||
if err := db.Select(&out.Results, stmt, id, pq.StringArray(status), query, pg.Offset, pg.Limit); err != nil {
|
if err := db.Select(&out.Results, stmt, id, pq.StringArray(status), queryStr, pg.Offset, pg.Limit); err != nil {
|
||||||
app.log.Printf("error fetching campaigns: %v", err)
|
app.log.Printf("error fetching campaigns: %v", err)
|
||||||
return echo.NewHTTPError(http.StatusInternalServerError,
|
return echo.NewHTTPError(http.StatusInternalServerError,
|
||||||
app.i18n.Ts("globals.messages.errorFetching",
|
app.i18n.Ts("globals.messages.errorFetching",
|
||||||
|
@ -605,6 +605,64 @@ func handleTestCampaign(c echo.Context) error {
|
||||||
return c.JSON(http.StatusOK, okResp{true})
|
return c.JSON(http.StatusOK, okResp{true})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleGetCampaignViewAnalytics retrieves view counts for a campaign.
|
||||||
|
func handleGetCampaignViewAnalytics(c echo.Context) error {
|
||||||
|
var (
|
||||||
|
app = c.Get("app").(*App)
|
||||||
|
|
||||||
|
typ = c.Param("type")
|
||||||
|
from = c.QueryParams().Get("from")
|
||||||
|
to = c.QueryParams().Get("to")
|
||||||
|
)
|
||||||
|
|
||||||
|
ids, err := parseStringIDs(c.Request().URL.Query()["id"])
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusBadRequest,
|
||||||
|
app.i18n.Ts("globals.messages.errorInvalidIDs", "error", err.Error()))
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return echo.NewHTTPError(http.StatusBadRequest,
|
||||||
|
app.i18n.Ts("globals.messages.missingFields", "name", "`id`"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pick campaign view counts or click counts.
|
||||||
|
var stmt *sqlx.Stmt
|
||||||
|
switch typ {
|
||||||
|
case "views":
|
||||||
|
stmt = app.queries.GetCampaignViewCounts
|
||||||
|
case "clicks":
|
||||||
|
stmt = app.queries.GetCampaignClickCounts
|
||||||
|
case "bounces":
|
||||||
|
stmt = app.queries.GetCampaignBounceCounts
|
||||||
|
case "links":
|
||||||
|
out := make([]campTopLinks, 0)
|
||||||
|
if err := app.queries.GetCampaignLinkCounts.Select(&out, pq.Int64Array(ids), from, to); err != nil {
|
||||||
|
app.log.Printf("error fetching campaign %s: %v", typ, err)
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError,
|
||||||
|
app.i18n.Ts("globals.messages.errorFetching",
|
||||||
|
"name", "{globals.terms.analytics}", "error", pqErrMsg(err)))
|
||||||
|
}
|
||||||
|
return c.JSON(http.StatusOK, okResp{out})
|
||||||
|
default:
|
||||||
|
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidData"))
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strHasLen(from, 10, 30) || !strHasLen(to, 10, 30) {
|
||||||
|
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("analytics.invalidDates"))
|
||||||
|
}
|
||||||
|
|
||||||
|
out := make([]campCountStats, 0)
|
||||||
|
if err := stmt.Select(&out, pq.Int64Array(ids), from, to); err != nil {
|
||||||
|
app.log.Printf("error fetching campaign %s: %v", typ, err)
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError,
|
||||||
|
app.i18n.Ts("globals.messages.errorFetching",
|
||||||
|
"name", "{globals.terms.analytics}", "error", pqErrMsg(err)))
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(http.StatusOK, okResp{out})
|
||||||
|
}
|
||||||
|
|
||||||
// sendTestMessage takes a campaign and a subsriber and sends out a sample campaign message.
|
// sendTestMessage takes a campaign and a subsriber and sends out a sample campaign message.
|
||||||
func sendTestMessage(sub models.Subscriber, camp *models.Campaign, app *App) error {
|
func sendTestMessage(sub models.Subscriber, camp *models.Campaign, app *App) error {
|
||||||
if err := camp.CompileTemplate(app.manager.TemplateFuncs(camp)); err != nil {
|
if err := camp.CompileTemplate(app.manager.TemplateFuncs(camp)); err != nil {
|
||||||
|
@ -719,3 +777,21 @@ func makeOptinCampaignMessage(o campaignReq, app *App) (campaignReq, error) {
|
||||||
o.Body = b.String()
|
o.Body = b.String()
|
||||||
return o, nil
|
return o, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// makeCampaignQuery cleans an optional campaign search string and prepares the
|
||||||
|
// campaign SQL statement (string) and returns them.
|
||||||
|
func makeCampaignQuery(q, orderBy, order, query string) (string, string) {
|
||||||
|
if q != "" {
|
||||||
|
q = `%` + string(regexFullTextQuery.ReplaceAll([]byte(q), []byte("&"))) + `%`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort params.
|
||||||
|
if !strSliceContains(orderBy, campaignQuerySortFields) {
|
||||||
|
orderBy = "created_at"
|
||||||
|
}
|
||||||
|
if order != sortAsc && order != sortDesc {
|
||||||
|
order = sortDesc
|
||||||
|
}
|
||||||
|
|
||||||
|
return q, fmt.Sprintf(query, orderBy, order)
|
||||||
|
}
|
||||||
|
|
|
@ -101,6 +101,7 @@ func registerHTTPHandlers(e *echo.Echo, app *App) {
|
||||||
g.GET("/api/campaigns", handleGetCampaigns)
|
g.GET("/api/campaigns", handleGetCampaigns)
|
||||||
g.GET("/api/campaigns/running/stats", handleGetRunningCampaignStats)
|
g.GET("/api/campaigns/running/stats", handleGetRunningCampaignStats)
|
||||||
g.GET("/api/campaigns/:id", handleGetCampaigns)
|
g.GET("/api/campaigns/:id", handleGetCampaigns)
|
||||||
|
g.GET("/api/campaigns/analytics/:type", handleGetCampaignViewAnalytics)
|
||||||
g.GET("/api/campaigns/:id/preview", handlePreviewCampaign)
|
g.GET("/api/campaigns/:id/preview", handlePreviewCampaign)
|
||||||
g.POST("/api/campaigns/:id/preview", handlePreviewCampaign)
|
g.POST("/api/campaigns/:id/preview", handlePreviewCampaign)
|
||||||
g.POST("/api/campaigns/:id/content", handleCampaignContent)
|
g.POST("/api/campaigns/:id/content", handleCampaignContent)
|
||||||
|
|
|
@ -57,6 +57,10 @@ type Queries struct {
|
||||||
GetCampaignForPreview *sqlx.Stmt `query:"get-campaign-for-preview"`
|
GetCampaignForPreview *sqlx.Stmt `query:"get-campaign-for-preview"`
|
||||||
GetCampaignStats *sqlx.Stmt `query:"get-campaign-stats"`
|
GetCampaignStats *sqlx.Stmt `query:"get-campaign-stats"`
|
||||||
GetCampaignStatus *sqlx.Stmt `query:"get-campaign-status"`
|
GetCampaignStatus *sqlx.Stmt `query:"get-campaign-status"`
|
||||||
|
GetCampaignViewCounts *sqlx.Stmt `query:"get-campaign-view-counts"`
|
||||||
|
GetCampaignClickCounts *sqlx.Stmt `query:"get-campaign-click-counts"`
|
||||||
|
GetCampaignBounceCounts *sqlx.Stmt `query:"get-campaign-bounce-counts"`
|
||||||
|
GetCampaignLinkCounts *sqlx.Stmt `query:"get-campaign-link-counts"`
|
||||||
NextCampaigns *sqlx.Stmt `query:"next-campaigns"`
|
NextCampaigns *sqlx.Stmt `query:"next-campaigns"`
|
||||||
NextCampaignSubscribers *sqlx.Stmt `query:"next-campaign-subscribers"`
|
NextCampaignSubscribers *sqlx.Stmt `query:"next-campaign-subscribers"`
|
||||||
GetOneCampaignSubscriber *sqlx.Stmt `query:"get-one-campaign-subscriber"`
|
GetOneCampaignSubscriber *sqlx.Stmt `query:"get-one-campaign-subscriber"`
|
||||||
|
|
|
@ -409,7 +409,7 @@ func handleBlocklistSubscribers(c echo.Context) error {
|
||||||
var req subQueryReq
|
var req subQueryReq
|
||||||
if err := c.Bind(&req); err != nil {
|
if err := c.Bind(&req); err != nil {
|
||||||
return echo.NewHTTPError(http.StatusBadRequest,
|
return echo.NewHTTPError(http.StatusBadRequest,
|
||||||
app.i18n.Ts("subscribers.errorInvalidIDs", "error", err.Error()))
|
app.i18n.Ts("globals.messages.errorInvalidIDs", "error", err.Error()))
|
||||||
}
|
}
|
||||||
if len(req.SubscriberIDs) == 0 {
|
if len(req.SubscriberIDs) == 0 {
|
||||||
return echo.NewHTTPError(http.StatusBadRequest,
|
return echo.NewHTTPError(http.StatusBadRequest,
|
||||||
|
@ -449,7 +449,7 @@ func handleManageSubscriberLists(c echo.Context) error {
|
||||||
var req subQueryReq
|
var req subQueryReq
|
||||||
if err := c.Bind(&req); err != nil {
|
if err := c.Bind(&req); err != nil {
|
||||||
return echo.NewHTTPError(http.StatusBadRequest,
|
return echo.NewHTTPError(http.StatusBadRequest,
|
||||||
app.i18n.Ts("subscribers.errorInvalidIDs", "error", err.Error()))
|
app.i18n.Ts("globals.messages.errorInvalidIDs", "error", err.Error()))
|
||||||
}
|
}
|
||||||
if len(req.SubscriberIDs) == 0 {
|
if len(req.SubscriberIDs) == 0 {
|
||||||
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("subscribers.errorNoIDs"))
|
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("subscribers.errorNoIDs"))
|
||||||
|
@ -505,7 +505,7 @@ func handleDeleteSubscribers(c echo.Context) error {
|
||||||
i, err := parseStringIDs(c.Request().URL.Query()["id"])
|
i, err := parseStringIDs(c.Request().URL.Query()["id"])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return echo.NewHTTPError(http.StatusBadRequest,
|
return echo.NewHTTPError(http.StatusBadRequest,
|
||||||
app.i18n.Ts("subscribers.errorInvalidIDs", "error", err.Error()))
|
app.i18n.Ts("globals.messages.errorInvalidIDs", "error", err.Error()))
|
||||||
}
|
}
|
||||||
if len(i) == 0 {
|
if len(i) == 0 {
|
||||||
return echo.NewHTTPError(http.StatusBadRequest,
|
return echo.NewHTTPError(http.StatusBadRequest,
|
||||||
|
|
|
@ -80,6 +80,10 @@
|
||||||
<b-menu-item :to="{name: 'templates'}" tag="router-link"
|
<b-menu-item :to="{name: 'templates'}" tag="router-link"
|
||||||
:active="activeItem.templates" data-cy="templates"
|
:active="activeItem.templates" data-cy="templates"
|
||||||
icon="file-image-outline" :label="$t('globals.terms.templates')"></b-menu-item>
|
icon="file-image-outline" :label="$t('globals.terms.templates')"></b-menu-item>
|
||||||
|
|
||||||
|
<b-menu-item :to="{name: 'campaignAnalytics'}" tag="router-link"
|
||||||
|
:active="activeItem.analytics" data-cy="analytics"
|
||||||
|
icon="chart-bar" :label="$t('globals.terms.analytics')"></b-menu-item>
|
||||||
</b-menu-item><!-- campaigns -->
|
</b-menu-item><!-- campaigns -->
|
||||||
|
|
||||||
<b-menu-item :expanded="activeGroup.settings"
|
<b-menu-item :expanded="activeGroup.settings"
|
||||||
|
|
|
@ -181,6 +181,18 @@ export const getCampaignStats = async () => http.get('/api/campaigns/running/sta
|
||||||
export const createCampaign = async (data) => http.post('/api/campaigns', data,
|
export const createCampaign = async (data) => http.post('/api/campaigns', data,
|
||||||
{ loading: models.campaigns });
|
{ loading: models.campaigns });
|
||||||
|
|
||||||
|
export const getCampaignViewCounts = async (params) => http.get('/api/campaigns/analytics/views',
|
||||||
|
{ params, loading: models.campaigns });
|
||||||
|
|
||||||
|
export const getCampaignClickCounts = async (params) => http.get('/api/campaigns/analytics/clicks',
|
||||||
|
{ params, loading: models.campaigns });
|
||||||
|
|
||||||
|
export const getCampaignBounceCounts = async (params) => http.get('/api/campaigns/analytics/bounces',
|
||||||
|
{ params, loading: models.campaigns });
|
||||||
|
|
||||||
|
export const getCampaignLinkCounts = async (params) => http.get('/api/campaigns/analytics/links',
|
||||||
|
{ params, loading: models.campaigns });
|
||||||
|
|
||||||
export const convertCampaignContent = async (data) => http.post(`/api/campaigns/${data.id}/content`, data,
|
export const convertCampaignContent = async (data) => http.post(`/api/campaigns/${data.id}/content`, data,
|
||||||
{ loading: models.campaigns });
|
{ loading: models.campaigns });
|
||||||
|
|
||||||
|
|
|
@ -262,10 +262,17 @@ body.is-noscroll .b-sidebar {
|
||||||
padding: 15px 10px;
|
padding: 15px 10px;
|
||||||
border-color: $grey-lightest;
|
border-color: $grey-lightest;
|
||||||
}
|
}
|
||||||
|
|
||||||
.actions a, .actions .a {
|
.actions a, .actions .a {
|
||||||
margin: 0 10px;
|
margin: 0 10px;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
}
|
}
|
||||||
|
.actions a[data-disabled],
|
||||||
|
.actions .icon[data-disabled] {
|
||||||
|
pointer-events: none;
|
||||||
|
cursor: not-allowed;
|
||||||
|
color: $grey-light;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Modal */
|
/* Modal */
|
||||||
|
@ -294,16 +301,37 @@ body.is-noscroll .b-sidebar {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.autocomplete .dropdown-content {
|
.autocomplete {
|
||||||
background-color: $white-ter;
|
.dropdown-content {
|
||||||
|
background-color: $white-bis;
|
||||||
|
}
|
||||||
|
a.dropdown-item {
|
||||||
|
&:hover, &.is-hovered {
|
||||||
|
background-color: $grey-lightest;
|
||||||
|
color: $primary;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.input, .taginput .taginput-container.is-focusable, .textarea {
|
.input, .taginput .taginput-container.is-focusable, .textarea {
|
||||||
// box-shadow: inset 2px 2px 0px $white-ter;
|
|
||||||
box-shadow: 2px 2px 0 $white-ter;
|
box-shadow: 2px 2px 0 $white-ter;
|
||||||
border: 1px solid $grey-lighter;
|
border: 1px solid $grey-lighter;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.input {
|
||||||
|
height: auto;
|
||||||
|
padding: 10px 12px;
|
||||||
|
}
|
||||||
|
.control.has-icons-left .icon.is-left {
|
||||||
|
height: 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button {
|
||||||
|
height: auto;
|
||||||
|
padding: 10px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/* Form fields */
|
/* Form fields */
|
||||||
.field {
|
.field {
|
||||||
&:not(:last-child) {
|
&:not(:last-child) {
|
||||||
|
@ -368,10 +396,10 @@ body.is-noscroll .b-sidebar {
|
||||||
}
|
}
|
||||||
&.public, &.running {
|
&.public, &.running {
|
||||||
$color: $primary;
|
$color: $primary;
|
||||||
color: $color;
|
color: lighten($color, 20%);;
|
||||||
background: #e6f7ff;
|
background: #e6f7ff;
|
||||||
border: 1px solid lighten($color, 37%);
|
border: 1px solid lighten($color, 42%);
|
||||||
box-shadow: 1px 1px 0 lighten($color, 25%);
|
box-shadow: 1px 1px 0 lighten($color, 42%);
|
||||||
}
|
}
|
||||||
&.finished, &.enabled {
|
&.finished, &.enabled {
|
||||||
$color: $green;
|
$color: $green;
|
||||||
|
@ -491,25 +519,22 @@ section.import {
|
||||||
/* Campaigns page */
|
/* Campaigns page */
|
||||||
section.campaigns {
|
section.campaigns {
|
||||||
table tbody {
|
table tbody {
|
||||||
tr.running {
|
.spinner {
|
||||||
background: lighten(#1890ff, 43%);
|
margin-left: 10px;
|
||||||
td {
|
.loading-overlay .loading-icon::after {
|
||||||
border-bottom: 1px solid lighten(#1890ff, 30%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.spinner {
|
|
||||||
margin-left: 10px;
|
|
||||||
}
|
|
||||||
.spinner .loading-overlay .loading-icon::after {
|
|
||||||
border-bottom-color: lighten(#1890ff, 30%);
|
border-bottom-color: lighten(#1890ff, 30%);
|
||||||
border-left-color: lighten(#1890ff, 30%);
|
border-left-color: lighten(#1890ff, 30%);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
td {
|
tr.running {
|
||||||
&.status .spinner {
|
background: lighten(#1890ff, 43%);
|
||||||
margin-left: 10px;
|
td {
|
||||||
|
border-bottom: 1px solid lighten(#1890ff, 30%);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
td {
|
||||||
.tags {
|
.tags {
|
||||||
margin-top: 5px;
|
margin-top: 5px;
|
||||||
}
|
}
|
||||||
|
@ -519,15 +544,8 @@ section.campaigns {
|
||||||
}
|
}
|
||||||
|
|
||||||
&.lists ul {
|
&.lists ul {
|
||||||
font-size: $size-7;
|
// font-size: $size-7;
|
||||||
list-style-type: circle;
|
list-style-type: circle;
|
||||||
|
|
||||||
a {
|
|
||||||
color: $grey-dark;
|
|
||||||
&:hover {
|
|
||||||
color: $primary;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.fields {
|
.fields {
|
||||||
|
@ -555,6 +573,26 @@ section.campaigns {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
section.analytics {
|
||||||
|
.charts {
|
||||||
|
position: relative;
|
||||||
|
min-height: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart {
|
||||||
|
margin-bottom: 45px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.donut-container {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.donut {
|
||||||
|
bottom: 0px;
|
||||||
|
right: 0px;
|
||||||
|
position: absolute !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* Campaign / template preview popup */
|
/* Campaign / template preview popup */
|
||||||
.preview {
|
.preview {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
@ -702,11 +740,10 @@ section.campaign {
|
||||||
}
|
}
|
||||||
|
|
||||||
.c3-tooltip {
|
.c3-tooltip {
|
||||||
border: 0;
|
@extend .box;
|
||||||
background-color: #fff;
|
padding: 10px;
|
||||||
empty-cells: show;
|
empty-cells: show;
|
||||||
box-shadow: none;
|
opacity: 0.95;
|
||||||
opacity: 0.9;
|
|
||||||
|
|
||||||
tr {
|
tr {
|
||||||
border: 0;
|
border: 0;
|
||||||
|
|
|
@ -71,6 +71,12 @@ const routes = [
|
||||||
meta: { title: 'Templates', group: 'campaigns' },
|
meta: { title: 'Templates', group: 'campaigns' },
|
||||||
component: () => import(/* webpackChunkName: "main" */ '../views/Templates.vue'),
|
component: () => import(/* webpackChunkName: "main" */ '../views/Templates.vue'),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/campaigns/analytics',
|
||||||
|
name: 'campaignAnalytics',
|
||||||
|
meta: { title: 'Campaign analytics', group: 'campaigns' },
|
||||||
|
component: () => import(/* webpackChunkName: "main" */ '../views/CampaignAnalytics.vue'),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/campaigns/:id',
|
path: '/campaigns/:id',
|
||||||
name: 'campaign',
|
name: 'campaign',
|
||||||
|
|
|
@ -78,6 +78,23 @@ export default class Utils {
|
||||||
return out.toFixed(2) + pfx;
|
return out.toFixed(2) + pfx;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Parse one or more numeric ids as query params and return as an array of ints.
|
||||||
|
parseQueryIDs = (ids) => {
|
||||||
|
if (!ids) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof ids === 'string') {
|
||||||
|
return [parseInt(ids, 10)];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof ids === 'number') {
|
||||||
|
return [parseInt(ids, 10)];
|
||||||
|
}
|
||||||
|
|
||||||
|
return ids.map((id) => parseInt(id, 10));
|
||||||
|
}
|
||||||
|
|
||||||
// https://stackoverflow.com/a/12034334
|
// https://stackoverflow.com/a/12034334
|
||||||
escapeHTML = (html) => html.replace(/[&<>"'`=/]/g, (s) => htmlEntities[s]);
|
escapeHTML = (html) => html.replace(/[&<>"'`=/]/g, (s) => htmlEntities[s]);
|
||||||
|
|
||||||
|
|
429
frontend/src/views/CampaignAnalytics.vue
Normal file
429
frontend/src/views/CampaignAnalytics.vue
Normal file
|
@ -0,0 +1,429 @@
|
||||||
|
<template>
|
||||||
|
<section class="analytics content relative">
|
||||||
|
<h1 class="title is-4">{{ $t('analytics.title') }}</h1>
|
||||||
|
<hr />
|
||||||
|
|
||||||
|
<form @submit.prevent="onSubmit">
|
||||||
|
<div class="columns">
|
||||||
|
<div class="column is-6">
|
||||||
|
<b-field :label="$t('globals.terms.campaigns')" label-position="on-border">
|
||||||
|
<b-taginput v-model="form.campaigns" :data="queriedCampaigns" name="campaigns" ellipsis
|
||||||
|
icon="tag-outline" :placeholder="$t('globals.terms.campaigns')"
|
||||||
|
autocomplete :allow-new="false" :before-adding="isCampaignSelected"
|
||||||
|
@typing="queryCampaigns" field="name" :loading="isSearchLoading"></b-taginput>
|
||||||
|
</b-field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="column is-5">
|
||||||
|
<div class="columns">
|
||||||
|
<div class="column is-6">
|
||||||
|
<b-field data-cy="from" :label="$t('analytics.fromDate')" label-position="on-border">
|
||||||
|
<b-datetimepicker
|
||||||
|
v-model="form.from"
|
||||||
|
icon="calendar-clock"
|
||||||
|
:timepicker="{ hourFormat: '24' }"
|
||||||
|
:datetime-formatter="formatDateTime" @input="onFromDateChange" />
|
||||||
|
</b-field>
|
||||||
|
</div>
|
||||||
|
<div class="column is-6">
|
||||||
|
<b-field data-cy="to" :label="$t('analytics.toDate')" label-position="on-border">
|
||||||
|
<b-datetimepicker
|
||||||
|
v-model="form.to"
|
||||||
|
icon="calendar-clock"
|
||||||
|
:timepicker="{ hourFormat: '24' }"
|
||||||
|
:datetime-formatter="formatDateTime" @input="onToDateChange" />
|
||||||
|
</b-field>
|
||||||
|
</div>
|
||||||
|
</div><!-- columns -->
|
||||||
|
</div><!-- columns -->
|
||||||
|
|
||||||
|
<div class="column is-1">
|
||||||
|
<b-button native-type="submit" type="is-primary" icon-left="magnify"
|
||||||
|
:disabled="form.campaigns.length === 0" data-cy="btn-search"></b-button>
|
||||||
|
</div>
|
||||||
|
</div><!-- columns -->
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<section class="charts mt-5">
|
||||||
|
<div class="chart columns" v-for="(v, k) in charts" :key="k">
|
||||||
|
<div class="column is-9">
|
||||||
|
<b-loading v-if="v.loading" :active="v.loading" :is-full-page="false" />
|
||||||
|
<h4 v-if="v.chart !== null">{{ v.name }} ({{ counts[k] }})</h4>
|
||||||
|
<div :ref="`chart-${k}`" :id="`chart-${k}`"></div>
|
||||||
|
</div>
|
||||||
|
<div class="column is-2 donut-container">
|
||||||
|
<div :ref="`donut-${k}`" :id="`donut-${k}`" class="donut"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="css">
|
||||||
|
@import "~c3/c3.css";
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import Vue from 'vue';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import c3 from 'c3';
|
||||||
|
import { colors } from '../constants';
|
||||||
|
|
||||||
|
const chartColorRed = '#ee7d5b';
|
||||||
|
const chartColors = [
|
||||||
|
colors.primary,
|
||||||
|
'#FFB50D',
|
||||||
|
'#41AC9C',
|
||||||
|
chartColorRed,
|
||||||
|
'#7FC7BC',
|
||||||
|
'#3a82d6',
|
||||||
|
'#688ED9',
|
||||||
|
'#FFC43D',
|
||||||
|
];
|
||||||
|
|
||||||
|
export default Vue.extend({
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
isSearchLoading: false,
|
||||||
|
queriedCampaigns: [],
|
||||||
|
|
||||||
|
// Data for each view.
|
||||||
|
counts: {
|
||||||
|
views: 0,
|
||||||
|
clicks: 0,
|
||||||
|
bounces: 0,
|
||||||
|
links: 0,
|
||||||
|
},
|
||||||
|
charts: {
|
||||||
|
views: {
|
||||||
|
name: this.$t('campaigns.views'),
|
||||||
|
data: [],
|
||||||
|
fn: this.$api.getCampaignViewCounts,
|
||||||
|
chart: null,
|
||||||
|
chartFn: this.processLines,
|
||||||
|
donut: null,
|
||||||
|
donutFn: this.renderDonutChart,
|
||||||
|
loading: false,
|
||||||
|
},
|
||||||
|
|
||||||
|
clicks: {
|
||||||
|
name: this.$t('campaigns.clicks'),
|
||||||
|
data: [],
|
||||||
|
fn: this.$api.getCampaignClickCounts,
|
||||||
|
chart: null,
|
||||||
|
chartFn: this.processLines,
|
||||||
|
donut: null,
|
||||||
|
donutFn: this.renderDonutChart,
|
||||||
|
loading: false,
|
||||||
|
},
|
||||||
|
|
||||||
|
bounces: {
|
||||||
|
name: this.$t('globals.terms.bounces'),
|
||||||
|
data: [],
|
||||||
|
fn: this.$api.getCampaignBounceCounts,
|
||||||
|
chart: null,
|
||||||
|
chartFn: this.processLines,
|
||||||
|
donut: null,
|
||||||
|
donutFn: this.renderDonutChart,
|
||||||
|
donutColor: chartColorRed,
|
||||||
|
loading: false,
|
||||||
|
},
|
||||||
|
|
||||||
|
links: {
|
||||||
|
name: this.$t('analytics.links'),
|
||||||
|
data: [],
|
||||||
|
chart: null,
|
||||||
|
loading: false,
|
||||||
|
fn: this.$api.getCampaignLinkCounts,
|
||||||
|
chartFn: this.renderLinksChart,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
form: {
|
||||||
|
campaigns: [],
|
||||||
|
from: null,
|
||||||
|
to: null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
formatDateTime(s) {
|
||||||
|
return dayjs(s).format('YYYY-MM-DD HH:mm');
|
||||||
|
},
|
||||||
|
|
||||||
|
isCampaignSelected(camp) {
|
||||||
|
return !this.form.campaigns.find(({ id }) => id === camp.id);
|
||||||
|
},
|
||||||
|
|
||||||
|
onFromDateChange() {
|
||||||
|
if (this.form.from > this.form.to) {
|
||||||
|
this.form.to = dayjs(this.form.from).add(7, 'day').toDate();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
onToDateChange() {
|
||||||
|
if (this.form.from > this.form.to) {
|
||||||
|
this.form.from = dayjs(this.form.to).add(-7, 'day').toDate();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
renderLineChart(typ, data, el) {
|
||||||
|
const conf = {
|
||||||
|
bindto: el,
|
||||||
|
unload: true,
|
||||||
|
data: {
|
||||||
|
type: 'spline',
|
||||||
|
xs: {},
|
||||||
|
columns: [],
|
||||||
|
names: [],
|
||||||
|
colors: {},
|
||||||
|
empty: { label: { text: this.$t('globals.messages.emptyState') } },
|
||||||
|
},
|
||||||
|
axis: {
|
||||||
|
x: {
|
||||||
|
type: 'timeseries',
|
||||||
|
tick: {
|
||||||
|
format: '%Y-%m-%d %H:%M',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
legend: {
|
||||||
|
show: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add campaign data to the chart.
|
||||||
|
data.forEach((c, n) => {
|
||||||
|
if (c.data.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const x = `x${n + 1}`;
|
||||||
|
const d = `data${n + 1}`;
|
||||||
|
|
||||||
|
// data1, data2, datan => x1, x2, xn.
|
||||||
|
conf.data.xs[d] = x;
|
||||||
|
|
||||||
|
// Campaign name for each datan.
|
||||||
|
conf.data.names[d] = c.name;
|
||||||
|
|
||||||
|
// Dates for each xn.
|
||||||
|
conf.data.columns.push([x, ...c.data.map((v) => dayjs(v.timestamp))]);
|
||||||
|
|
||||||
|
// Counts for each datan.
|
||||||
|
conf.data.columns.push([d, ...c.data.map((v) => v.count)]);
|
||||||
|
|
||||||
|
// Colours for each datan.
|
||||||
|
conf.data.colors[d] = chartColors[n % data.length];
|
||||||
|
});
|
||||||
|
|
||||||
|
this.$nextTick(() => {
|
||||||
|
if (this.charts[typ].chart) {
|
||||||
|
this.charts[typ].chart.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.charts[typ].chart = c3.generate(conf);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
renderDonutChart(typ, camps, data) {
|
||||||
|
const conf = {
|
||||||
|
bindto: this.$refs[`donut-${typ}`][0],
|
||||||
|
unload: true,
|
||||||
|
data: {
|
||||||
|
type: 'gauge',
|
||||||
|
columns: [],
|
||||||
|
},
|
||||||
|
gauge: {
|
||||||
|
width: 15,
|
||||||
|
max: 100,
|
||||||
|
},
|
||||||
|
color: {
|
||||||
|
pattern: [],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
conf.gauge.max = camps.reduce((sum, c) => sum + c.sent, 0);
|
||||||
|
conf.data.columns.push([this.charts[typ].name, data.reduce((sum, d) => sum + d.count, 0)]);
|
||||||
|
conf.color.pattern.push(this.charts[typ].donutColor ?? chartColors[0]);
|
||||||
|
|
||||||
|
this.$nextTick(() => {
|
||||||
|
if (this.charts[typ].donut) {
|
||||||
|
this.charts[typ].donut.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (conf.gauge.max > 0) {
|
||||||
|
this.charts[typ].donut = c3.generate(conf);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
renderLinksChart(typ, camps, data) {
|
||||||
|
const conf = {
|
||||||
|
bindto: this.$refs[`chart-${typ}`][0],
|
||||||
|
unload: true,
|
||||||
|
data: {
|
||||||
|
type: 'bar',
|
||||||
|
x: 'x',
|
||||||
|
columns: [],
|
||||||
|
color: (c, d) => (typeof (d) === 'object' ? chartColors[d.index % data.length] : chartColors[0]),
|
||||||
|
empty: { label: { text: this.$t('globals.messages.emptyState') } },
|
||||||
|
onclick: (d) => {
|
||||||
|
window.open(data[d.index].url, '_blank', 'noopener noreferrer');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
bar: {
|
||||||
|
width: {
|
||||||
|
max: 30,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
axis: {
|
||||||
|
rotated: true,
|
||||||
|
x: {
|
||||||
|
type: 'category',
|
||||||
|
tick: {
|
||||||
|
multiline: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add link data to the chart.
|
||||||
|
// https://c3js.org/samples/axes_x_tick_rotate.html
|
||||||
|
conf.data.columns.push(['x', ...data.map((l) => {
|
||||||
|
try {
|
||||||
|
const u = new URL(l.url);
|
||||||
|
if (l.url.length > 80) {
|
||||||
|
return `${u.hostname}${u.pathname.substr(0, 50)}..`;
|
||||||
|
}
|
||||||
|
return u.hostname + u.pathname;
|
||||||
|
} catch {
|
||||||
|
return l.url;
|
||||||
|
}
|
||||||
|
})]);
|
||||||
|
conf.data.columns.push([this.$t('analytics.count'), ...data.map((l) => l.count)]);
|
||||||
|
|
||||||
|
this.$nextTick(() => {
|
||||||
|
if (this.charts[typ].chart) {
|
||||||
|
this.charts[typ].chart.destroy();
|
||||||
|
}
|
||||||
|
this.charts[typ].chart = c3.generate(conf);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
processLines(typ, camps, data) {
|
||||||
|
// Make a campaign id => camp lookup map to group incoming
|
||||||
|
// data by campaigns.
|
||||||
|
const campIDs = camps.reduce((obj, c) => {
|
||||||
|
const out = { ...obj };
|
||||||
|
out[c.id] = c;
|
||||||
|
return out;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
// Group individual data points per campaign id.
|
||||||
|
// {1: [...], 2: [...]}
|
||||||
|
const groups = data.reduce((obj, d) => {
|
||||||
|
const out = { ...obj };
|
||||||
|
if (!(d.campaignId in out)) {
|
||||||
|
out[d.campaignId] = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
out[d.campaignId].push(d);
|
||||||
|
return out;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
Object.keys(groups).forEach((k) => {
|
||||||
|
this.charts[typ].data.push({
|
||||||
|
name: campIDs[groups[k][0].campaignId].name,
|
||||||
|
data: groups[k],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
this.$nextTick(() => {
|
||||||
|
this.renderLineChart(typ, this.charts[typ].data, this.$refs[`chart-${typ}`][0]);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
onSubmit() {
|
||||||
|
// Fetch count for each analytics type (views, counts, bounces);
|
||||||
|
Object.keys(this.charts).forEach((k) => {
|
||||||
|
// Clear existing data.
|
||||||
|
this.charts[k].data = [];
|
||||||
|
|
||||||
|
// Fetch views, clicks, bounces for every campaign.
|
||||||
|
this.getData(k, this.form.campaigns);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
queryCampaigns(q) {
|
||||||
|
this.isSearchLoading = true;
|
||||||
|
this.$api.getCampaigns({
|
||||||
|
query: q,
|
||||||
|
order_by: 'created_at',
|
||||||
|
order: 'DESC',
|
||||||
|
}).then((data) => {
|
||||||
|
this.isSearchLoading = false;
|
||||||
|
this.queriedCampaigns = data.results.map((c) => {
|
||||||
|
// Change the name to include the ID in the auto-suggest results.
|
||||||
|
const camp = c;
|
||||||
|
camp.name = `#${c.id}: ${c.name}`;
|
||||||
|
return camp;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
getData(typ, camps) {
|
||||||
|
this.charts[typ].loading = true;
|
||||||
|
|
||||||
|
// Call the HTTP API.
|
||||||
|
this.charts[typ].fn({
|
||||||
|
id: camps.map((c) => c.id),
|
||||||
|
from: this.form.from,
|
||||||
|
to: this.form.to,
|
||||||
|
}).then((data) => {
|
||||||
|
// Set the total count.
|
||||||
|
this.counts[typ] = data.reduce((sum, d) => sum + d.count, 0);
|
||||||
|
|
||||||
|
this.charts[typ].chartFn(typ, camps, data);
|
||||||
|
|
||||||
|
if (this.charts[typ].donutFn) {
|
||||||
|
this.charts[typ].donutFn(typ, camps, data);
|
||||||
|
}
|
||||||
|
this.charts[typ].loading = false;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
created() {
|
||||||
|
const now = dayjs().set('hour', 23).set('minute', 59).set('seconds', 0);
|
||||||
|
this.form.to = now.toDate();
|
||||||
|
this.form.from = now.subtract(7, 'day').set('hour', 0).set('minute', 0).toDate();
|
||||||
|
},
|
||||||
|
|
||||||
|
mounted() {
|
||||||
|
// Fetch one or more campaigns if there are ?id params, wait for the fetches
|
||||||
|
// to finish, add them to the campaign selector and submit the form.
|
||||||
|
const ids = this.$utils.parseQueryIDs(this.$route.query.id);
|
||||||
|
if (ids.length > 0) {
|
||||||
|
this.isSearchLoading = true;
|
||||||
|
Promise.allSettled(ids.map((id) => this.$api.getCampaign(id))).then((data) => {
|
||||||
|
data.forEach((d) => {
|
||||||
|
if (d.status !== 'fulfilled') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const camp = d.value;
|
||||||
|
camp.name = `#${camp.id}: ${camp.name}`;
|
||||||
|
this.form.campaigns.push(camp);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.$nextTick(() => {
|
||||||
|
this.isSearchLoading = false;
|
||||||
|
this.onSubmit();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
|
@ -29,7 +29,7 @@
|
||||||
paginated backend-pagination pagination-position="both" @page-change="onPageChange"
|
paginated backend-pagination pagination-position="both" @page-change="onPageChange"
|
||||||
:current-page="queryParams.page" :per-page="campaigns.perPage" :total="campaigns.total"
|
:current-page="queryParams.page" :per-page="campaigns.perPage" :total="campaigns.total"
|
||||||
hoverable backend-sorting @sort="onSort">
|
hoverable backend-sorting @sort="onSort">
|
||||||
<b-table-column v-slot="props" class="status" field="status"
|
<b-table-column v-slot="props" cell-class="status" field="status"
|
||||||
:label="$t('globals.fields.status')" width="10%" sortable
|
:label="$t('globals.fields.status')" width="10%" sortable
|
||||||
:td-attrs="$utils.tdID" header-class="cy-status">
|
:td-attrs="$utils.tdID" header-class="cy-status">
|
||||||
<div>
|
<div>
|
||||||
|
@ -70,9 +70,9 @@
|
||||||
</b-taglist>
|
</b-taglist>
|
||||||
</div>
|
</div>
|
||||||
</b-table-column>
|
</b-table-column>
|
||||||
<b-table-column v-slot="props" class="lists" field="lists"
|
<b-table-column v-slot="props" cell-class="lists" field="lists"
|
||||||
:label="$t('globals.terms.lists')" width="15%">
|
:label="$t('globals.terms.lists')" width="15%">
|
||||||
<ul class="no">
|
<ul>
|
||||||
<li v-for="l in props.row.lists" :key="l.id">
|
<li v-for="l in props.row.lists" :key="l.id">
|
||||||
<router-link :to="{name: 'subscribers_list', params: { listID: l.id }}">
|
<router-link :to="{name: 'subscribers_list', params: { listID: l.id }}">
|
||||||
{{ l.name }}
|
{{ l.name }}
|
||||||
|
@ -103,7 +103,7 @@
|
||||||
</div>
|
</div>
|
||||||
</b-table-column>
|
</b-table-column>
|
||||||
|
|
||||||
<b-table-column v-slot="props" field="stats" :label="$t('campaigns.stats')" width="18%">
|
<b-table-column v-slot="props" field="stats" :label="$t('campaigns.stats')" width="15%">
|
||||||
<div class="fields stats" :set="stats = getCampaignStats(props.row)">
|
<div class="fields stats" :set="stats = getCampaignStats(props.row)">
|
||||||
<p>
|
<p>
|
||||||
<label>{{ $t('campaigns.views') }}</label>
|
<label>{{ $t('campaigns.views') }}</label>
|
||||||
|
@ -140,8 +140,9 @@
|
||||||
</div>
|
</div>
|
||||||
</b-table-column>
|
</b-table-column>
|
||||||
|
|
||||||
<b-table-column v-slot="props" cell-class="actions" width="13%" align="right">
|
<b-table-column v-slot="props" cell-class="actions" width="15%" align="right">
|
||||||
<div>
|
<div>
|
||||||
|
<!-- start / pause / resume / scheduled -->
|
||||||
<a href="" v-if="canStart(props.row)"
|
<a href="" v-if="canStart(props.row)"
|
||||||
@click.prevent="$utils.confirm(null,
|
@click.prevent="$utils.confirm(null,
|
||||||
() => changeCampaignStatus(props.row, 'running'))" data-cy="btn-start">
|
() => changeCampaignStatus(props.row, 'running'))" data-cy="btn-start">
|
||||||
|
@ -170,6 +171,25 @@
|
||||||
<b-icon icon="clock-start" size="is-small" />
|
<b-icon icon="clock-start" size="is-small" />
|
||||||
</b-tooltip>
|
</b-tooltip>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
<!-- placeholder for finished campaigns -->
|
||||||
|
<a v-if="!canCancel(props.row)
|
||||||
|
&& !canSchedule(props.row) && !canStart(props.row)" data-disabled>
|
||||||
|
<b-icon icon="rocket-launch-outline" size="is-small" />
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a href="" v-if="canCancel(props.row)"
|
||||||
|
@click.prevent="$utils.confirm(null,
|
||||||
|
() => changeCampaignStatus(props.row, 'cancelled'))"
|
||||||
|
data-cy="btn-cancel">
|
||||||
|
<b-tooltip :label="$t('globals.buttons.cancel')" type="is-dark">
|
||||||
|
<b-icon icon="cancel" size="is-small" />
|
||||||
|
</b-tooltip>
|
||||||
|
</a>
|
||||||
|
<a v-else data-disabled>
|
||||||
|
<b-icon icon="cancel" size="is-small" />
|
||||||
|
</a>
|
||||||
|
|
||||||
<a href="" @click.prevent="previewCampaign(props.row)" data-cy="btn-preview">
|
<a href="" @click.prevent="previewCampaign(props.row)" data-cy="btn-preview">
|
||||||
<b-tooltip :label="$t('campaigns.preview')" type="is-dark">
|
<b-tooltip :label="$t('campaigns.preview')" type="is-dark">
|
||||||
<b-icon icon="file-find-outline" size="is-small" />
|
<b-icon icon="file-find-outline" size="is-small" />
|
||||||
|
@ -184,14 +204,11 @@
|
||||||
<b-icon icon="file-multiple-outline" size="is-small" />
|
<b-icon icon="file-multiple-outline" size="is-small" />
|
||||||
</b-tooltip>
|
</b-tooltip>
|
||||||
</a>
|
</a>
|
||||||
<a href="" v-if="canCancel(props.row)"
|
<router-link :to="{ name: 'campaignAnalytics', query: { 'id': props.row.id }}">
|
||||||
@click.prevent="$utils.confirm(null,
|
<b-tooltip :label="$t('globals.terms.analytics')" type="is-dark">
|
||||||
() => changeCampaignStatus(props.row, 'cancelled'))"
|
<b-icon icon="chart-bar" size="is-small" />
|
||||||
data-cy="btn-cancel">
|
|
||||||
<b-tooltip :label="$t('globals.buttons.cancel')" type="is-dark">
|
|
||||||
<b-icon icon="cancel" size="is-small" />
|
|
||||||
</b-tooltip>
|
</b-tooltip>
|
||||||
</a>
|
</router-link>
|
||||||
<a href=""
|
<a href=""
|
||||||
@click.prevent="$utils.confirm($t('campaigns.confirmDelete', { name: props.row.name }),
|
@click.prevent="$utils.confirm($t('campaigns.confirmDelete', { name: props.row.name }),
|
||||||
() => deleteCampaign(props.row))" data-cy="btn-delete">
|
() => deleteCampaign(props.row))" data-cy="btn-delete">
|
||||||
|
|
|
@ -427,7 +427,7 @@
|
||||||
"subscribers.email": "E-mail",
|
"subscribers.email": "E-mail",
|
||||||
"subscribers.emailExists": "E-mail již existuje.",
|
"subscribers.emailExists": "E-mail již existuje.",
|
||||||
"subscribers.errorBlocklisting": "Chyba při uvádění odběratelů na seznam blokovaných: {error}",
|
"subscribers.errorBlocklisting": "Chyba při uvádění odběratelů na seznam blokovaných: {error}",
|
||||||
"subscribers.errorInvalidIDs": "Uvedeno jedno nebo více neplatných ID: {error}",
|
"globals.messages.errorInvalidIDs": "Uvedeno jedno nebo více neplatných ID: {error}",
|
||||||
"subscribers.errorNoIDs": "Nejsou uvedena žádná ID.",
|
"subscribers.errorNoIDs": "Nejsou uvedena žádná ID.",
|
||||||
"subscribers.errorNoListsGiven": "Nejsou uvedeny žádné seznamy.",
|
"subscribers.errorNoListsGiven": "Nejsou uvedeny žádné seznamy.",
|
||||||
"subscribers.errorPreparingQuery": "Chyba při přípravě dotazu na odběratele: {error}",
|
"subscribers.errorPreparingQuery": "Chyba při přípravě dotazu na odběratele: {error}",
|
||||||
|
|
|
@ -427,7 +427,7 @@
|
||||||
"subscribers.email": "E-Mail",
|
"subscribers.email": "E-Mail",
|
||||||
"subscribers.emailExists": "E-Mail existiert bereits.",
|
"subscribers.emailExists": "E-Mail existiert bereits.",
|
||||||
"subscribers.errorBlocklisting": "Fehler. Abonnement ist geblockt: {error}",
|
"subscribers.errorBlocklisting": "Fehler. Abonnement ist geblockt: {error}",
|
||||||
"subscribers.errorInvalidIDs": "Eine oder mehrere IDs sind ungültig: {error}",
|
"globals.messages.errorInvalidIDs": "Eine oder mehrere IDs sind ungültig: {error}",
|
||||||
"subscribers.errorNoIDs": "Keine IDs angegeben.",
|
"subscribers.errorNoIDs": "Keine IDs angegeben.",
|
||||||
"subscribers.errorNoListsGiven": "Keine Listen angegeben.",
|
"subscribers.errorNoListsGiven": "Keine Listen angegeben.",
|
||||||
"subscribers.errorPreparingQuery": "Fehler beim Vorbereiten der Abonnentenabfrage: {error}",
|
"subscribers.errorPreparingQuery": "Fehler beim Vorbereiten der Abonnentenabfrage: {error}",
|
||||||
|
|
10
i18n/en.json
10
i18n/en.json
|
@ -1,6 +1,12 @@
|
||||||
{
|
{
|
||||||
"_.code": "en",
|
"_.code": "en",
|
||||||
"_.name": "English (en)",
|
"_.name": "English (en)",
|
||||||
|
"analytics.title": "Analytics",
|
||||||
|
"analytics.fromDate": "From",
|
||||||
|
"analytics.toDate": "To",
|
||||||
|
"analytics.count": "Count",
|
||||||
|
"analytics.invalidDates": "Invalid `from` or `to` dates.",
|
||||||
|
"analytics.links": "Links",
|
||||||
"admin.errorMarshallingConfig": "Error marshalling config: {error}",
|
"admin.errorMarshallingConfig": "Error marshalling config: {error}",
|
||||||
"bounces.source": "Source",
|
"bounces.source": "Source",
|
||||||
"bounces.unknownService": "Unknown service.",
|
"bounces.unknownService": "Unknown service.",
|
||||||
|
@ -160,6 +166,7 @@
|
||||||
"globals.months.7": "Jul",
|
"globals.months.7": "Jul",
|
||||||
"globals.months.8": "Aug",
|
"globals.months.8": "Aug",
|
||||||
"globals.months.9": "Sep",
|
"globals.months.9": "Sep",
|
||||||
|
"globals.terms.analytics": "Analytics",
|
||||||
"globals.terms.bounce": "Bounce | Bounces",
|
"globals.terms.bounce": "Bounce | Bounces",
|
||||||
"globals.terms.bounces": "Bounces",
|
"globals.terms.bounces": "Bounces",
|
||||||
"globals.terms.campaign": "Campaign | Campaigns",
|
"globals.terms.campaign": "Campaign | Campaigns",
|
||||||
|
@ -427,7 +434,8 @@
|
||||||
"subscribers.email": "E-mail",
|
"subscribers.email": "E-mail",
|
||||||
"subscribers.emailExists": "E-mail already exists.",
|
"subscribers.emailExists": "E-mail already exists.",
|
||||||
"subscribers.errorBlocklisting": "Error blocklisting subscribers: {error}",
|
"subscribers.errorBlocklisting": "Error blocklisting subscribers: {error}",
|
||||||
"subscribers.errorInvalidIDs": "One or more invalid IDs given: {error}",
|
"globals.messages.errorInvalidIDs": "One or more IDs are invalid: {error}",
|
||||||
|
"globals.messages.missingFields": "Missing field(s): {name}",
|
||||||
"subscribers.errorNoIDs": "No IDs given.",
|
"subscribers.errorNoIDs": "No IDs given.",
|
||||||
"subscribers.errorNoListsGiven": "No lists given.",
|
"subscribers.errorNoListsGiven": "No lists given.",
|
||||||
"subscribers.errorPreparingQuery": "Error preparing subscriber query: {error}",
|
"subscribers.errorPreparingQuery": "Error preparing subscriber query: {error}",
|
||||||
|
|
|
@ -427,7 +427,7 @@
|
||||||
"subscribers.email": "Correo electrónico",
|
"subscribers.email": "Correo electrónico",
|
||||||
"subscribers.emailExists": "El correo electrónico ya existe.",
|
"subscribers.emailExists": "El correo electrónico ya existe.",
|
||||||
"subscribers.errorBlocklisting": "Error blocklisting subscriptrores: {error}",
|
"subscribers.errorBlocklisting": "Error blocklisting subscriptrores: {error}",
|
||||||
"subscribers.errorInvalidIDs": "Uno o más IDs ingresados son inválidos: {error}",
|
"globals.messages.errorInvalidIDs": "Uno o más IDs ingresados son inválidos: {error}",
|
||||||
"subscribers.errorNoIDs": "No se ingresaron IDs.",
|
"subscribers.errorNoIDs": "No se ingresaron IDs.",
|
||||||
"subscribers.errorNoListsGiven": "No se ingresaron listas.",
|
"subscribers.errorNoListsGiven": "No se ingresaron listas.",
|
||||||
"subscribers.errorPreparingQuery": "Error preparando la consulta del subscriptor: {error}",
|
"subscribers.errorPreparingQuery": "Error preparando la consulta del subscriptor: {error}",
|
||||||
|
|
|
@ -427,7 +427,7 @@
|
||||||
"subscribers.email": "Email",
|
"subscribers.email": "Email",
|
||||||
"subscribers.emailExists": "Cet email existe déjà.",
|
"subscribers.emailExists": "Cet email existe déjà.",
|
||||||
"subscribers.errorBlocklisting": "Erreur lors du blocage des abonné·es : {error}",
|
"subscribers.errorBlocklisting": "Erreur lors du blocage des abonné·es : {error}",
|
||||||
"subscribers.errorInvalidIDs": "Un ou plusieurs identifiants non valides fournis : {error}",
|
"globals.messages.errorInvalidIDs": "Un ou plusieurs identifiants non valides fournis : {error}",
|
||||||
"subscribers.errorNoIDs": "Aucun identifiant fourni.",
|
"subscribers.errorNoIDs": "Aucun identifiant fourni.",
|
||||||
"subscribers.errorNoListsGiven": "Aucune liste attribuée.",
|
"subscribers.errorNoListsGiven": "Aucune liste attribuée.",
|
||||||
"subscribers.errorPreparingQuery": "Erreur lors de la préparation de la requête d'abonné·e : {error}",
|
"subscribers.errorPreparingQuery": "Erreur lors de la préparation de la requête d'abonné·e : {error}",
|
||||||
|
|
|
@ -427,7 +427,7 @@
|
||||||
"subscribers.email": "Email",
|
"subscribers.email": "Email",
|
||||||
"subscribers.emailExists": "Email già esistente.",
|
"subscribers.emailExists": "Email già esistente.",
|
||||||
"subscribers.errorBlocklisting": "Errore durante il blocco degli iscritti: {error}",
|
"subscribers.errorBlocklisting": "Errore durante il blocco degli iscritti: {error}",
|
||||||
"subscribers.errorInvalidIDs": "Una o più credenziali fornite non valide: {error}",
|
"globals.messages.errorInvalidIDs": "Una o più credenziali fornite non valide: {error}",
|
||||||
"subscribers.errorNoIDs": "Nessun ID fornito.",
|
"subscribers.errorNoIDs": "Nessun ID fornito.",
|
||||||
"subscribers.errorNoListsGiven": "Nessuna lista fornita.",
|
"subscribers.errorNoListsGiven": "Nessuna lista fornita.",
|
||||||
"subscribers.errorPreparingQuery": "Errore durante la preparazione della richiesta dell'iscritto: {error}",
|
"subscribers.errorPreparingQuery": "Errore durante la preparazione della richiesta dell'iscritto: {error}",
|
||||||
|
|
|
@ -427,7 +427,7 @@
|
||||||
"subscribers.email": "ഇ-മെയിൽ",
|
"subscribers.email": "ഇ-മെയിൽ",
|
||||||
"subscribers.emailExists": "ഇ-മെയിൽ നേരത്തേതന്നെ ഉള്ളതാണ്",
|
"subscribers.emailExists": "ഇ-മെയിൽ നേരത്തേതന്നെ ഉള്ളതാണ്",
|
||||||
"subscribers.errorBlocklisting": "വരിക്കാരെ തടയുന്ന പട്ടികയിൽ പെടുത്തുന്നതിൽ പരാജയപ്പേട്ടു: {error}",
|
"subscribers.errorBlocklisting": "വരിക്കാരെ തടയുന്ന പട്ടികയിൽ പെടുത്തുന്നതിൽ പരാജയപ്പേട്ടു: {error}",
|
||||||
"subscribers.errorInvalidIDs": "നൽകിയിരിക്കുന്ന ഐഡികളിൽ ഒന്നോ അതിലധികം അസാധുവാണ്: {error}",
|
"globals.messages.errorInvalidIDs": "നൽകിയിരിക്കുന്ന ഐഡികളിൽ ഒന്നോ അതിലധികം അസാധുവാണ്: {error}",
|
||||||
"subscribers.errorNoIDs": "ഐഡികളൊന്നും നൽകിയിട്ടില്ല",
|
"subscribers.errorNoIDs": "ഐഡികളൊന്നും നൽകിയിട്ടില്ല",
|
||||||
"subscribers.errorNoListsGiven": "ലിസ്റ്റുകളോന്നും നൽകിയിട്ടില്ല",
|
"subscribers.errorNoListsGiven": "ലിസ്റ്റുകളോന്നും നൽകിയിട്ടില്ല",
|
||||||
"subscribers.errorPreparingQuery": "വരിക്കാരന്റെ ചോദ്യം തയാറാക്കുന്നതിൽ പരാജയപ്പെട്ടു: {error}",
|
"subscribers.errorPreparingQuery": "വരിക്കാരന്റെ ചോദ്യം തയാറാക്കുന്നതിൽ പരാജയപ്പെട്ടു: {error}",
|
||||||
|
|
|
@ -427,7 +427,7 @@
|
||||||
"subscribers.email": "Email",
|
"subscribers.email": "Email",
|
||||||
"subscribers.emailExists": "Email już istnieje.",
|
"subscribers.emailExists": "Email już istnieje.",
|
||||||
"subscribers.errorBlocklisting": "Błąd blokowania subskrybentów: {error}",
|
"subscribers.errorBlocklisting": "Błąd blokowania subskrybentów: {error}",
|
||||||
"subscribers.errorInvalidIDs": "Podano jeden lub więcej nieprawidłowy ID: {error}",
|
"globals.messages.errorInvalidIDs": "Podano jeden lub więcej nieprawidłowy ID: {error}",
|
||||||
"subscribers.errorNoIDs": "Nie podano identyfikatorów.",
|
"subscribers.errorNoIDs": "Nie podano identyfikatorów.",
|
||||||
"subscribers.errorNoListsGiven": "Nie podano list.",
|
"subscribers.errorNoListsGiven": "Nie podano list.",
|
||||||
"subscribers.errorPreparingQuery": "Błąd przygotowywania zapytania o subskrypcje: {error}",
|
"subscribers.errorPreparingQuery": "Błąd przygotowywania zapytania o subskrypcje: {error}",
|
||||||
|
|
|
@ -427,7 +427,7 @@
|
||||||
"subscribers.email": "E-mail",
|
"subscribers.email": "E-mail",
|
||||||
"subscribers.emailExists": "E-mail já existe.",
|
"subscribers.emailExists": "E-mail já existe.",
|
||||||
"subscribers.errorBlocklisting": "Erro ao bloquear inscritos: {error}",
|
"subscribers.errorBlocklisting": "Erro ao bloquear inscritos: {error}",
|
||||||
"subscribers.errorInvalidIDs": "Um ou mais IDs inválidos: {error}",
|
"globals.messages.errorInvalidIDs": "Um ou mais IDs inválidos: {error}",
|
||||||
"subscribers.errorNoIDs": "Nenhum ID informado.",
|
"subscribers.errorNoIDs": "Nenhum ID informado.",
|
||||||
"subscribers.errorNoListsGiven": "Nenhuma lista informada.",
|
"subscribers.errorNoListsGiven": "Nenhuma lista informada.",
|
||||||
"subscribers.errorPreparingQuery": "Erro ao preparar consulta de inscritos: {error}",
|
"subscribers.errorPreparingQuery": "Erro ao preparar consulta de inscritos: {error}",
|
||||||
|
|
|
@ -427,7 +427,7 @@
|
||||||
"subscribers.email": "E-mail",
|
"subscribers.email": "E-mail",
|
||||||
"subscribers.emailExists": "E-mail já existe.",
|
"subscribers.emailExists": "E-mail já existe.",
|
||||||
"subscribers.errorBlocklisting": "Erro ao bloquear subscritores: {error}",
|
"subscribers.errorBlocklisting": "Erro ao bloquear subscritores: {error}",
|
||||||
"subscribers.errorInvalidIDs": "Foram dados um ou mais IDs inválidos: {error}",
|
"globals.messages.errorInvalidIDs": "Foram dados um ou mais IDs inválidos: {error}",
|
||||||
"subscribers.errorNoIDs": "Não foram dados IDs.",
|
"subscribers.errorNoIDs": "Não foram dados IDs.",
|
||||||
"subscribers.errorNoListsGiven": "Não foram dadas listas.",
|
"subscribers.errorNoListsGiven": "Não foram dadas listas.",
|
||||||
"subscribers.errorPreparingQuery": "Erro ao preparar query dos subscritores: {error}",
|
"subscribers.errorPreparingQuery": "Erro ao preparar query dos subscritores: {error}",
|
||||||
|
|
|
@ -427,7 +427,7 @@
|
||||||
"subscribers.email": "E-mail",
|
"subscribers.email": "E-mail",
|
||||||
"subscribers.emailExists": "E-mail существует.",
|
"subscribers.emailExists": "E-mail существует.",
|
||||||
"subscribers.errorBlocklisting": "Ошибка блокировки подписчиков: {error}",
|
"subscribers.errorBlocklisting": "Ошибка блокировки подписчиков: {error}",
|
||||||
"subscribers.errorInvalidIDs": "Указан один или более неверных ID: {error}",
|
"globals.messages.errorInvalidIDs": "Указан один или более неверных ID: {error}",
|
||||||
"subscribers.errorNoIDs": "Не указано ни одного ID.",
|
"subscribers.errorNoIDs": "Не указано ни одного ID.",
|
||||||
"subscribers.errorNoListsGiven": "Не указано ни одного списка.",
|
"subscribers.errorNoListsGiven": "Не указано ни одного списка.",
|
||||||
"subscribers.errorPreparingQuery": "Ошибка подготовки запроса подписчиков: {error}",
|
"subscribers.errorPreparingQuery": "Ошибка подготовки запроса подписчиков: {error}",
|
||||||
|
|
|
@ -427,7 +427,7 @@
|
||||||
"subscribers.email": "E-posta",
|
"subscribers.email": "E-posta",
|
||||||
"subscribers.emailExists": "E-posta zaten mevcut.",
|
"subscribers.emailExists": "E-posta zaten mevcut.",
|
||||||
"subscribers.errorBlocklisting": "Hata, erişime engelli üyeleri gösterme: {error}",
|
"subscribers.errorBlocklisting": "Hata, erişime engelli üyeleri gösterme: {error}",
|
||||||
"subscribers.errorInvalidIDs": "Bir yada daha fazla geçersiz ID: {error}",
|
"globals.messages.errorInvalidIDs": "Bir yada daha fazla geçersiz ID: {error}",
|
||||||
"subscribers.errorNoIDs": "Herhangi bir ID verilmedi.",
|
"subscribers.errorNoIDs": "Herhangi bir ID verilmedi.",
|
||||||
"subscribers.errorNoListsGiven": "Liste tanımı yapılmamış.",
|
"subscribers.errorNoListsGiven": "Liste tanımı yapılmamış.",
|
||||||
"subscribers.errorPreparingQuery": "Hata, üye sorgusu hazırlarken: {error}",
|
"subscribers.errorPreparingQuery": "Hata, üye sorgusu hazırlarken: {error}",
|
||||||
|
|
37
queries.sql
37
queries.sql
|
@ -544,6 +544,43 @@ u AS (
|
||||||
)
|
)
|
||||||
SELECT * FROM camps;
|
SELECT * FROM camps;
|
||||||
|
|
||||||
|
-- name: get-campaign-view-counts
|
||||||
|
WITH intval AS (
|
||||||
|
-- For intervals < a week, aggregate counts hourly, otherwise daily.
|
||||||
|
SELECT CASE WHEN (EXTRACT (EPOCH FROM ($3::TIMESTAMP - $2::TIMESTAMP)) / 86400) >= 7 THEN 'day' ELSE 'hour' END
|
||||||
|
)
|
||||||
|
SELECT campaign_id, COUNT(*) AS "count", DATE_TRUNC((SELECT * FROM intval), created_at) AS "timestamp"
|
||||||
|
FROM campaign_views
|
||||||
|
WHERE campaign_id=ANY($1) AND created_at >= $2 AND created_at <= $3
|
||||||
|
GROUP BY campaign_id, "timestamp" ORDER BY "timestamp" ASC;
|
||||||
|
|
||||||
|
-- name: get-campaign-click-counts
|
||||||
|
WITH intval AS (
|
||||||
|
-- For intervals < a week, aggregate counts hourly, otherwise daily.
|
||||||
|
SELECT CASE WHEN (EXTRACT (EPOCH FROM ($3::TIMESTAMP - $2::TIMESTAMP)) / 86400) >= 7 THEN 'day' ELSE 'hour' END
|
||||||
|
)
|
||||||
|
SELECT campaign_id, COUNT(*) AS "count", DATE_TRUNC((SELECT * FROM intval), created_at) AS "timestamp"
|
||||||
|
FROM link_clicks
|
||||||
|
WHERE campaign_id=ANY($1) AND created_at >= $2 AND created_at <= $3
|
||||||
|
GROUP BY campaign_id, "timestamp" ORDER BY "timestamp" ASC;
|
||||||
|
|
||||||
|
-- name: get-campaign-bounce-counts
|
||||||
|
WITH intval AS (
|
||||||
|
-- For intervals < a week, aggregate counts hourly, otherwise daily.
|
||||||
|
SELECT CASE WHEN (EXTRACT (EPOCH FROM ($3::TIMESTAMP - $2::TIMESTAMP)) / 86400) >= 7 THEN 'day' ELSE 'hour' END
|
||||||
|
)
|
||||||
|
SELECT campaign_id, COUNT(*) AS "count", DATE_TRUNC((SELECT * FROM intval), created_at) AS "timestamp"
|
||||||
|
FROM bounces
|
||||||
|
WHERE campaign_id=ANY($1) AND created_at >= $2 AND created_at <= $3
|
||||||
|
GROUP BY campaign_id, "timestamp" ORDER BY "timestamp" ASC;
|
||||||
|
|
||||||
|
-- name: get-campaign-link-counts
|
||||||
|
SELECT COUNT(*) AS "count", url
|
||||||
|
FROM link_clicks
|
||||||
|
LEFT JOIN links ON (link_clicks.link_id = links.id)
|
||||||
|
WHERE campaign_id=ANY($1) AND link_clicks.created_at >= $2 AND link_clicks.created_at <= $3
|
||||||
|
GROUP BY links.url ORDER BY "count" DESC LIMIT 50;
|
||||||
|
|
||||||
-- name: next-campaign-subscribers
|
-- name: next-campaign-subscribers
|
||||||
-- Returns a batch of subscribers in a given campaign starting from the last checkpoint
|
-- Returns a batch of subscribers in a given campaign starting from the last checkpoint
|
||||||
-- (last_subscriber_id). Every fetch updates the checkpoint and the sent count, which means
|
-- (last_subscriber_id). Every fetch updates the checkpoint and the sent count, which means
|
||||||
|
|
Loading…
Reference in a new issue