mirror of
https://github.com/knadh/listmonk.git
synced 2024-12-29 18:33:43 +08:00
Add campaign analytics APIs and UI
This commit is contained in:
parent
3135bfc12a
commit
61e88681ed
24 changed files with 720 additions and 72 deletions
104
cmd/campaigns.go
104
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)
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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"`
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -80,6 +80,10 @@
|
|||
<b-menu-item :to="{name: 'templates'}" tag="router-link"
|
||||
:active="activeItem.templates" data-cy="templates"
|
||||
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 :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,
|
||||
{ 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 });
|
||||
|
||||
|
|
|
@ -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,37 @@ 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 12px;
|
||||
}
|
||||
.control.has-icons-left .icon.is-left {
|
||||
height: 3rem;
|
||||
}
|
||||
|
||||
.button {
|
||||
height: auto;
|
||||
padding: 10px 20px;
|
||||
}
|
||||
|
||||
|
||||
/* Form fields */
|
||||
.field {
|
||||
&:not(:last-child) {
|
||||
|
@ -368,10 +396,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 +519,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 +544,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 +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 */
|
||||
.preview {
|
||||
padding: 0;
|
||||
|
@ -702,11 +740,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;
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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]);
|
||||
|
||||
|
|
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"
|
||||
:current-page="queryParams.page" :per-page="campaigns.perPage" :total="campaigns.total"
|
||||
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
|
||||
:td-attrs="$utils.tdID" header-class="cy-status">
|
||||
<div>
|
||||
|
@ -70,9 +70,9 @@
|
|||
</b-taglist>
|
||||
</div>
|
||||
</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%">
|
||||
<ul class="no">
|
||||
<ul>
|
||||
<li v-for="l in props.row.lists" :key="l.id">
|
||||
<router-link :to="{name: 'subscribers_list', params: { listID: l.id }}">
|
||||
{{ l.name }}
|
||||
|
@ -103,7 +103,7 @@
|
|||
</div>
|
||||
</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)">
|
||||
<p>
|
||||
<label>{{ $t('campaigns.views') }}</label>
|
||||
|
@ -140,8 +140,9 @@
|
|||
</div>
|
||||
</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>
|
||||
<!-- start / pause / resume / scheduled -->
|
||||
<a href="" v-if="canStart(props.row)"
|
||||
@click.prevent="$utils.confirm(null,
|
||||
() => changeCampaignStatus(props.row, 'running'))" data-cy="btn-start">
|
||||
|
@ -170,6 +171,25 @@
|
|||
<b-icon icon="clock-start" size="is-small" />
|
||||
</b-tooltip>
|
||||
</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">
|
||||
<b-tooltip :label="$t('campaigns.preview')" type="is-dark">
|
||||
<b-icon icon="file-find-outline" size="is-small" />
|
||||
|
@ -184,14 +204,11 @@
|
|||
<b-icon icon="file-multiple-outline" size="is-small" />
|
||||
</b-tooltip>
|
||||
</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" />
|
||||
<router-link :to="{ name: 'campaignAnalytics', query: { 'id': props.row.id }}">
|
||||
<b-tooltip :label="$t('globals.terms.analytics')" type="is-dark">
|
||||
<b-icon icon="chart-bar" size="is-small" />
|
||||
</b-tooltip>
|
||||
</a>
|
||||
</router-link>
|
||||
<a href=""
|
||||
@click.prevent="$utils.confirm($t('campaigns.confirmDelete', { name: props.row.name }),
|
||||
() => deleteCampaign(props.row))" data-cy="btn-delete">
|
||||
|
|
|
@ -427,7 +427,7 @@
|
|||
"subscribers.email": "E-mail",
|
||||
"subscribers.emailExists": "E-mail již existuje.",
|
||||
"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.errorNoListsGiven": "Nejsou uvedeny žádné seznamy.",
|
||||
"subscribers.errorPreparingQuery": "Chyba při přípravě dotazu na odběratele: {error}",
|
||||
|
|
|
@ -427,7 +427,7 @@
|
|||
"subscribers.email": "E-Mail",
|
||||
"subscribers.emailExists": "E-Mail existiert bereits.",
|
||||
"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.errorNoListsGiven": "Keine Listen angegeben.",
|
||||
"subscribers.errorPreparingQuery": "Fehler beim Vorbereiten der Abonnentenabfrage: {error}",
|
||||
|
|
10
i18n/en.json
10
i18n/en.json
|
@ -1,6 +1,12 @@
|
|||
{
|
||||
"_.code": "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}",
|
||||
"bounces.source": "Source",
|
||||
"bounces.unknownService": "Unknown service.",
|
||||
|
@ -160,6 +166,7 @@
|
|||
"globals.months.7": "Jul",
|
||||
"globals.months.8": "Aug",
|
||||
"globals.months.9": "Sep",
|
||||
"globals.terms.analytics": "Analytics",
|
||||
"globals.terms.bounce": "Bounce | Bounces",
|
||||
"globals.terms.bounces": "Bounces",
|
||||
"globals.terms.campaign": "Campaign | Campaigns",
|
||||
|
@ -427,7 +434,8 @@
|
|||
"subscribers.email": "E-mail",
|
||||
"subscribers.emailExists": "E-mail already exists.",
|
||||
"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.errorNoListsGiven": "No lists given.",
|
||||
"subscribers.errorPreparingQuery": "Error preparing subscriber query: {error}",
|
||||
|
|
|
@ -427,7 +427,7 @@
|
|||
"subscribers.email": "Correo electrónico",
|
||||
"subscribers.emailExists": "El correo electrónico ya existe.",
|
||||
"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.errorNoListsGiven": "No se ingresaron listas.",
|
||||
"subscribers.errorPreparingQuery": "Error preparando la consulta del subscriptor: {error}",
|
||||
|
|
|
@ -427,7 +427,7 @@
|
|||
"subscribers.email": "Email",
|
||||
"subscribers.emailExists": "Cet email existe déjà.",
|
||||
"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.errorNoListsGiven": "Aucune liste attribuée.",
|
||||
"subscribers.errorPreparingQuery": "Erreur lors de la préparation de la requête d'abonné·e : {error}",
|
||||
|
|
|
@ -427,7 +427,7 @@
|
|||
"subscribers.email": "Email",
|
||||
"subscribers.emailExists": "Email già esistente.",
|
||||
"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.errorNoListsGiven": "Nessuna lista fornita.",
|
||||
"subscribers.errorPreparingQuery": "Errore durante la preparazione della richiesta dell'iscritto: {error}",
|
||||
|
|
|
@ -427,7 +427,7 @@
|
|||
"subscribers.email": "ഇ-മെയിൽ",
|
||||
"subscribers.emailExists": "ഇ-മെയിൽ നേരത്തേതന്നെ ഉള്ളതാണ്",
|
||||
"subscribers.errorBlocklisting": "വരിക്കാരെ തടയുന്ന പട്ടികയിൽ പെടുത്തുന്നതിൽ പരാജയപ്പേട്ടു: {error}",
|
||||
"subscribers.errorInvalidIDs": "നൽകിയിരിക്കുന്ന ഐഡികളിൽ ഒന്നോ അതിലധികം അസാധുവാണ്: {error}",
|
||||
"globals.messages.errorInvalidIDs": "നൽകിയിരിക്കുന്ന ഐഡികളിൽ ഒന്നോ അതിലധികം അസാധുവാണ്: {error}",
|
||||
"subscribers.errorNoIDs": "ഐഡികളൊന്നും നൽകിയിട്ടില്ല",
|
||||
"subscribers.errorNoListsGiven": "ലിസ്റ്റുകളോന്നും നൽകിയിട്ടില്ല",
|
||||
"subscribers.errorPreparingQuery": "വരിക്കാരന്റെ ചോദ്യം തയാറാക്കുന്നതിൽ പരാജയപ്പെട്ടു: {error}",
|
||||
|
|
|
@ -427,7 +427,7 @@
|
|||
"subscribers.email": "Email",
|
||||
"subscribers.emailExists": "Email już istnieje.",
|
||||
"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.errorNoListsGiven": "Nie podano list.",
|
||||
"subscribers.errorPreparingQuery": "Błąd przygotowywania zapytania o subskrypcje: {error}",
|
||||
|
|
|
@ -427,7 +427,7 @@
|
|||
"subscribers.email": "E-mail",
|
||||
"subscribers.emailExists": "E-mail já existe.",
|
||||
"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.errorNoListsGiven": "Nenhuma lista informada.",
|
||||
"subscribers.errorPreparingQuery": "Erro ao preparar consulta de inscritos: {error}",
|
||||
|
|
|
@ -427,7 +427,7 @@
|
|||
"subscribers.email": "E-mail",
|
||||
"subscribers.emailExists": "E-mail já existe.",
|
||||
"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.errorNoListsGiven": "Não foram dadas listas.",
|
||||
"subscribers.errorPreparingQuery": "Erro ao preparar query dos subscritores: {error}",
|
||||
|
|
|
@ -427,7 +427,7 @@
|
|||
"subscribers.email": "E-mail",
|
||||
"subscribers.emailExists": "E-mail существует.",
|
||||
"subscribers.errorBlocklisting": "Ошибка блокировки подписчиков: {error}",
|
||||
"subscribers.errorInvalidIDs": "Указан один или более неверных ID: {error}",
|
||||
"globals.messages.errorInvalidIDs": "Указан один или более неверных ID: {error}",
|
||||
"subscribers.errorNoIDs": "Не указано ни одного ID.",
|
||||
"subscribers.errorNoListsGiven": "Не указано ни одного списка.",
|
||||
"subscribers.errorPreparingQuery": "Ошибка подготовки запроса подписчиков: {error}",
|
||||
|
|
|
@ -427,7 +427,7 @@
|
|||
"subscribers.email": "E-posta",
|
||||
"subscribers.emailExists": "E-posta zaten mevcut.",
|
||||
"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.errorNoListsGiven": "Liste tanımı yapılmamış.",
|
||||
"subscribers.errorPreparingQuery": "Hata, üye sorgusu hazırlarken: {error}",
|
||||
|
|
37
queries.sql
37
queries.sql
|
@ -544,6 +544,43 @@ u AS (
|
|||
)
|
||||
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
|
||||
-- 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
|
||||
|
|
Loading…
Reference in a new issue