diff --git a/cmd/campaigns.go b/cmd/campaigns.go index 312f799..41576c5 100644 --- a/cmd/campaigns.go +++ b/cmd/campaigns.go @@ -14,6 +14,7 @@ import ( "time" "github.com/gofrs/uuid" + "github.com/jmoiron/sqlx" "github.com/knadh/listmonk/internal/subimporter" "github.com/knadh/listmonk/models" "github.com/labstack/echo" @@ -49,6 +50,17 @@ type campaignContentReq struct { 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 { ID int `db:"id" json:"id"` Status string `db:"status" json:"status"` @@ -96,23 +108,11 @@ func handleGetCampaigns(c echo.Context) error { if id > 0 { single = true } - if query != "" { - query = `%` + - string(regexFullTextQuery.ReplaceAll([]byte(query), []byte("&"))) + `%` - } - // Sort params. - if !strSliceContains(orderBy, campaignQuerySortFields) { - orderBy = "created_at" - } - if order != sortAsc && order != sortDesc { - order = sortDesc - } - - stmt := fmt.Sprintf(app.queries.QueryCampaigns, orderBy, order) + queryStr, stmt := makeCampaignQuery(query, orderBy, order, app.queries.QueryCampaigns) // 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) return echo.NewHTTPError(http.StatusInternalServerError, app.i18n.Ts("globals.messages.errorFetching", @@ -605,6 +605,64 @@ func handleTestCampaign(c echo.Context) error { 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. func sendTestMessage(sub models.Subscriber, camp *models.Campaign, app *App) error { 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() 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) +} diff --git a/cmd/handlers.go b/cmd/handlers.go index 7e777ba..dd46923 100644 --- a/cmd/handlers.go +++ b/cmd/handlers.go @@ -101,6 +101,7 @@ func registerHTTPHandlers(e *echo.Echo, app *App) { g.GET("/api/campaigns", handleGetCampaigns) g.GET("/api/campaigns/running/stats", handleGetRunningCampaignStats) g.GET("/api/campaigns/:id", handleGetCampaigns) + g.GET("/api/campaigns/analytics/:type", handleGetCampaignViewAnalytics) g.GET("/api/campaigns/:id/preview", handlePreviewCampaign) g.POST("/api/campaigns/:id/preview", handlePreviewCampaign) g.POST("/api/campaigns/:id/content", handleCampaignContent) diff --git a/cmd/queries.go b/cmd/queries.go index 3fdded4..ed55331 100644 --- a/cmd/queries.go +++ b/cmd/queries.go @@ -57,6 +57,10 @@ type Queries struct { GetCampaignForPreview *sqlx.Stmt `query:"get-campaign-for-preview"` GetCampaignStats *sqlx.Stmt `query:"get-campaign-stats"` 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"` NextCampaignSubscribers *sqlx.Stmt `query:"next-campaign-subscribers"` GetOneCampaignSubscriber *sqlx.Stmt `query:"get-one-campaign-subscriber"` diff --git a/cmd/subscribers.go b/cmd/subscribers.go index a4a6288..00aa13f 100644 --- a/cmd/subscribers.go +++ b/cmd/subscribers.go @@ -409,7 +409,7 @@ func handleBlocklistSubscribers(c echo.Context) error { var req subQueryReq if err := c.Bind(&req); err != nil { 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 { return echo.NewHTTPError(http.StatusBadRequest, @@ -449,7 +449,7 @@ func handleManageSubscriberLists(c echo.Context) error { var req subQueryReq if err := c.Bind(&req); err != nil { 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 { 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"]) if err != nil { 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 { return echo.NewHTTPError(http.StatusBadRequest, diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 485d26a..4cae158 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -80,6 +80,10 @@ + + http.get('/api/campaigns/running/sta export const createCampaign = async (data) => http.post('/api/campaigns', data, { 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, { loading: models.campaigns }); diff --git a/frontend/src/assets/style.scss b/frontend/src/assets/style.scss index 0a81ca9..0548817 100644 --- a/frontend/src/assets/style.scss +++ b/frontend/src/assets/style.scss @@ -262,10 +262,17 @@ body.is-noscroll .b-sidebar { padding: 15px 10px; border-color: $grey-lightest; } + .actions a, .actions .a { margin: 0 10px; display: inline-block; } + .actions a[data-disabled], + .actions .icon[data-disabled] { + pointer-events: none; + cursor: not-allowed; + color: $grey-light; + } } /* Modal */ @@ -294,16 +301,28 @@ body.is-noscroll .b-sidebar { } } -.autocomplete .dropdown-content { - background-color: $white-ter; +.autocomplete { + .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 { - // box-shadow: inset 2px 2px 0px $white-ter; box-shadow: 2px 2px 0 $white-ter; border: 1px solid $grey-lighter; } +.input { + height: auto; + padding: 10px; +} + /* Form fields */ .field { &:not(:last-child) { @@ -368,10 +387,10 @@ body.is-noscroll .b-sidebar { } &.public, &.running { $color: $primary; - color: $color; + color: lighten($color, 20%);; background: #e6f7ff; - border: 1px solid lighten($color, 37%); - box-shadow: 1px 1px 0 lighten($color, 25%); + border: 1px solid lighten($color, 42%); + box-shadow: 1px 1px 0 lighten($color, 42%); } &.finished, &.enabled { $color: $green; @@ -491,25 +510,22 @@ section.import { /* Campaigns page */ section.campaigns { table tbody { - tr.running { - background: lighten(#1890ff, 43%); - td { - border-bottom: 1px solid lighten(#1890ff, 30%); - } - - .spinner { - margin-left: 10px; - } - .spinner .loading-overlay .loading-icon::after { + .spinner { + margin-left: 10px; + .loading-overlay .loading-icon::after { border-bottom-color: lighten(#1890ff, 30%); border-left-color: lighten(#1890ff, 30%); } } - td { - &.status .spinner { - margin-left: 10px; + tr.running { + background: lighten(#1890ff, 43%); + td { + border-bottom: 1px solid lighten(#1890ff, 30%); } + } + + td { .tags { margin-top: 5px; } @@ -519,15 +535,8 @@ section.campaigns { } &.lists ul { - font-size: $size-7; + // font-size: $size-7; list-style-type: circle; - - a { - color: $grey-dark; - &:hover { - color: $primary; - } - } } .fields { @@ -555,6 +564,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 */ .preview { padding: 0; @@ -702,11 +731,10 @@ section.campaign { } .c3-tooltip { - border: 0; - background-color: #fff; + @extend .box; + padding: 10px; empty-cells: show; - box-shadow: none; - opacity: 0.9; + opacity: 0.95; tr { border: 0; diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js index 724a282..efb01a9 100644 --- a/frontend/src/router/index.js +++ b/frontend/src/router/index.js @@ -71,6 +71,12 @@ const routes = [ meta: { title: 'Templates', group: 'campaigns' }, 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', name: 'campaign', diff --git a/frontend/src/utils.js b/frontend/src/utils.js index d102fe2..80816ad 100644 --- a/frontend/src/utils.js +++ b/frontend/src/utils.js @@ -78,6 +78,23 @@ export default class Utils { 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 escapeHTML = (html) => html.replace(/[&<>"'`=/]/g, (s) => htmlEntities[s]); diff --git a/frontend/src/views/CampaignAnalytics.vue b/frontend/src/views/CampaignAnalytics.vue new file mode 100644 index 0000000..83e1dc5 --- /dev/null +++ b/frontend/src/views/CampaignAnalytics.vue @@ -0,0 +1,427 @@ + + + + + diff --git a/frontend/src/views/Campaigns.vue b/frontend/src/views/Campaigns.vue index 65a3641..8d265e1 100644 --- a/frontend/src/views/Campaigns.vue +++ b/frontend/src/views/Campaigns.vue @@ -29,7 +29,7 @@ paginated backend-pagination pagination-position="both" @page-change="onPageChange" :current-page="queryParams.page" :per-page="campaigns.perPage" :total="campaigns.total" hoverable backend-sorting @sort="onSort"> -
@@ -70,9 +70,9 @@
- -