Преглед на файлове

Merge branch 'campaign-analytics'

Kailash Nadh преди 3 години
родител
ревизия
4b127f1eda
променени са 58 файла, в които са добавени 1183 реда и са изтрити 765 реда
  1. 90 14
      cmd/campaigns.go
  2. 1 0
      cmd/handlers.go
  3. 33 6
      cmd/lists.go
  4. 5 0
      cmd/queries.go
  5. 25 11
      cmd/subscribers.go
  6. 14 14
      frontend/fontello/config.json
  7. 1 2
      frontend/package.json
  8. BIN
      frontend/public/favicon.png
  9. 5 1
      frontend/src/App.vue
  10. 42 1
      frontend/src/api/index.js
  11. BIN
      frontend/src/assets/favicon.png
  12. 1 0
      frontend/src/assets/icons/fontello.css
  13. BIN
      frontend/src/assets/icons/fontello.woff2
  14. BIN
      frontend/src/assets/logo.png
  15. 0 131
      frontend/src/assets/logo.svg
  16. 133 58
      frontend/src/assets/style.scss
  17. 10 4
      frontend/src/components/ListSelector.vue
  18. 1 1
      frontend/src/components/LogView.vue
  19. 1 1
      frontend/src/constants.js
  20. 12 0
      frontend/src/router/index.js
  21. 27 3
      frontend/src/utils.js
  22. 6 2
      frontend/src/views/Campaign.vue
  23. 432 0
      frontend/src/views/CampaignAnalytics.vue
  24. 38 20
      frontend/src/views/Campaigns.vue
  25. 42 48
      frontend/src/views/Dashboard.vue
  26. 1 1
      frontend/src/views/Import.vue
  27. 3 1
      frontend/src/views/ListForm.vue
  28. 22 8
      frontend/src/views/Lists.vue
  29. 1 1
      frontend/src/views/Media.vue
  30. 1 1
      frontend/src/views/Settings.vue
  31. 3 1
      frontend/src/views/SubscriberForm.vue
  32. 30 19
      frontend/src/views/Subscribers.vue
  33. 9 14
      frontend/yarn.lock
  34. 1 1
      i18n/cs-cz.json
  35. 1 1
      i18n/de.json
  36. 9 1
      i18n/en.json
  37. 1 1
      i18n/es.json
  38. 1 1
      i18n/fr.json
  39. 1 1
      i18n/it.json
  40. 1 1
      i18n/ml.json
  41. 1 1
      i18n/pl.json
  42. 1 1
      i18n/pt-BR.json
  43. 1 1
      i18n/pt.json
  44. 1 1
      i18n/ru.json
  45. 1 1
      i18n/tr.json
  46. 32 1
      internal/migrations/v2.0.0.go
  47. 0 4
      models/models.go
  48. 84 16
      queries.sql
  49. 6 0
      schema.sql
  50. 2 2
      static/email-templates/base.html
  51. 2 2
      static/email-templates/default.tpl
  52. BIN
      static/public/static/favicon.png
  53. BIN
      static/public/static/logo.png
  54. 0 135
      static/public/static/logo.svg
  55. 31 197
      static/public/static/style.css
  56. 3 7
      static/public/templates/index.html
  57. 6 10
      static/public/templates/subscription-form.html
  58. 8 16
      static/public/templates/subscription.html

+ 90 - 14
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.
-	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.
 	// 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)
+}

+ 1 - 0
cmd/handlers.go

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

+ 33 - 6
cmd/lists.go

@@ -24,17 +24,18 @@ var (
 	listQuerySortFields = []string{"name", "type", "subscriber_count", "created_at", "updated_at"}
 	listQuerySortFields = []string{"name", "type", "subscriber_count", "created_at", "updated_at"}
 )
 )
 
 
-// handleGetLists handles retrieval of lists.
+// handleGetLists retrieves lists with additional metadata like subscriber counts. This may be slow.
 func handleGetLists(c echo.Context) error {
 func handleGetLists(c echo.Context) error {
 	var (
 	var (
 		app = c.Get("app").(*App)
 		app = c.Get("app").(*App)
 		out listsWrap
 		out listsWrap
 
 
-		pg        = getPagination(c.QueryParams(), 20)
-		orderBy   = c.FormValue("order_by")
-		order     = c.FormValue("order")
-		listID, _ = strconv.Atoi(c.Param("id"))
-		single    = false
+		pg         = getPagination(c.QueryParams(), 20)
+		orderBy    = c.FormValue("order_by")
+		order      = c.FormValue("order")
+		minimal, _ = strconv.ParseBool(c.FormValue("minimal"))
+		listID, _  = strconv.Atoi(c.Param("id"))
+		single     = false
 	)
 	)
 
 
 	// Fetch one list.
 	// Fetch one list.
@@ -42,6 +43,28 @@ func handleGetLists(c echo.Context) error {
 		single = true
 		single = true
 	}
 	}
 
 
+	if !single && minimal {
+		// Minimal query simply returns the list of all lists with no additional metadata. This is fast.
+		if err := app.queries.GetLists.Select(&out.Results, ""); err != nil {
+			app.log.Printf("error fetching lists: %v", err)
+			return echo.NewHTTPError(http.StatusInternalServerError,
+				app.i18n.Ts("globals.messages.errorFetching",
+					"name", "{globals.terms.lists}", "error", pqErrMsg(err)))
+		}
+		if len(out.Results) == 0 {
+			return c.JSON(http.StatusOK, okResp{[]struct{}{}})
+		}
+
+		// Meta.
+		out.Total = out.Results[0].Total
+		out.Page = 1
+		out.PerPage = out.Total
+		if out.PerPage == 0 {
+			out.PerPage = out.Total
+		}
+		return c.JSON(http.StatusOK, okResp{out})
+	}
+
 	// Sort params.
 	// Sort params.
 	if !strSliceContains(orderBy, listQuerySortFields) {
 	if !strSliceContains(orderBy, listQuerySortFields) {
 		orderBy = "created_at"
 		orderBy = "created_at"
@@ -79,6 +102,10 @@ func handleGetLists(c echo.Context) error {
 	out.Total = out.Results[0].Total
 	out.Total = out.Results[0].Total
 	out.Page = pg.Page
 	out.Page = pg.Page
 	out.PerPage = pg.PerPage
 	out.PerPage = pg.PerPage
+	if out.PerPage == 0 {
+		out.PerPage = out.Total
+	}
+
 	return c.JSON(http.StatusOK, okResp{out})
 	return c.JSON(http.StatusOK, okResp{out})
 }
 }
 
 

+ 5 - 0
cmd/queries.go

@@ -35,6 +35,7 @@ type Queries struct {
 
 
 	// Non-prepared arbitrary subscriber queries.
 	// Non-prepared arbitrary subscriber queries.
 	QuerySubscribers                       string `query:"query-subscribers"`
 	QuerySubscribers                       string `query:"query-subscribers"`
+	QuerySubscribersCount                  string `query:"query-subscribers-count"`
 	QuerySubscribersForExport              string `query:"query-subscribers-for-export"`
 	QuerySubscribersForExport              string `query:"query-subscribers-for-export"`
 	QuerySubscribersTpl                    string `query:"query-subscribers-template"`
 	QuerySubscribersTpl                    string `query:"query-subscribers-template"`
 	DeleteSubscribersByQuery               string `query:"delete-subscribers-by-query"`
 	DeleteSubscribersByQuery               string `query:"delete-subscribers-by-query"`
@@ -57,6 +58,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"`

+ 25 - 11
cmd/subscribers.go

@@ -112,7 +112,7 @@ func handleQuerySubscribers(c echo.Context) error {
 		query   = sanitizeSQLExp(c.FormValue("query"))
 		query   = sanitizeSQLExp(c.FormValue("query"))
 		orderBy = c.FormValue("order_by")
 		orderBy = c.FormValue("order_by")
 		order   = c.FormValue("order")
 		order   = c.FormValue("order")
-		out     subsWrap
+		out     = subsWrap{Results: make([]models.Subscriber, 0, 1)}
 	)
 	)
 
 
 	listIDs := pq.Int64Array{}
 	listIDs := pq.Int64Array{}
@@ -130,15 +130,15 @@ func handleQuerySubscribers(c echo.Context) error {
 
 
 	// Sort params.
 	// Sort params.
 	if !strSliceContains(orderBy, subQuerySortFields) {
 	if !strSliceContains(orderBy, subQuerySortFields) {
-		orderBy = "updated_at"
+		orderBy = "subscribers.id"
 	}
 	}
 	if order != sortAsc && order != sortDesc {
 	if order != sortAsc && order != sortDesc {
-		order = sortAsc
+		order = sortDesc
 	}
 	}
 
 
-	stmt := fmt.Sprintf(app.queries.QuerySubscribers, cond, orderBy, order)
-
-	// Create a readonly transaction to prevent mutations.
+	// Create a readonly transaction that just does COUNT() to obtain the count of results
+	// and to ensure that the arbitrary query is indeed readonly.
+	stmt := fmt.Sprintf(app.queries.QuerySubscribersCount, cond)
 	tx, err := app.db.BeginTxx(context.Background(), &sql.TxOptions{ReadOnly: true})
 	tx, err := app.db.BeginTxx(context.Background(), &sql.TxOptions{ReadOnly: true})
 	if err != nil {
 	if err != nil {
 		app.log.Printf("error preparing subscriber query: %v", err)
 		app.log.Printf("error preparing subscriber query: %v", err)
@@ -147,7 +147,21 @@ func handleQuerySubscribers(c echo.Context) error {
 	}
 	}
 	defer tx.Rollback()
 	defer tx.Rollback()
 
 
-	// Run the query. stmt is the raw SQL query.
+	// Execute the readonly query and get the count of results.
+	var total = 0
+	if err := tx.Get(&total, stmt, listIDs); err != nil {
+		return echo.NewHTTPError(http.StatusInternalServerError,
+			app.i18n.Ts("globals.messages.errorFetching",
+				"name", "{globals.terms.subscribers}", "error", pqErrMsg(err)))
+	}
+
+	// No results.
+	if total == 0 {
+		return c.JSON(http.StatusOK, okResp{out})
+	}
+
+	// Run the query again and fetch the actual data. stmt is the raw SQL query.
+	stmt = fmt.Sprintf(app.queries.QuerySubscribers, cond, orderBy, order)
 	if err := tx.Select(&out.Results, stmt, listIDs, pg.Offset, pg.Limit); err != nil {
 	if err := tx.Select(&out.Results, stmt, listIDs, pg.Offset, pg.Limit); err != nil {
 		return echo.NewHTTPError(http.StatusInternalServerError,
 		return echo.NewHTTPError(http.StatusInternalServerError,
 			app.i18n.Ts("globals.messages.errorFetching",
 			app.i18n.Ts("globals.messages.errorFetching",
@@ -169,7 +183,7 @@ func handleQuerySubscribers(c echo.Context) error {
 	}
 	}
 
 
 	// Meta.
 	// Meta.
-	out.Total = out.Results[0].Total
+	out.Total = total
 	out.Page = pg.Page
 	out.Page = pg.Page
 	out.PerPage = pg.PerPage
 	out.PerPage = pg.PerPage
 
 
@@ -409,7 +423,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 +463,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 +519,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,

+ 14 - 14
frontend/fontello/config.json

@@ -538,6 +538,20 @@
         "email-bounce"
         "email-bounce"
       ]
       ]
     },
     },
+    {
+      "uid": "fcb7bfb12b7533c7026762bfc328ca1c",
+      "css": "speedometer",
+      "code": 59430,
+      "src": "custom_icons",
+      "selected": true,
+      "svg": {
+        "path": "M500 666Q447.3 666 411.1 629.9T375 541Q375 507.8 391.6 478.5T437.5 433.6L841.8 199.2 611.3 597.7Q595.7 628.9 565.4 647.5T500 666ZM500 125Q609.4 125 707 179.7L619.1 230.5Q562.5 209 500 209 410.2 209 333 253.9T210.9 375 166 541Q166 609.4 192.4 669.9T263.7 777.3V777.3Q277.3 789.1 277.3 806.6T264.6 835.9 234.4 847.7 205.1 835.9V835.9Q148.4 779.3 116.2 703.1T84 543 115.2 381.8 205.1 246.1 340.8 156.3 500 125ZM916 541Q916 627 883.8 703.1T794.9 835.9V835.9Q783.2 847.7 765.6 847.7T736.3 835.9 724.6 806.6 736.3 777.3V777.3Q781.3 730.5 807.6 669.9T834 541Q834 478.5 810.5 419.9L861.3 334Q916 433.6 916 541Z",
+        "width": 1000
+      },
+      "search": [
+        "speedometer"
+      ]
+    },
     {
     {
       "uid": "f4ad3f6d071a0bfb3a8452b514ed0892",
       "uid": "f4ad3f6d071a0bfb3a8452b514ed0892",
       "css": "vector-square",
       "css": "vector-square",
@@ -17478,20 +17492,6 @@
         "speaker-off"
         "speaker-off"
       ]
       ]
     },
     },
-    {
-      "uid": "fcb7bfb12b7533c7026762bfc328ca1c",
-      "css": "speedometer",
-      "code": 984261,
-      "src": "custom_icons",
-      "selected": false,
-      "svg": {
-        "path": "M500 666Q447.3 666 411.1 629.9T375 541Q375 507.8 391.6 478.5T437.5 433.6L841.8 199.2 611.3 597.7Q595.7 628.9 565.4 647.5T500 666ZM500 125Q609.4 125 707 179.7L619.1 230.5Q562.5 209 500 209 410.2 209 333 253.9T210.9 375 166 541Q166 609.4 192.4 669.9T263.7 777.3V777.3Q277.3 789.1 277.3 806.6T264.6 835.9 234.4 847.7 205.1 835.9V835.9Q148.4 779.3 116.2 703.1T84 543 115.2 381.8 205.1 246.1 340.8 156.3 500 125ZM916 541Q916 627 883.8 703.1T794.9 835.9V835.9Q783.2 847.7 765.6 847.7T736.3 835.9 724.6 806.6 736.3 777.3V777.3Q781.3 730.5 807.6 669.9T834 541Q834 478.5 810.5 419.9L861.3 334Q916 433.6 916 541Z",
-        "width": 1000
-      },
-      "search": [
-        "speedometer"
-      ]
-    },
     {
     {
       "uid": "d26bb53c36a567c6d3f5a87d8ce6accf",
       "uid": "d26bb53c36a567c6d3f5a87d8ce6accf",
       "css": "spellcheck",
       "css": "spellcheck",

+ 1 - 2
frontend/package.json

@@ -10,7 +10,7 @@
   },
   },
   "dependencies": {
   "dependencies": {
     "axios": "^0.21.1",
     "axios": "^0.21.1",
-    "buefy": "^0.9.7",
+    "buefy": "^0.9.10",
     "c3": "^0.7.20",
     "c3": "^0.7.20",
     "codeflask": "^1.4.1",
     "codeflask": "^1.4.1",
     "core-js": "^3.12.1",
     "core-js": "^3.12.1",
@@ -22,7 +22,6 @@
     "textversionjs": "^1.1.3",
     "textversionjs": "^1.1.3",
     "turndown": "^7.0.0",
     "turndown": "^7.0.0",
     "vue": "^2.6.12",
     "vue": "^2.6.12",
-    "vue-c3": "^1.2.11",
     "vue-i18n": "^8.22.2",
     "vue-i18n": "^8.22.2",
     "vue-quill-editor": "^3.0.6",
     "vue-quill-editor": "^3.0.6",
     "vue-router": "^3.2.0",
     "vue-router": "^3.2.0",

BIN
frontend/public/favicon.png


+ 5 - 1
frontend/src/App.vue

@@ -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.campaignAnalytics" 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"
@@ -187,7 +191,7 @@ export default Vue.extend({
   mounted() {
   mounted() {
     // Lists is required across different views. On app load, fetch the lists
     // Lists is required across different views. On app load, fetch the lists
     // and have them in the store.
     // and have them in the store.
-    this.$api.getLists();
+    this.$api.getLists({ minimal: true });
   },
   },
 });
 });
 </script>
 </script>

+ 42 - 1
frontend/src/api/index.js

@@ -39,6 +39,24 @@ http.interceptors.response.use((resp) => {
       // Transform field case.
       // Transform field case.
       data = humps.camelizeKeys(resp.data.data);
       data = humps.camelizeKeys(resp.data.data);
     }
     }
+
+    if (resp.config.preserveCase && resp.config.preserveResultsCase) {
+      // For each key in preserveResultsCase, get the values out in an array of arrays
+      // and save them as stringified JSON.
+      const save = resp.data.data.results.map(
+        (r) => resp.config.preserveResultsCase.map((k) => JSON.stringify(r[k])),
+      );
+
+      // Camelcase everything.
+      data = humps.camelizeKeys(resp.data.data);
+
+      // Put the saved results back.
+      data.results.forEach((r, n) => {
+        resp.config.preserveResultsCase.forEach((k, i) => {
+          data.results[n][k] = JSON.parse(save[n][i]);
+        });
+      });
+    }
   } else {
   } else {
     data = resp.data.data;
     data = resp.data.data;
   }
   }
@@ -67,6 +85,8 @@ http.interceptors.response.use((resp) => {
       message: msg,
       message: msg,
       type: 'is-danger',
       type: 'is-danger',
       queue: false,
       queue: false,
+      position: 'is-top',
+      pauseOnHover: true,
     });
     });
   }
   }
 
 
@@ -98,6 +118,9 @@ export const getLists = (params) => http.get('/api/lists',
     store: models.lists,
     store: models.lists,
   });
   });
 
 
+export const getList = async (id) => http.get(`/api/lists/${id}`,
+  { loading: models.list });
+
 export const createList = (data) => http.post('/api/lists', data,
 export const createList = (data) => http.post('/api/lists', data,
   { loading: models.lists });
   { loading: models.lists });
 
 
@@ -109,7 +132,13 @@ export const deleteList = (id) => http.delete(`/api/lists/${id}`,
 
 
 // Subscribers.
 // Subscribers.
 export const getSubscribers = async (params) => http.get('/api/subscribers',
 export const getSubscribers = async (params) => http.get('/api/subscribers',
-  { params, loading: models.subscribers, store: models.subscribers });
+  {
+    params,
+    loading: models.subscribers,
+    store: models.subscribers,
+    preserveCase: true,
+    preserveResultsCase: ['attribs'],
+  });
 
 
 export const getSubscriber = async (id) => http.get(`/api/subscribers/${id}`,
 export const getSubscriber = async (id) => http.get(`/api/subscribers/${id}`,
   { loading: models.subscribers });
   { loading: models.subscribers });
@@ -179,6 +208,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 });
 
 

BIN
frontend/src/assets/favicon.png


+ 1 - 0
frontend/src/assets/icons/fontello.css

@@ -78,3 +78,4 @@
 .mdi-magnify:before { content: '\e823'; } /* '' */
 .mdi-magnify:before { content: '\e823'; } /* '' */
 .mdi-chart-bar:before { content: '\e824'; } /* '' */
 .mdi-chart-bar:before { content: '\e824'; } /* '' */
 .mdi-email-bounce:before { content: '\e825'; } /* '' */
 .mdi-email-bounce:before { content: '\e825'; } /* '' */
+.mdi-speedometer:before { content: '\e826'; } /* '' */

BIN
frontend/src/assets/icons/fontello.woff2


BIN
frontend/src/assets/logo.png


Файловите разлики са ограничени, защото са твърде много
+ 0 - 131
frontend/src/assets/logo.svg


+ 133 - 58
frontend/src/assets/style.scss

@@ -5,10 +5,10 @@ $body-family: "Inter", "Helvetica Neue", sans-serif;
 $body-size: 15px;
 $body-size: 15px;
 $background: $white-bis;
 $background: $white-bis;
 $body-background-color: $white-bis;
 $body-background-color: $white-bis;
-$primary: #7f2aff;
-$green: #4caf50;
+$primary: #0055d4;
+$green: #0db35e;
 $turquoise: $green;
 $turquoise: $green;
-$red: #ff5722;
+$red: #FF5722;
 $link: $primary;
 $link: $primary;
 $input-placeholder-color: $grey-light;
 $input-placeholder-color: $grey-light;
 $grey-lightest: #eaeaea;
 $grey-lightest: #eaeaea;
@@ -27,8 +27,6 @@ $menu-item-active-color: $primary;
 
 
 /* Buefy */
 /* Buefy */
 $modal-background-background-color: rgba(0, 0, 0, .30);
 $modal-background-background-color: rgba(0, 0, 0, .30);
-$speed-slow: 25ms !default;
-$speed-slower: 50ms !default;
 
 
 /* Import full Bulma and Buefy */
 /* Import full Bulma and Buefy */
 @import "~bulma";
 @import "~bulma";
@@ -66,9 +64,6 @@ section {
   &.wrap {
   &.wrap {
     max-width: 1100px;
     max-width: 1100px;
   }
   }
-  &.wrap-small {
-    max-width: 900px;
-  }
 }
 }
 
 
 .spinner.is-tiny {
 .spinner.is-tiny {
@@ -92,14 +87,13 @@ section {
 }
 }
 
 
 .box {
 .box {
-  background: $white-bis;
-  box-shadow: 2px 2px 5px $white-ter;
-  border: 1px solid $grey-lightest;
-
-  hr {
+  background: $white;
+  box-shadow: 2px 2px 0 #f3f3f3;
+  border: 1px solid #eee;
+}
+  .box hr {
     background-color: #efefef;
     background-color: #efefef;
   }
   }
-}
 
 
 .page-header {
 .page-header {
   min-height: 60px;
   min-height: 60px;
@@ -175,6 +169,13 @@ section {
   }
   }
 }
 }
 
 
+/* Fix for sidebar jumping on modals */
+body.is-noscroll {
+  position: static;
+  overflow-y: visible;
+  width: auto;
+}
+
 /* Global notices */
 /* Global notices */
 .global-notices {
 .global-notices {
   margin-bottom: 30px;
   margin-bottom: 30px;
@@ -260,10 +261,17 @@ section {
     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 */
@@ -284,6 +292,12 @@ section {
 /* Fix for input colours */
 /* Fix for input colours */
 .button.is-primary {
 .button.is-primary {
   background: $primary;
   background: $primary;
+
+  &:not(.is-small) {
+    height: auto;
+    padding: 10px 20px;
+  }
+
   &:hover {
   &:hover {
     background: darken($primary, 15%);
     background: darken($primary, 15%);
   }
   }
@@ -292,12 +306,48 @@ section {
   }
   }
 }
 }
 
 
-.autocomplete .dropdown-content {
-  background-color: $white-ter;
+.autocomplete {
+  .dropdown-content {
+    background-color: $white-bis;
+    border: 1px solid $primary;
+  }
+  a.dropdown-item {
+    font-size: $size-6;
+    &:hover, &.is-hovered {
+      background-color: $grey-lightest;
+      color: $primary;
+    }
+  }
+  .dropdown-menu {
+    top: 92%;
+  }
+  .dropdown-menu.is-opened-top {
+    top: auto;
+    bottom: 92%;
+  }
 }
 }
 
 
 .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;
+  border: 1px solid $grey-lighter;
+}
+
+.input {
+  height: auto;
+  padding: 10px 12px;
+}
+  .control.has-icons-left .icon.is-left {
+    height: 3rem;
+  }
+
+.taginput .taginput-container .autocomplete {
+  input {
+    padding: 16px 32px 17px 32px;
+  }
+
+  .control.has-icons-left .icon.is-left {
+    height: 2rem;
+  }
 }
 }
 
 
 /* Form fields */
 /* Form fields */
@@ -317,11 +367,11 @@ section {
   }
   }
 
 
   label {
   label {
-    color: $grey;
+    color: $grey-dark;
   }
   }
 
 
   .help {
   .help {
-    color: $grey-light;
+    color: $grey;
   }
   }
 }
 }
 .has-numberinput .field, .field.is-grouped {
 .has-numberinput .field, .field.is-grouped {
@@ -338,7 +388,7 @@ section {
 
 
 /* Tags */
 /* Tags */
 .tag {
 .tag {
-  min-width: 75px;
+  min-width: 85px;
 
 
   &.is-small {
   &.is-small {
     font-size: 0.65rem;
     font-size: 0.65rem;
@@ -349,6 +399,7 @@ section {
   }
   }
 
 
   &:not(body) {
   &:not(body) {
+    font-size: 0.85em;
     $color: $grey-lighter;
     $color: $grey-lighter;
     border: 1px solid $color;
     border: 1px solid $color;
     box-shadow: 1px 1px 0 $color;
     box-shadow: 1px 1px 0 $color;
@@ -362,22 +413,22 @@ section {
     border: 1px solid lighten($color, 37%);
     border: 1px solid lighten($color, 37%);
     box-shadow: 1px 1px 0 lighten($color, 37%);
     box-shadow: 1px 1px 0 lighten($color, 37%);
   }
   }
-  &.public, &.running {
-    $color: #1890ff;
-    color: $color;
+  &.public, &.running, &.list {
+    $color: $primary;
+    color: lighten($color, 20%);;
     background: #e6f7ff;
     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 {
   &.finished, &.enabled {
-    $color: #50ab24;
+    $color: $green;
     color: $color;
     color: $color;
     background: #f6ffed;
     background: #f6ffed;
     border: 1px solid lighten($color, 45%);
     border: 1px solid lighten($color, 45%);
     box-shadow: 1px 1px 0 lighten($color, 45%);
     box-shadow: 1px 1px 0 lighten($color, 45%);
   }
   }
   &.blocklisted, &.cancelled {
   &.blocklisted, &.cancelled {
-    $color: #f5222d;
+    $color: $red;
     color: $color;
     color: $color;
     background: #fff1f0;
     background: #fff1f0;
     border: 1px solid lighten($color, 30%);
     border: 1px solid lighten($color, 30%);
@@ -406,6 +457,11 @@ section.dashboard {
     margin-bottom: 0.5rem;
     margin-bottom: 0.5rem;
   }
   }
 
 
+  .tile.notification {
+    @extend .box;
+    padding: 10px;
+  }
+
   .counts .column {
   .counts .column {
     padding: 30px;
     padding: 30px;
   }
   }
@@ -482,22 +538,22 @@ section.import {
 /* Campaigns page */
 /* Campaigns page */
 section.campaigns {
 section.campaigns {
   table tbody {
   table tbody {
+    .spinner {
+      margin-left: 10px;
+      .loading-overlay .loading-icon::after {
+        border-bottom-color: lighten(#1890ff, 30%);
+        border-left-color: lighten(#1890ff, 30%);
+      }
+    }
+
     tr.running {
     tr.running {
       background: lighten(#1890ff, 43%);
       background: lighten(#1890ff, 43%);
       td {
       td {
         border-bottom: 1px solid lighten(#1890ff, 30%);
         border-bottom: 1px solid lighten(#1890ff, 30%);
       }
       }
-
-      .spinner .loading-overlay .loading-icon::after {
-        border-bottom-color: lighten(#1890ff, 30%);
-        border-left-color: lighten(#1890ff, 30%);
-      }
     }
     }
 
 
     td {
     td {
-      &.status .spinner {
-        margin-left: 10px;
-      }
       .tags {
       .tags {
         margin-top: 5px;
         margin-top: 5px;
       }
       }
@@ -507,15 +563,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 {
@@ -543,6 +592,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;
@@ -673,6 +742,10 @@ section.campaign {
 
 
 /* C3 charting lib */
 /* C3 charting lib */
 .c3 {
 .c3 {
+  .c3-text.c3-empty {
+    font-family: $body-family;
+    font-size: $size-6;
+  }
   .c3-chart-lines .c3-line {
   .c3-chart-lines .c3-line {
     stroke-width: 2px;
     stroke-width: 2px;
   }
   }
@@ -690,11 +763,10 @@ section.campaign {
   }
   }
 
 
   .c3-tooltip {
   .c3-tooltip {
-    border: 0;
-    background-color: #fff;
+    @extend .box;
+    padding: 10px;
     empty-cells: show;
     empty-cells: show;
-    box-shadow: none;
-    opacity: 0.9;
+    opacity: 0.95;
 
 
     tr {
     tr {
       border: 0;
       border: 0;
@@ -723,20 +795,23 @@ section.campaign {
 
 
 /* Toasts */
 /* Toasts */
 .notices {
 .notices {
-  @keyframes scale {
-    0% {
-      scale: 1;
-    }
-    50% {
-      scale: 1.3;
+  .toast {
+    @extend .box;
+    border-left: 15px solid $grey;
+    border-radius: 3px;
+    padding: 20px;
+
+    &.is-danger {
+      background: $white;
+      border-left-color: $red;
+      color: $grey-dark;
     }
     }
-    100% {
-      scale: 1;
+    &.is-success {
+      background: $white;
+      border-left-color: $green;
+      color: $grey-dark;
     }
     }
   }
   }
-  .toast {
-    animation: scale 300ms ease-in-out;
-  }
 }
 }
 
 
 @media screen and (max-width: 1450px) and (min-width: 769px) {
 @media screen and (max-width: 1450px) and (min-width: 769px) {

+ 10 - 4
frontend/src/components/ListSelector.vue

@@ -7,7 +7,7 @@
             :class="l.subscriptionStatus"
             :class="l.subscriptionStatus"
             :closable="true"
             :closable="true"
             :data-id="l.id"
             :data-id="l.id"
-            @close="removeList(l.id)">
+            @close="removeList(l.id)" class="list">
             {{ l.name }} <sup>{{ l.subscriptionStatus }}</sup>
             {{ l.name }} <sup>{{ l.subscriptionStatus }}</sup>
           </b-tag>
           </b-tag>
         </b-taglist>
         </b-taglist>
@@ -17,10 +17,11 @@
       :label="label  + (selectedItems ? ` (${selectedItems.length})` : '')"
       :label="label  + (selectedItems ? ` (${selectedItems.length})` : '')"
       label-position="on-border">
       label-position="on-border">
       <b-autocomplete
       <b-autocomplete
+        v-model="query"
         :placeholder="placeholder"
         :placeholder="placeholder"
         clearable
         clearable
         dropdown-position="top"
         dropdown-position="top"
-        :disabled="disabled || filteredLists.length === 0"
+        :disabled="all.length === 0"
         :keep-first="true"
         :keep-first="true"
         :clear-on-select="true"
         :clear-on-select="true"
         :open-on-focus="true"
         :open-on-focus="true"
@@ -60,6 +61,7 @@ export default {
 
 
   data() {
   data() {
     return {
     return {
+      query: '',
       selectedItems: [],
       selectedItems: [],
     };
     };
   },
   },
@@ -70,6 +72,7 @@ export default {
         return;
         return;
       }
       }
       this.selectedItems.push(l);
       this.selectedItems.push(l);
+      this.query = '';
 
 
       // Propagate the items to the parent's v-model binding.
       // Propagate the items to the parent's v-model binding.
       Vue.nextTick(() => {
       Vue.nextTick(() => {
@@ -88,14 +91,17 @@ export default {
   },
   },
 
 
   computed: {
   computed: {
-    // Returns the list of lists to which the subscriber isn't subscribed.
+    // Return the list of unselected lists.
     filteredLists() {
     filteredLists() {
       // Get a map of IDs of the user subsciptions. eg: {1: true, 2: true};
       // Get a map of IDs of the user subsciptions. eg: {1: true, 2: true};
       const subIDs = this.selectedItems.reduce((obj, item) => ({ ...obj, [item.id]: true }), {});
       const subIDs = this.selectedItems.reduce((obj, item) => ({ ...obj, [item.id]: true }), {});
 
 
       // Filter lists from the global lists whose IDs are not in the user's
       // Filter lists from the global lists whose IDs are not in the user's
       // subscribed ist.
       // subscribed ist.
-      return this.$props.all.filter((l) => !(l.id in subIDs));
+      const q = this.query.toLowerCase();
+      return this.$props.all.filter(
+        (l) => (!(l.id in subIDs) && l.name.toLowerCase().indexOf(q) >= 0),
+      );
     },
     },
   },
   },
 
 

+ 1 - 1
frontend/src/components/LogView.vue

@@ -5,7 +5,7 @@
         <template v-for="(l, i) in lines">
         <template v-for="(l, i) in lines">
           <span :set="line = splitLine(l)" :key="i" class="line">
           <span :set="line = splitLine(l)" :key="i" class="line">
             <span class="timestamp" :title="line.file">{{ line.timestamp }}</span>
             <span class="timestamp" :title="line.file">{{ line.timestamp }}</span>
-            <span class="message">{{ line.message }}</span>
+            <span class="log-message">{{ line.message }}</span>
           </span>
           </span>
         </template>
         </template>
       </div>
       </div>

+ 1 - 1
frontend/src/constants.js

@@ -29,7 +29,7 @@ export const storeKeys = Object.freeze({
 export const timestamp = 'ddd D MMM YYYY, hh:mm A';
 export const timestamp = 'ddd D MMM YYYY, hh:mm A';
 
 
 export const colors = Object.freeze({
 export const colors = Object.freeze({
-  primary: '#7f2aff',
+  primary: '#0055d4',
 });
 });
 
 
 export const regDuration = '[0-9]+(ms|s|m|h|d)';
 export const regDuration = '[0-9]+(ms|s|m|h|d)';

+ 12 - 0
frontend/src/router/index.js

@@ -23,6 +23,12 @@ const routes = [
     meta: { title: 'Forms', group: 'lists' },
     meta: { title: 'Forms', group: 'lists' },
     component: () => import(/* webpackChunkName: "main" */ '../views/Forms.vue'),
     component: () => import(/* webpackChunkName: "main" */ '../views/Forms.vue'),
   },
   },
+  {
+    path: '/lists/:id',
+    name: 'lists',
+    meta: { title: 'Lists', group: 'lists' },
+    component: () => import(/* webpackChunkName: "main" */ '../views/Lists.vue'),
+  },
   {
   {
     path: '/subscribers',
     path: '/subscribers',
     name: 'subscribers',
     name: 'subscribers',
@@ -71,6 +77,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',

+ 27 - 3
frontend/src/utils.js

@@ -23,6 +23,7 @@ const htmlEntities = {
 export default class Utils {
 export default class Utils {
   constructor(i18n) {
   constructor(i18n) {
     this.i18n = i18n;
     this.i18n = i18n;
+    this.intlNumFormat = new Intl.NumberFormat();
   }
   }
 
 
   // Parses an ISO timestamp to a simpler form.
   // Parses an ISO timestamp to a simpler form.
@@ -78,13 +79,34 @@ export default class Utils {
     return out.toFixed(2) + pfx;
     return out.toFixed(2) + pfx;
   }
   }
 
 
+  formatNumber(v) {
+    return this.intlNumFormat.format(v);
+  }
+
+  // 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]);
 
 
   // UI shortcuts.
   // UI shortcuts.
   confirm = (msg, onConfirm, onCancel) => {
   confirm = (msg, onConfirm, onCancel) => {
     Dialog.confirm({
     Dialog.confirm({
-      scroll: 'clip',
+      scroll: 'keep',
       message: !msg ? this.i18n.t('globals.messages.confirm') : msg,
       message: !msg ? this.i18n.t('globals.messages.confirm') : msg,
       confirmText: this.i18n.t('globals.buttons.ok'),
       confirmText: this.i18n.t('globals.buttons.ok'),
       cancelText: this.i18n.t('globals.buttons.cancel'),
       cancelText: this.i18n.t('globals.buttons.cancel'),
@@ -95,7 +117,7 @@ export default class Utils {
 
 
   prompt = (msg, inputAttrs, onConfirm, onCancel) => {
   prompt = (msg, inputAttrs, onConfirm, onCancel) => {
     Dialog.prompt({
     Dialog.prompt({
-      scroll: 'clip',
+      scroll: 'keep',
       message: msg,
       message: msg,
       confirmText: this.i18n.t('globals.buttons.ok'),
       confirmText: this.i18n.t('globals.buttons.ok'),
       cancelText: this.i18n.t('globals.buttons.cancel'),
       cancelText: this.i18n.t('globals.buttons.cancel'),
@@ -115,7 +137,9 @@ export default class Utils {
       message: this.escapeHTML(msg),
       message: this.escapeHTML(msg),
       type: !typ ? 'is-success' : typ,
       type: !typ ? 'is-success' : typ,
       queue: false,
       queue: false,
-      duration: duration || 2000,
+      duration: duration || 3000,
+      position: 'is-top',
+      pauseOnHover: true,
     });
     });
   };
   };
 
 

+ 6 - 2
frontend/src/views/Campaign.vue

@@ -3,8 +3,12 @@
     <header class="columns">
     <header class="columns">
       <div class="column is-8">
       <div class="column is-8">
         <p v-if="isEditing" class="tags">
         <p v-if="isEditing" class="tags">
-          <b-tag v-if="isEditing" :class="data.status">{{ data.status }}</b-tag>
-          <b-tag v-if="data.type === 'optin'" :class="data.type">{{ data.type }}</b-tag>
+          <b-tag v-if="isEditing" :class="data.status">
+            {{ $t(`campaigns.status.${data.status}`) }}
+          </b-tag>
+          <b-tag v-if="data.type === 'optin'" :class="data.type">
+            {{ $t('lists.optin') }}
+          </b-tag>
           <span v-if="isEditing" class="has-text-grey-light is-size-7">
           <span v-if="isEditing" class="has-text-grey-light is-size-7">
             {{ $t('globals.fields.id') }}: {{ data.id }} /
             {{ $t('globals.fields.id') }}: {{ data.id }} /
             {{ $t('globals.fields.uuid') }}: {{ data.uuid }}
             {{ $t('globals.fields.uuid') }}: {{ data.uuid }}

+ 432 - 0
frontend/src/views/CampaignAnalytics.vue

@@ -0,0 +1,432 @@
+<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 }}
+            <span class="has-text-grey-light">({{ $utils.niceNumber(counts[k]) }})</span>
+          </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>

+ 38 - 20
frontend/src/views/Campaigns.vue

@@ -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>
@@ -58,8 +58,8 @@
         sortable header-class="cy-name">
         sortable header-class="cy-name">
         <div>
         <div>
           <p>
           <p>
-            <b-tag v-if="props.row.type !== 'regular'" class="is-small">
-              {{ props.row.type }}
+            <b-tag v-if="props.row.type === 'optin'" class="is-small">
+              {{ $t('lists.optin') }}
             </b-tag>
             </b-tag>
             <router-link :to="{ name: 'campaign', params: { 'id': props.row.id }}">
             <router-link :to="{ name: 'campaign', params: { 'id': props.row.id }}">
               {{ props.row.name }}</router-link>
               {{ props.row.name }}</router-link>
@@ -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,24 +103,24 @@
         </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>
-            {{ props.row.views }}
+            {{ $utils.formatNumber(props.row.views) }}
           </p>
           </p>
           <p>
           <p>
             <label>{{ $t('campaigns.clicks') }}</label>
             <label>{{ $t('campaigns.clicks') }}</label>
-            {{ props.row.clicks }}
+            {{ $utils.formatNumber(props.row.clicks) }}
           </p>
           </p>
           <p>
           <p>
             <label>{{ $t('campaigns.sent') }}</label>
             <label>{{ $t('campaigns.sent') }}</label>
-            {{ stats.sent }} / {{ stats.toSend }}
+            {{ $utils.formatNumber(stats.sent) }} / {{ $utils.formatNumber(stats.toSend) }}
           </p>
           </p>
           <p>
           <p>
             <label>{{ $t('globals.terms.bounces') }}</label>
             <label>{{ $t('globals.terms.bounces') }}</label>
             <router-link :to="{name: 'bounces', query: { campaign_id: props.row.id }}">
             <router-link :to="{name: 'bounces', query: { campaign_id: props.row.id }}">
-              {{ props.row.bounces }}
+              {{ $utils.formatNumber(props.row.bounces) }}
             </router-link>
             </router-link>
           </p>
           </p>
           <p title="Speed" v-if="stats.rate">
           <p title="Speed" v-if="stats.rate">
@@ -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,16 +204,14 @@
               <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)"
-            @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>
             </b-tooltip>
-          </a>
-          <a href="" @click.prevent="$utils.confirm($tc('campaigns.confirmDelete'),
-              () => deleteCampaign(props.row))" data-cy="btn-delete">
+          </router-link>
+          <a href=""
+            @click.prevent="$utils.confirm($t('campaigns.confirmDelete', { name: props.row.name }),
+            () => deleteCampaign(props.row))" data-cy="btn-delete">
               <b-icon icon="trash-can-outline" size="is-small" />
               <b-icon icon="trash-can-outline" size="is-small" />
           </a>
           </a>
         </div>
         </div>

+ 42 - 48
frontend/src/views/Dashboard.vue

@@ -6,7 +6,7 @@
       </div>
       </div>
     </header>
     </header>
 
 
-    <section class="counts wrap-small">
+    <section class="counts wrap">
       <div class="tile is-ancestor">
       <div class="tile is-ancestor">
         <div class="tile is-vertical is-12">
         <div class="tile is-vertical is-12">
           <div class="tile">
           <div class="tile">
@@ -15,13 +15,16 @@
               <article class="tile is-child notification" data-cy="lists">
               <article class="tile is-child notification" data-cy="lists">
                 <div class="columns is-mobile">
                 <div class="columns is-mobile">
                   <div class="column is-6">
                   <div class="column is-6">
-                    <p class="title">{{ $utils.niceNumber(counts.lists.total) }}</p>
+                    <p class="title">
+                      <b-icon icon="format-list-bulleted-square" />
+                      {{ $utils.niceNumber(counts.lists.total) }}
+                    </p>
                     <p class="is-size-6 has-text-grey">
                     <p class="is-size-6 has-text-grey">
                       {{ $tc('globals.terms.list', counts.lists.total) }}
                       {{ $tc('globals.terms.list', counts.lists.total) }}
                     </p>
                     </p>
                   </div>
                   </div>
                   <div class="column is-6">
                   <div class="column is-6">
-                    <ul class="no is-size-7 has-text-grey">
+                    <ul class="no has-text-grey">
                       <li>
                       <li>
                         <label>{{ $utils.niceNumber(counts.lists.public) }}</label>
                         <label>{{ $utils.niceNumber(counts.lists.public) }}</label>
                         {{ $t('lists.types.public') }}
                         {{ $t('lists.types.public') }}
@@ -46,15 +49,21 @@
               <article class="tile is-child notification" data-cy="campaigns">
               <article class="tile is-child notification" data-cy="campaigns">
                 <div class="columns is-mobile">
                 <div class="columns is-mobile">
                   <div class="column is-6">
                   <div class="column is-6">
-                    <p class="title">{{ $utils.niceNumber(counts.campaigns.total) }}</p>
+                    <p class="title">
+                      <b-icon icon="rocket-launch-outline" />
+                      {{ $utils.niceNumber(counts.campaigns.total) }}
+                    </p>
                     <p class="is-size-6 has-text-grey">
                     <p class="is-size-6 has-text-grey">
                       {{ $tc('globals.terms.campaign', counts.campaigns.total) }}
                       {{ $tc('globals.terms.campaign', counts.campaigns.total) }}
                     </p>
                     </p>
                   </div>
                   </div>
                   <div class="column is-6">
                   <div class="column is-6">
-                    <ul class="no is-size-7 has-text-grey">
+                    <ul class="no has-text-grey">
                       <li v-for="(num, status) in counts.campaigns.byStatus" :key="status">
                       <li v-for="(num, status) in counts.campaigns.byStatus" :key="status">
                         <label>{{ num }}</label> {{ status }}
                         <label>{{ num }}</label> {{ status }}
+                        <span v-if="status === 'running'" class="spinner is-tiny">
+                          <b-loading :is-full-page="false" active />
+                        </span>
                       </li>
                       </li>
                     </ul>
                     </ul>
                   </div>
                   </div>
@@ -67,14 +76,17 @@
               <article class="tile is-child notification" data-cy="subscribers">
               <article class="tile is-child notification" data-cy="subscribers">
                 <div class="columns is-mobile">
                 <div class="columns is-mobile">
                   <div class="column is-6">
                   <div class="column is-6">
-                    <p class="title">{{ $utils.niceNumber(counts.subscribers.total) }}</p>
+                    <p class="title">
+                      <b-icon icon="account-multiple" />
+                      {{ $utils.niceNumber(counts.subscribers.total) }}
+                    </p>
                     <p class="is-size-6 has-text-grey">
                     <p class="is-size-6 has-text-grey">
                       {{ $tc('globals.terms.subscriber', counts.subscribers.total) }}
                       {{ $tc('globals.terms.subscriber', counts.subscribers.total) }}
                     </p>
                     </p>
                   </div>
                   </div>
 
 
                   <div class="column is-6">
                   <div class="column is-6">
-                    <ul class="no is-size-7 has-text-grey">
+                    <ul class="no has-text-grey">
                       <li>
                       <li>
                         <label>{{ $utils.niceNumber(counts.subscribers.blocklisted) }}</label>
                         <label>{{ $utils.niceNumber(counts.subscribers.blocklisted) }}</label>
                         {{ $t('subscribers.status.blocklisted') }}
                         {{ $t('subscribers.status.blocklisted') }}
@@ -89,7 +101,10 @@
                 <hr />
                 <hr />
                 <div class="columns" data-cy="messages">
                 <div class="columns" data-cy="messages">
                   <div class="column is-12">
                   <div class="column is-12">
-                    <p class="title">{{ $utils.niceNumber(counts.messages) }}</p>
+                    <p class="title">
+                      <b-icon icon="email-outline" />
+                      {{ $utils.niceNumber(counts.messages) }}
+                    </p>
                     <p class="is-size-6 has-text-grey">
                     <p class="is-size-6 has-text-grey">
                       {{ $t('dashboard.messagesSent') }}
                       {{ $t('dashboard.messagesSent') }}
                     </p>
                     </p>
@@ -104,15 +119,13 @@
               <div class="columns">
               <div class="columns">
                 <div class="column is-6">
                 <div class="column is-6">
                   <h3 class="title is-size-6">{{ $t('dashboard.campaignViews') }}</h3><br />
                   <h3 class="title is-size-6">{{ $t('dashboard.campaignViews') }}</h3><br />
-                  <vue-c3 v-if="chartViewsInst" :handler="chartViewsInst"></vue-c3>
-                  <empty-placeholder v-else-if="!isChartsLoading" />
+                  <div ref="chart-views"></div>
                 </div>
                 </div>
                 <div class="column is-6">
                 <div class="column is-6">
                   <h3 class="title is-size-6 has-text-right">
                   <h3 class="title is-size-6 has-text-right">
                     {{ $t('dashboard.linkClicks') }}
                     {{ $t('dashboard.linkClicks') }}
                   </h3><br />
                   </h3><br />
-                  <vue-c3 v-if="chartClicksInst" :handler="chartClicksInst"></vue-c3>
-                  <empty-placeholder v-else-if="!isChartsLoading" />
+                  <div ref="chart-clicks"></div>
                 </div>
                 </div>
               </div>
               </div>
             </article>
             </article>
@@ -130,23 +143,13 @@
 
 
 <script>
 <script>
 import Vue from 'vue';
 import Vue from 'vue';
-import VueC3 from 'vue-c3';
+import c3 from 'c3';
 import dayjs from 'dayjs';
 import dayjs from 'dayjs';
 import { colors } from '../constants';
 import { colors } from '../constants';
-import EmptyPlaceholder from '../components/EmptyPlaceholder.vue';
 
 
 export default Vue.extend({
 export default Vue.extend({
-  components: {
-    EmptyPlaceholder,
-    VueC3,
-  },
-
   data() {
   data() {
     return {
     return {
-      // Unique Vue() instances for each chart.
-      chartViewsInst: null,
-      chartClicksInst: null,
-
       isChartsLoading: true,
       isChartsLoading: true,
       isCountsLoading: true,
       isCountsLoading: true,
 
 
@@ -160,21 +163,22 @@ export default Vue.extend({
   },
   },
 
 
   methods: {
   methods: {
-    makeChart(label, data) {
+    renderChart(label, data, el) {
       const conf = {
       const conf = {
+        bindto: el,
+        unload: true,
         data: {
         data: {
-          columns: [
-            [label, ...data.map((d) => d.count).reverse()],
-          ],
           type: 'spline',
           type: 'spline',
+          columns: [],
           color() {
           color() {
             return colors.primary;
             return colors.primary;
           },
           },
+          empty: { label: { text: this.$t('globals.messages.emptyState') } },
         },
         },
         axis: {
         axis: {
           x: {
           x: {
             type: 'category',
             type: 'category',
-            categories: data.map((d) => dayjs(d.date).format('DD MMM')).reverse(),
+            categories: data.map((d) => dayjs(d.date).format('DD MMM')),
             tick: {
             tick: {
               rotate: -45,
               rotate: -45,
               multiline: false,
               multiline: false,
@@ -186,7 +190,14 @@ export default Vue.extend({
           show: false,
           show: false,
         },
         },
       };
       };
-      return conf;
+
+      if (data.length > 0) {
+        conf.data.columns.push([label, ...data.map((d) => d.count)]);
+      }
+
+      this.$nextTick(() => {
+        c3.generate(conf);
+      });
     },
     },
   },
   },
 
 
@@ -206,25 +217,8 @@ export default Vue.extend({
     // Pull the charts.
     // Pull the charts.
     this.$api.getDashboardCharts().then((data) => {
     this.$api.getDashboardCharts().then((data) => {
       this.isChartsLoading = false;
       this.isChartsLoading = false;
-
-      // vue-c3 lib requires unique instances of Vue() to communicate.
-      if (data.campaignViews.length > 0) {
-        this.chartViewsInst = this;
-
-        this.$nextTick(() => {
-          this.chartViewsInst.$emit('init',
-            this.makeChart(this.$t('dashboard.campaignViews'), data.campaignViews));
-        });
-      }
-
-      if (data.linkClicks.length > 0) {
-        this.chartClicksInst = new Vue();
-
-        this.$nextTick(() => {
-          this.chartClicksInst.$emit('init',
-            this.makeChart(this.$t('dashboard.linkClicks'), data.linkClicks));
-        });
-      }
+      this.renderChart(this.$t('dashboard.linkClicks'), data.campaignViews, this.$refs['chart-views']);
+      this.renderChart(this.$t('dashboard.linkClicks'), data.linkClicks, this.$refs['chart-clicks']);
     });
     });
   },
   },
 });
 });

+ 1 - 1
frontend/src/views/Import.vue

@@ -3,7 +3,7 @@
     <h1 class="title is-4">{{ $t('import.title') }}</h1>
     <h1 class="title is-4">{{ $t('import.title') }}</h1>
     <b-loading :active="isLoading"></b-loading>
     <b-loading :active="isLoading"></b-loading>
 
 
-    <section v-if="isFree()" class="wrap-small">
+    <section v-if="isFree()" class="wrap">
       <form @submit.prevent="onSubmit" class="box">
       <form @submit.prevent="onSubmit" class="box">
         <div>
         <div>
           <div class="columns">
           <div class="columns">

+ 3 - 1
frontend/src/views/ListForm.vue

@@ -6,7 +6,9 @@
           {{ $t('globals.fields.id') }}: {{ data.id }} /
           {{ $t('globals.fields.id') }}: {{ data.id }} /
           {{ $t('globals.fields.uuid') }}: {{ data.uuid }}
           {{ $t('globals.fields.uuid') }}: {{ data.uuid }}
         </p>
         </p>
-        <b-tag v-if="isEditing" :class="[data.type, 'is-pulled-right']">{{ data.type }}</b-tag>
+        <b-tag v-if="isEditing" :class="[data.type, 'is-pulled-right']">
+          {{ $t(`lists.types.${data.type}`) }}
+        </b-tag>
         <h4 v-if="isEditing">{{ data.name }}</h4>
         <h4 v-if="isEditing">{{ data.name }}</h4>
         <h4 v-else>{{ $t('lists.newList') }}</h4>
         <h4 v-else>{{ $t('lists.newList') }}</h4>
       </header>
       </header>

+ 22 - 8
frontend/src/views/Lists.vue

@@ -28,9 +28,10 @@
         :td-attrs="$utils.tdID"
         :td-attrs="$utils.tdID"
         @page-change="onPageChange">
         @page-change="onPageChange">
         <div>
         <div>
-          <router-link :to="{name: 'subscribers_list', params: { listID: props.row.id }}">
+          <a :href="`/lists/${props.row.id}`"
+            @click.prevent="showEditForm(props.row)">
             {{ props.row.name }}
             {{ props.row.name }}
-          </router-link>
+          </a>
           <b-taglist>
           <b-taglist>
               <b-tag class="is-small" v-for="t in props.row.tags" :key="t">{{ t }}</b-tag>
               <b-tag class="is-small" v-for="t in props.row.tags" :key="t">{{ t }}</b-tag>
           </b-taglist>
           </b-taglist>
@@ -41,14 +42,14 @@
         header-class="cy-type" sortable>
         header-class="cy-type" sortable>
         <div>
         <div>
           <b-tag :class="props.row.type" :data-cy="`type-${props.row.type}`">
           <b-tag :class="props.row.type" :data-cy="`type-${props.row.type}`">
-            {{ $t('lists.types.' + props.row.type) }}
+            {{ $t(`lists.types.${props.row.type}`) }}
           </b-tag>
           </b-tag>
           {{ ' ' }}
           {{ ' ' }}
           <b-tag :data-cy="`optin-${props.row.optin}`">
           <b-tag :data-cy="`optin-${props.row.optin}`">
             <b-icon :icon="props.row.optin === 'double' ?
             <b-icon :icon="props.row.optin === 'double' ?
               'account-check-outline' : 'account-off-outline'" size="is-small" />
               'account-check-outline' : 'account-off-outline'" size="is-small" />
             {{ ' ' }}
             {{ ' ' }}
-            {{ $t('lists.optins.' + props.row.optin) }}
+            {{ $t(`lists.optins.${props.row.optin}`) }}
           </b-tag>{{ ' ' }}
           </b-tag>{{ ' ' }}
           <a v-if="props.row.optin === 'double'" class="is-size-7 send-optin"
           <a v-if="props.row.optin === 'double'" class="is-size-7 send-optin"
             href="#" @click="$utils.confirm(null, () => createOptinCampaign(props.row))"
             href="#" @click="$utils.confirm(null, () => createOptinCampaign(props.row))"
@@ -65,7 +66,7 @@
         :label="$t('globals.terms.subscribers')" header-class="cy-subscribers"
         :label="$t('globals.terms.subscribers')" header-class="cy-subscribers"
         numeric sortable centered>
         numeric sortable centered>
         <router-link :to="`/subscribers/lists/${props.row.id}`">
         <router-link :to="`/subscribers/lists/${props.row.id}`">
-          {{ props.row.subscriberCount }}
+          {{ $utils.formatNumber(props.row.subscriberCount) }}
         </router-link>
         </router-link>
       </b-table-column>
       </b-table-column>
 
 
@@ -104,7 +105,8 @@
     </b-table>
     </b-table>
 
 
     <!-- Add / edit form modal -->
     <!-- Add / edit form modal -->
-    <b-modal scroll="keep" :aria-modal="true" :active.sync="isFormVisible" :width="600">
+    <b-modal scroll="keep" :aria-modal="true" :active.sync="isFormVisible" :width="600"
+      @close="onFormClose">
       <list-form :data="curItem" :isEditing="isEditing" @finished="formFinished"></list-form>
       <list-form :data="curItem" :isEditing="isEditing" @finished="formFinished"></list-form>
     </b-modal>
     </b-modal>
   </section>
   </section>
@@ -130,7 +132,7 @@ export default Vue.extend({
       isFormVisible: false,
       isFormVisible: false,
       queryParams: {
       queryParams: {
         page: 1,
         page: 1,
-        orderBy: 'created_at',
+        orderBy: 'id',
         order: 'asc',
         order: 'asc',
       },
       },
     };
     };
@@ -167,6 +169,12 @@ export default Vue.extend({
       this.getLists();
       this.getLists();
     },
     },
 
 
+    onFormClose() {
+      if (this.$route.params.id) {
+        this.$router.push({ name: 'lists' });
+      }
+    },
+
     getLists() {
     getLists() {
       this.$api.getLists({
       this.$api.getLists({
         page: this.queryParams.page,
         page: this.queryParams.page,
@@ -211,7 +219,13 @@ export default Vue.extend({
   },
   },
 
 
   mounted() {
   mounted() {
-    this.getLists();
+    if (this.$route.params.id) {
+      this.$api.getList(parseInt(this.$route.params.id, 10)).then((data) => {
+        this.showEditForm(data);
+      });
+    } else {
+      this.getLists();
+    }
   },
   },
 });
 });
 </script>
 </script>

+ 1 - 1
frontend/src/views/Media.vue

@@ -8,7 +8,7 @@
 
 
     <b-loading :active="isProcessing || loading.media"></b-loading>
     <b-loading :active="isProcessing || loading.media"></b-loading>
 
 
-    <section class="wrap-small">
+    <section class="wrap">
       <form @submit.prevent="onSubmit" class="box">
       <form @submit.prevent="onSubmit" class="box">
         <div>
         <div>
           <b-field :label="$t('media.uploadImage')">
           <b-field :label="$t('media.uploadImage')">

+ 1 - 1
frontend/src/views/Settings.vue

@@ -17,7 +17,7 @@
     </header>
     </header>
     <hr />
     <hr />
 
 
-    <section class="wrap-small">
+    <section class="wrap">
       <form @submit.prevent="onSubmit">
       <form @submit.prevent="onSubmit">
         <b-tabs type="is-boxed" :animated="false">
         <b-tabs type="is-boxed" :animated="false">
           <b-tab-item :label="$t('settings.general.name')" label-position="on-border">
           <b-tab-item :label="$t('settings.general.name')" label-position="on-border">

+ 3 - 1
frontend/src/views/SubscriberForm.vue

@@ -2,7 +2,9 @@
   <form @submit.prevent="onSubmit">
   <form @submit.prevent="onSubmit">
     <div class="modal-card content" style="width: auto">
     <div class="modal-card content" style="width: auto">
       <header class="modal-card-head">
       <header class="modal-card-head">
-        <b-tag v-if="isEditing" :class="[data.status, 'is-pulled-right']">{{ data.status }}</b-tag>
+        <b-tag v-if="isEditing" :class="[data.status, 'is-pulled-right']">
+          {{ $t(`subscribers.status.${data.status}`) }}
+        </b-tag>
         <h4 v-if="isEditing">{{ data.name }}</h4>
         <h4 v-if="isEditing">{{ data.name }}</h4>
         <h4 v-else>{{ $t('subscribers.newSubscriber') }}</h4>
         <h4 v-else>{{ $t('subscribers.newSubscriber') }}</h4>
 
 

+ 30 - 19
frontend/src/views/Subscribers.vue

@@ -20,7 +20,7 @@
 
 
     <section class="subscribers-controls columns">
     <section class="subscribers-controls columns">
       <div class="column is-4">
       <div class="column is-4">
-        <form @submit.prevent="querySubscribers">
+        <form @submit.prevent="onSubmit">
           <div>
           <div>
             <b-field grouped>
             <b-field grouped>
               <b-input @input="onSimpleQueryInput" v-model="queryInput"
               <b-input @input="onSimpleQueryInput" v-model="queryInput"
@@ -118,7 +118,7 @@
           <a :href="`/subscribers/${props.row.id}`"
           <a :href="`/subscribers/${props.row.id}`"
             @click.prevent="showEditForm(props.row)">
             @click.prevent="showEditForm(props.row)">
             <b-tag :class="props.row.status">
             <b-tag :class="props.row.status">
-              {{ $t('subscribers.status.'+ props.row.status) }}
+              {{ $t(`subscribers.status.${props.row.status}`) }}
             </b-tag>
             </b-tag>
           </a>
           </a>
         </b-table-column>
         </b-table-column>
@@ -246,7 +246,7 @@ export default Vue.extend({
         // ID of the list the current subscriber view is filtered by.
         // ID of the list the current subscriber view is filtered by.
         listID: null,
         listID: null,
         page: 1,
         page: 1,
-        orderBy: 'updated_at',
+        orderBy: 'id',
         order: 'desc',
         order: 'desc',
       },
       },
     };
     };
@@ -317,40 +317,51 @@ export default Vue.extend({
     },
     },
 
 
     onPageChange(p) {
     onPageChange(p) {
-      this.queryParams.page = p;
-      this.querySubscribers();
+      this.querySubscribers({ page: p });
     },
     },
 
 
     onSort(field, direction) {
     onSort(field, direction) {
-      this.queryParams.orderBy = field;
-      this.queryParams.order = direction;
-      this.querySubscribers();
+      this.querySubscribers({ orderBy: field, order: direction });
     },
     },
 
 
     // Prepares an SQL expression for simple name search inputs and saves it
     // Prepares an SQL expression for simple name search inputs and saves it
     // in this.queryExp.
     // in this.queryExp.
     onSimpleQueryInput(v) {
     onSimpleQueryInput(v) {
       const q = v.replace(/'/, "''").trim();
       const q = v.replace(/'/, "''").trim();
-      this.queryParams.queryExp = `(name ~* '${q}' OR email ~* '${q}')`;
+      this.queryParams.page = 1;
+
+      if (this.$utils.validateEmail(q)) {
+        this.queryParams.queryExp = `email = '${q}'`;
+      } else {
+        this.queryParams.queryExp = `(name ~* '${q}' OR email ~* '${q}')`;
+      }
     },
     },
 
 
     // Ctrl + Enter on the advanced query searches.
     // Ctrl + Enter on the advanced query searches.
     onAdvancedQueryEnter(e) {
     onAdvancedQueryEnter(e) {
       if (e.ctrlKey) {
       if (e.ctrlKey) {
-        this.querySubscribers();
+        this.onSubmit();
       }
       }
     },
     },
 
 
+    onSubmit() {
+      this.querySubscribers({ page: 1 });
+    },
+
     // Search / query subscribers.
     // Search / query subscribers.
-    querySubscribers() {
-      this.$api.getSubscribers({
-        list_id: this.queryParams.listID,
-        query: this.queryParams.queryExp,
-        page: this.queryParams.page,
-        order_by: this.queryParams.orderBy,
-        order: this.queryParams.order,
-      }).then(() => {
-        this.bulk.checked = [];
+    querySubscribers(params) {
+      this.queryParams = { ...this.queryParams, ...params };
+
+      this.$nextTick(() => {
+        this.$api.getSubscribers({
+          list_id: this.queryParams.listID,
+          query: this.queryParams.queryExp,
+          page: this.queryParams.page,
+          order_by: this.queryParams.orderBy,
+          order: this.queryParams.order,
+        }).then(() => {
+          this.bulk.checked = [];
+        });
       });
       });
     },
     },
 
 

+ 9 - 14
frontend/yarn.lock

@@ -2571,12 +2571,12 @@ browserslist@^4.0.0, browserslist@^4.12.0, browserslist@^4.14.5, browserslist@^4
     escalade "^3.1.1"
     escalade "^3.1.1"
     node-releases "^1.1.71"
     node-releases "^1.1.71"
 
 
-buefy@^0.9.7:
-  version "0.9.7"
-  resolved "https://registry.yarnpkg.com/buefy/-/buefy-0.9.7.tgz#694e73fe0b32632a53d94c5ba9cfa4468363badd"
-  integrity sha512-Fli0ZjNDgtFtHm0LItWmfhNJ1oLjDwPzUWccvwXXoo2mADXaH8JQxyhY+drUuUV5/GMu5PtwqQSqPgZy942VZg==
+buefy@^0.9.10:
+  version "0.9.10"
+  resolved "https://registry.yarnpkg.com/buefy/-/buefy-0.9.10.tgz#17f64ee1ba43a145d1d3c56f45cba95e4e2975fa"
+  integrity sha512-xXEoy/NTgBNiIfBTCdHi2Vu5SJJdB046py6ekUvYuUgYwRvulySZksdecVNNWdfEVU8iD4esZaRbTLwCegFcVQ==
   dependencies:
   dependencies:
-    bulma "0.9.2"
+    bulma "0.9.3"
 
 
 buffer-crc32@~0.2.3:
 buffer-crc32@~0.2.3:
   version "0.2.13"
   version "0.2.13"
@@ -2617,10 +2617,10 @@ builtin-status-codes@^3.0.0:
   resolved "https://registry.yarnpkg.com/builtin-status-codes/-/builtin-status-codes-3.0.0.tgz#85982878e21b98e1c66425e03d0174788f569ee8"
   resolved "https://registry.yarnpkg.com/builtin-status-codes/-/builtin-status-codes-3.0.0.tgz#85982878e21b98e1c66425e03d0174788f569ee8"
   integrity sha1-hZgoeOIbmOHGZCXgPQF0eI9Wnug=
   integrity sha1-hZgoeOIbmOHGZCXgPQF0eI9Wnug=
 
 
-bulma@0.9.2:
-  version "0.9.2"
-  resolved "https://registry.yarnpkg.com/bulma/-/bulma-0.9.2.tgz#340011e119c605f19b8ca886bfea595f1deaf23c"
-  integrity sha512-e14EF+3VSZ488yL/lJH0tR8mFWiEQVCMi/BQUMi2TGMBOk+zrDg4wryuwm/+dRSHJw0gMawp2tsW7X1JYUCE3A==
+bulma@0.9.3:
+  version "0.9.3"
+  resolved "https://registry.yarnpkg.com/bulma/-/bulma-0.9.3.tgz#ddccb7436ebe3e21bf47afe01d3c43a296b70243"
+  integrity sha512-0d7GNW1PY4ud8TWxdNcP6Cc8Bu7MxcntD/RRLGWuiw/s0a9P+XlH/6QoOIrmbj6o8WWJzJYhytiu9nFjTszk1g==
 
 
 bytes@3.0.0:
 bytes@3.0.0:
   version "3.0.0"
   version "3.0.0"
@@ -9991,11 +9991,6 @@ vm-browserify@^1.0.1:
   resolved "https://registry.yarnpkg.com/vm-browserify/-/vm-browserify-1.1.2.tgz#78641c488b8e6ca91a75f511e7a3b32a86e5dda0"
   resolved "https://registry.yarnpkg.com/vm-browserify/-/vm-browserify-1.1.2.tgz#78641c488b8e6ca91a75f511e7a3b32a86e5dda0"
   integrity sha512-2ham8XPWTONajOR0ohOKOHXkm3+gaBmGut3SRuu75xLd/RRaY6vqgh8NBYYk7+RW3u5AtzPQZG8F10LHkl0lAQ==
   integrity sha512-2ham8XPWTONajOR0ohOKOHXkm3+gaBmGut3SRuu75xLd/RRaY6vqgh8NBYYk7+RW3u5AtzPQZG8F10LHkl0lAQ==
 
 
-vue-c3@^1.2.11:
-  version "1.2.11"
-  resolved "https://registry.yarnpkg.com/vue-c3/-/vue-c3-1.2.11.tgz#6937f0dd54addab2b76de74cd30c0ab9ad788080"
-  integrity sha512-jxYZ726lKO1Qa+CHOcekPD4ZIwcMQy2LYDafYy2jYD1oswAo/4SnEJmbwp9X+NWzZg/KIAijeB9ImS7Gfvhceg==
-
 vue-eslint-parser@^7.6.0:
 vue-eslint-parser@^7.6.0:
   version "7.6.0"
   version "7.6.0"
   resolved "https://registry.yarnpkg.com/vue-eslint-parser/-/vue-eslint-parser-7.6.0.tgz#01ea1a2932f581ff244336565d712801f8f72561"
   resolved "https://registry.yarnpkg.com/vue-eslint-parser/-/vue-eslint-parser-7.6.0.tgz#01ea1a2932f581ff244336565d712801f8f72561"

+ 1 - 1
i18n/cs-cz.json

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

+ 1 - 1
i18n/de.json

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

+ 9 - 1
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}",

+ 1 - 1
i18n/es.json

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

+ 1 - 1
i18n/fr.json

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

+ 1 - 1
i18n/it.json

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

+ 1 - 1
i18n/ml.json

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

+ 1 - 1
i18n/pl.json

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

+ 1 - 1
i18n/pt-BR.json

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

+ 1 - 1
i18n/pt.json

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

+ 1 - 1
i18n/ru.json

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

+ 1 - 1
i18n/tr.json

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

+ 32 - 1
internal/migrations/v2.0.0.go

@@ -8,6 +8,17 @@ import (
 
 
 // V2_0_0 performs the DB migrations for v.1.0.0.
 // V2_0_0 performs the DB migrations for v.1.0.0.
 func V2_0_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf) error {
 func V2_0_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf) error {
+	if _, err := db.Exec(`
+		DO $$
+		BEGIN
+			IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'bounce_type') THEN
+				CREATE TYPE bounce_type AS ENUM ('soft', 'hard', 'complaint');
+			END IF;
+		END$$;
+	`); err != nil {
+		return err
+	}
+
 	if _, err := db.Exec(`
 	if _, err := db.Exec(`
 		CREATE TABLE IF NOT EXISTS bounces (
 		CREATE TABLE IF NOT EXISTS bounces (
 		    id               SERIAL PRIMARY KEY,
 		    id               SERIAL PRIMARY KEY,
@@ -39,7 +50,27 @@ func V2_0_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf) error {
 		return err
 		return err
 	}
 	}
 
 
-	if _, err := db.Exec(`ALTER TABLE subscribers DROP COLUMN IF EXISTS campaigns; `); err != nil {
+	if _, err := db.Exec(`ALTER TABLE subscribers DROP COLUMN IF EXISTS campaigns`); err != nil {
+		return err
+	}
+
+	if _, err := db.Exec(`
+		DO $$
+		BEGIN
+			IF NOT EXISTS (SELECT 1 FROM pg_class WHERE relname = 'campaign_views_pkey') THEN
+				ALTER TABLE campaign_views ADD COLUMN IF NOT EXISTS id BIGSERIAL PRIMARY KEY;
+			END IF;
+			IF NOT EXISTS (SELECT 1 FROM pg_class WHERE relname = 'link_clicks_pkey') THEN
+				ALTER TABLE link_clicks ADD COLUMN IF NOT EXISTS id BIGSERIAL PRIMARY KEY;
+			END IF;
+			IF NOT EXISTS (SELECT 1 FROM pg_class WHERE relname = 'campaign_lists_pkey') THEN
+				ALTER TABLE campaign_lists ADD COLUMN IF NOT EXISTS id BIGSERIAL PRIMARY KEY;
+			END IF;
+		END$$;
+
+		CREATE INDEX IF NOT EXISTS idx_views_date ON campaign_views((TIMEZONE('UTC', created_at)::DATE));
+		CREATE INDEX IF NOT EXISTS idx_clicks_date ON link_clicks((TIMEZONE('UTC', created_at)::DATE));
+	`); err != nil {
 		return err
 		return err
 	}
 	}
 
 

+ 0 - 4
models/models.go

@@ -126,10 +126,6 @@ type Subscriber struct {
 	Attribs SubscriberAttribs `db:"attribs" json:"attribs"`
 	Attribs SubscriberAttribs `db:"attribs" json:"attribs"`
 	Status  string            `db:"status" json:"status"`
 	Status  string            `db:"status" json:"status"`
 	Lists   types.JSONText    `db:"lists" json:"lists"`
 	Lists   types.JSONText    `db:"lists" json:"lists"`
-
-	// Pseudofield for getting the total number of subscribers
-	// in searches and queries.
-	Total int `db:"total" json:"-"`
 }
 }
 type subLists struct {
 type subLists struct {
 	SubscriberID int            `db:"subscriber_id"`
 	SubscriberID int            `db:"subscriber_id"`

+ 84 - 16
queries.sql

@@ -208,13 +208,13 @@ views AS (
     SELECT subject as campaign, COUNT(subscriber_id) as views FROM campaign_views
     SELECT subject as campaign, COUNT(subscriber_id) as views FROM campaign_views
         LEFT JOIN campaigns ON (campaigns.id = campaign_views.campaign_id)
         LEFT JOIN campaigns ON (campaigns.id = campaign_views.campaign_id)
         WHERE subscriber_id = (SELECT id FROM prof)
         WHERE subscriber_id = (SELECT id FROM prof)
-        GROUP BY campaigns.id ORDER BY id
+        GROUP BY campaigns.id ORDER BY campaigns.id
 ),
 ),
 clicks AS (
 clicks AS (
     SELECT url, COUNT(subscriber_id) as clicks FROM link_clicks
     SELECT url, COUNT(subscriber_id) as clicks FROM link_clicks
         LEFT JOIN links ON (links.id = link_clicks.link_id)
         LEFT JOIN links ON (links.id = link_clicks.link_id)
         WHERE subscriber_id = (SELECT id FROM prof)
         WHERE subscriber_id = (SELECT id FROM prof)
-        GROUP BY links.id ORDER BY id
+        GROUP BY links.id ORDER BY links.id
 )
 )
 SELECT (SELECT email FROM prof) as email,
 SELECT (SELECT email FROM prof) as email,
         COALESCE((SELECT JSON_AGG(t) FROM prof t), '{}') AS profile,
         COALESCE((SELECT JSON_AGG(t) FROM prof t), '{}') AS profile,
@@ -233,7 +233,7 @@ SELECT (SELECT email FROM prof) as email,
 -- for pagination in the frontend, albeit being a field that'll repeat
 -- for pagination in the frontend, albeit being a field that'll repeat
 -- with every resultant row.
 -- with every resultant row.
 -- %s = arbitrary expression, %s = order by field, %s = order direction
 -- %s = arbitrary expression, %s = order by field, %s = order direction
-SELECT COUNT(*) OVER () AS total, subscribers.* FROM subscribers
+SELECT subscribers.* FROM subscribers
     LEFT JOIN subscriber_lists
     LEFT JOIN subscriber_lists
     ON (
     ON (
         -- Optional list filtering.
         -- Optional list filtering.
@@ -244,6 +244,17 @@ SELECT COUNT(*) OVER () AS total, subscribers.* FROM subscribers
     %s
     %s
     ORDER BY %s %s OFFSET $2 LIMIT (CASE WHEN $3 = 0 THEN NULL ELSE $3 END);
     ORDER BY %s %s OFFSET $2 LIMIT (CASE WHEN $3 = 0 THEN NULL ELSE $3 END);
 
 
+-- name: query-subscribers-count
+-- Replica of query-subscribers for obtaining the results count.
+SELECT COUNT(*) AS total FROM subscribers
+    LEFT JOIN subscriber_lists
+    ON (
+        -- Optional list filtering.
+        (CASE WHEN CARDINALITY($1::INT[]) > 0 THEN true ELSE false END)
+        AND subscriber_lists.subscriber_id = subscribers.id
+    )
+    WHERE subscriber_lists.list_id = ALL($1::INT[]) %s;
+
 -- name: query-subscribers-for-export
 -- name: query-subscribers-for-export
 -- raw: true
 -- raw: true
 -- Unprepared statement for issuring arbitrary WHERE conditions for
 -- Unprepared statement for issuring arbitrary WHERE conditions for
@@ -332,7 +343,10 @@ WITH ls AS (
     WHERE ($1 = 0 OR id = $1) OFFSET $2 LIMIT (CASE WHEN $3 = 0 THEN NULL ELSE $3 END)
     WHERE ($1 = 0 OR id = $1) OFFSET $2 LIMIT (CASE WHEN $3 = 0 THEN NULL ELSE $3 END)
 ),
 ),
 counts AS (
 counts AS (
-	SELECT COUNT(*) as subscriber_count, list_id FROM subscriber_lists WHERE status != 'unsubscribed' GROUP BY list_id
+	SELECT COUNT(*) as subscriber_count, list_id FROM subscriber_lists
+    WHERE status != 'unsubscribed'
+    AND ($1 = 0 OR list_id = $1)
+    GROUP BY list_id
 )
 )
 SELECT ls.*, COALESCE(subscriber_count, 0) AS subscriber_count FROM ls
 SELECT ls.*, COALESCE(subscriber_count, 0) AS subscriber_count FROM ls
     LEFT JOIN counts ON (counts.list_id = ls.id) ORDER BY %s %s;
     LEFT JOIN counts ON (counts.list_id = ls.id) ORDER BY %s %s;
@@ -369,9 +383,9 @@ DELETE FROM lists WHERE id = ALL($1);
 -- This creates the campaign and inserts campaign_lists relationships.
 -- This creates the campaign and inserts campaign_lists relationships.
 WITH campLists AS (
 WITH campLists AS (
     -- Get the list_ids and their optin statuses for the campaigns found in the previous step.
     -- Get the list_ids and their optin statuses for the campaigns found in the previous step.
-    SELECT id AS list_id, campaign_id, optin FROM lists
+    SELECT lists.id AS list_id, campaign_id, optin FROM lists
     INNER JOIN campaign_lists ON (campaign_lists.list_id = lists.id)
     INNER JOIN campaign_lists ON (campaign_lists.list_id = lists.id)
-    WHERE id=ANY($13::INT[])
+    WHERE lists.id = ANY($13::INT[])
 ),
 ),
 tpl AS (
 tpl AS (
     -- If there's no template_id given, use the defualt template.
     -- If there's no template_id given, use the defualt template.
@@ -504,7 +518,7 @@ WITH camps AS (
 ),
 ),
 campLists AS (
 campLists AS (
     -- Get the list_ids and their optin statuses for the campaigns found in the previous step.
     -- Get the list_ids and their optin statuses for the campaigns found in the previous step.
-    SELECT id AS list_id, campaign_id, optin FROM lists
+    SELECT lists.id AS list_id, campaign_id, optin FROM lists
     INNER JOIN campaign_lists ON (campaign_lists.list_id = lists.id)
     INNER JOIN campaign_lists ON (campaign_lists.list_id = lists.id)
     WHERE campaign_lists.campaign_id = ANY(SELECT id FROM camps)
     WHERE campaign_lists.campaign_id = ANY(SELECT id FROM camps)
 ),
 ),
@@ -544,6 +558,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
@@ -554,7 +605,7 @@ WITH camps AS (
     WHERE id=$1 AND status='running'
     WHERE id=$1 AND status='running'
 ),
 ),
 campLists AS (
 campLists AS (
-    SELECT id AS list_id, optin FROM lists
+    SELECT lists.id AS list_id, optin FROM lists
     INNER JOIN campaign_lists ON (campaign_lists.list_id = lists.id)
     INNER JOIN campaign_lists ON (campaign_lists.list_id = lists.id)
     WHERE campaign_lists.campaign_id = $1
     WHERE campaign_lists.campaign_id = $1
 ),
 ),
@@ -736,26 +787,43 @@ INSERT INTO link_clicks (campaign_id, subscriber_id, link_id) VALUES(
 
 
 -- name: get-dashboard-charts
 -- name: get-dashboard-charts
 WITH clicks AS (
 WITH clicks AS (
-    -- Clicks by day for the last 3 months
     SELECT JSON_AGG(ROW_TO_JSON(row))
     SELECT JSON_AGG(ROW_TO_JSON(row))
-    FROM (SELECT COUNT(*) AS count, created_at::DATE as date
-          FROM link_clicks GROUP by date ORDER BY date DESC LIMIT 100
+    FROM (
+        WITH viewDates AS (
+          SELECT TIMEZONE('UTC', created_at)::DATE AS to_date,
+                 TIMEZONE('UTC', created_at)::DATE - INTERVAL '30 DAY' AS from_date
+                 FROM link_clicks ORDER BY id DESC LIMIT 1
+        )
+        SELECT COUNT(*) AS count, created_at::DATE as date FROM link_clicks
+          -- use > between < to force the use of the date index.
+          WHERE TIMEZONE('UTC', created_at)::DATE BETWEEN (SELECT from_date FROM viewDates) AND (SELECT to_date FROM viewDates)
+          GROUP by date ORDER BY date
     ) row
     ) row
 ),
 ),
 views AS (
 views AS (
-    -- Views by day for the last 3 months
     SELECT JSON_AGG(ROW_TO_JSON(row))
     SELECT JSON_AGG(ROW_TO_JSON(row))
-    FROM (SELECT COUNT(*) AS count, created_at::DATE as date
-          FROM campaign_views GROUP by date ORDER BY date DESC LIMIT 100
+    FROM (
+        WITH viewDates AS (
+          SELECT TIMEZONE('UTC', created_at)::DATE AS to_date,
+                 TIMEZONE('UTC', created_at)::DATE - INTERVAL '30 DAY' AS from_date
+                 FROM campaign_views ORDER BY id DESC LIMIT 1
+        )
+        SELECT COUNT(*) AS count, created_at::DATE as date FROM campaign_views
+          -- use > between < to force the use of the date index.
+          WHERE TIMEZONE('UTC', created_at)::DATE BETWEEN (SELECT from_date FROM viewDates) AND (SELECT to_date FROM viewDates)
+          GROUP by date ORDER BY date
     ) row
     ) row
 )
 )
 SELECT JSON_BUILD_OBJECT('link_clicks', COALESCE((SELECT * FROM clicks), '[]'),
 SELECT JSON_BUILD_OBJECT('link_clicks', COALESCE((SELECT * FROM clicks), '[]'),
                         'campaign_views', COALESCE((SELECT * FROM views), '[]'));
                         'campaign_views', COALESCE((SELECT * FROM views), '[]'));
 
 
 -- name: get-dashboard-counts
 -- name: get-dashboard-counts
+WITH subs AS (
+    SELECT COUNT(*) AS num, status FROM subscribers GROUP BY status
+)
 SELECT JSON_BUILD_OBJECT('subscribers', JSON_BUILD_OBJECT(
 SELECT JSON_BUILD_OBJECT('subscribers', JSON_BUILD_OBJECT(
-                            'total', (SELECT COUNT(*) FROM subscribers),
-                            'blocklisted', (SELECT COUNT(*) FROM subscribers WHERE status='blocklisted'),
+                            'total', (SELECT SUM(num) FROM subs),
+                            'blocklisted', (SELECT num FROM subs WHERE status='blocklisted'),
                             'orphans', (
                             'orphans', (
                                 SELECT COUNT(id) FROM subscribers
                                 SELECT COUNT(id) FROM subscribers
                                 LEFT JOIN subscriber_lists ON (subscribers.id = subscriber_lists.subscriber_id)
                                 LEFT JOIN subscriber_lists ON (subscribers.id = subscriber_lists.subscriber_id)

+ 6 - 0
schema.sql

@@ -102,6 +102,7 @@ CREATE TABLE campaigns (
 
 
 DROP TABLE IF EXISTS campaign_lists CASCADE;
 DROP TABLE IF EXISTS campaign_lists CASCADE;
 CREATE TABLE campaign_lists (
 CREATE TABLE campaign_lists (
+    id           BIGSERIAL PRIMARY KEY,
     campaign_id  INTEGER NOT NULL REFERENCES campaigns(id) ON DELETE CASCADE ON UPDATE CASCADE,
     campaign_id  INTEGER NOT NULL REFERENCES campaigns(id) ON DELETE CASCADE ON UPDATE CASCADE,
 
 
     -- Lists may be deleted, so list_id is nullable
     -- Lists may be deleted, so list_id is nullable
@@ -115,6 +116,7 @@ DROP INDEX IF EXISTS idx_camp_lists_list_id; CREATE INDEX idx_camp_lists_list_id
 
 
 DROP TABLE IF EXISTS campaign_views CASCADE;
 DROP TABLE IF EXISTS campaign_views CASCADE;
 CREATE TABLE campaign_views (
 CREATE TABLE campaign_views (
+    id               BIGSERIAL PRIMARY KEY,
     campaign_id      INTEGER NOT NULL REFERENCES campaigns(id) ON DELETE CASCADE ON UPDATE CASCADE,
     campaign_id      INTEGER NOT NULL REFERENCES campaigns(id) ON DELETE CASCADE ON UPDATE CASCADE,
 
 
     -- Subscribers may be deleted, but the view counts should remain.
     -- Subscribers may be deleted, but the view counts should remain.
@@ -123,6 +125,7 @@ CREATE TABLE campaign_views (
 );
 );
 DROP INDEX IF EXISTS idx_views_camp_id; CREATE INDEX idx_views_camp_id ON campaign_views(campaign_id);
 DROP INDEX IF EXISTS idx_views_camp_id; CREATE INDEX idx_views_camp_id ON campaign_views(campaign_id);
 DROP INDEX IF EXISTS idx_views_subscriber_id; CREATE INDEX idx_views_subscriber_id ON campaign_views(subscriber_id);
 DROP INDEX IF EXISTS idx_views_subscriber_id; CREATE INDEX idx_views_subscriber_id ON campaign_views(subscriber_id);
+DROP INDEX IF EXISTS idx_views_date; CREATE INDEX idx_views_date ON campaign_views((TIMEZONE('UTC', created_at)::DATE));
 
 
 -- media
 -- media
 DROP TABLE IF EXISTS media CASCADE;
 DROP TABLE IF EXISTS media CASCADE;
@@ -146,6 +149,7 @@ CREATE TABLE links (
 
 
 DROP TABLE IF EXISTS link_clicks CASCADE;
 DROP TABLE IF EXISTS link_clicks CASCADE;
 CREATE TABLE link_clicks (
 CREATE TABLE link_clicks (
+    id               BIGSERIAL PRIMARY KEY,
     campaign_id      INTEGER NULL REFERENCES campaigns(id) ON DELETE CASCADE ON UPDATE CASCADE,
     campaign_id      INTEGER NULL REFERENCES campaigns(id) ON DELETE CASCADE ON UPDATE CASCADE,
     link_id          INTEGER NOT NULL REFERENCES links(id) ON DELETE CASCADE ON UPDATE CASCADE,
     link_id          INTEGER NOT NULL REFERENCES links(id) ON DELETE CASCADE ON UPDATE CASCADE,
 
 
@@ -156,6 +160,7 @@ CREATE TABLE link_clicks (
 DROP INDEX IF EXISTS idx_clicks_camp_id; CREATE INDEX idx_clicks_camp_id ON link_clicks(campaign_id);
 DROP INDEX IF EXISTS idx_clicks_camp_id; CREATE INDEX idx_clicks_camp_id ON link_clicks(campaign_id);
 DROP INDEX IF EXISTS idx_clicks_link_id; CREATE INDEX idx_clicks_link_id ON link_clicks(link_id);
 DROP INDEX IF EXISTS idx_clicks_link_id; CREATE INDEX idx_clicks_link_id ON link_clicks(link_id);
 DROP INDEX IF EXISTS idx_clicks_sub_id; CREATE INDEX idx_clicks_sub_id ON link_clicks(subscriber_id);
 DROP INDEX IF EXISTS idx_clicks_sub_id; CREATE INDEX idx_clicks_sub_id ON link_clicks(subscriber_id);
+DROP INDEX IF EXISTS idx_clicks_date; CREATE INDEX idx_clicks_date ON link_clicks((TIMEZONE('UTC', created_at)::DATE));
 
 
 -- settings
 -- settings
 DROP TABLE IF EXISTS settings CASCADE;
 DROP TABLE IF EXISTS settings CASCADE;
@@ -227,3 +232,4 @@ CREATE TABLE bounces (
 DROP INDEX IF EXISTS idx_bounces_sub_id; CREATE INDEX idx_bounces_sub_id ON bounces(subscriber_id);
 DROP INDEX IF EXISTS idx_bounces_sub_id; CREATE INDEX idx_bounces_sub_id ON bounces(subscriber_id);
 DROP INDEX IF EXISTS idx_bounces_camp_id; CREATE INDEX idx_bounces_camp_id ON bounces(campaign_id);
 DROP INDEX IF EXISTS idx_bounces_camp_id; CREATE INDEX idx_bounces_camp_id ON bounces(campaign_id);
 DROP INDEX IF EXISTS idx_bounces_source; CREATE INDEX idx_bounces_source ON bounces(source);
 DROP INDEX IF EXISTS idx_bounces_source; CREATE INDEX idx_bounces_source ON bounces(source);
+DROP INDEX IF EXISTS idx_bounces_date; CREATE INDEX idx_bounces_date ON bounces((TIMEZONE('UTC', created_at)::DATE));

+ 2 - 2
static/email-templates/base.html

@@ -43,7 +43,7 @@
                 padding: 30px;
                 padding: 30px;
             }
             }
             .button {
             .button {
-                background: #7f2aff;
+                background: #0055d4;
                 color: #fff !important;
                 color: #fff !important;
                 display: inline-block;
                 display: inline-block;
                 border-radius: 3px;
                 border-radius: 3px;
@@ -61,7 +61,7 @@
             }
             }
 
 
             a {
             a {
-                color: #7f2aff;
+                color: #0055d4;
             }
             }
                 a:hover {
                 a:hover {
                     color: #111;
                     color: #111;

+ 2 - 2
static/email-templates/default.tpl

@@ -24,7 +24,7 @@
             }
             }
 
 
             .button {
             .button {
-                background: #7f2aff;
+                background: #0055d4;
                 border-radius: 3px;
                 border-radius: 3px;
                 text-decoration: none !important;
                 text-decoration: none !important;
                 color: #fff !important;
                 color: #fff !important;
@@ -54,7 +54,7 @@
             }
             }
 
 
             a {
             a {
-                color: #7f2aff;
+                color: #0055d4;
             }
             }
                 a:hover {
                 a:hover {
                     color: #111;
                     color: #111;

BIN
static/public/static/favicon.png


BIN
static/public/static/logo.png


Файловите разлики са ограничени, защото са твърде много
+ 0 - 135
static/public/static/logo.svg


+ 31 - 197
static/public/static/style.css

@@ -1,184 +1,6 @@
 * {
 * {
   box-sizing: border-box;
   box-sizing: border-box;
 }
 }
-
-/* Flexit grid */
-.container {
-  position: relative;
-  width: 100%;
-  max-width: 960px;
-  margin: 0 auto;
-  padding: 0 10px;
-  box-sizing: border-box;
-}
-.row {
-  box-sizing: border-box;
-  display: flex;
-  flex: 0 1 auto;
-  flex-flow: row wrap;
-}
-.columns,
-.column {
-  box-sizing: border-box;
-  flex-grow: 1;
-  flex-shrink: 1;
-  flex-basis: 1;
-  margin: 10px 0 10px 4%;
-}
-.column:first-child,
-.columns:first-child {
-  margin-left: 0;
-}
-.one {
-  max-width: 4.6666666667%;
-}
-.two {
-  max-width: 13.3333333333%;
-}
-.three {
-  max-width: 22%;
-}
-.four {
-  max-width: 30.6666666667%;
-}
-.five {
-  max-width: 39.3333333333%;
-}
-.six {
-  max-width: 48%;
-}
-.seven {
-  max-width: 56.6666666667%;
-}
-.eight {
-  max-width: 65.3333333333%;
-}
-.nine {
-  max-width: 74%;
-}
-.ten {
-  max-width: 82.6666666667%;
-}
-.eleven {
-  max-width: 91.3333333333%;
-}
-.twelve {
-  max-width: 100%;
-  margin-left: 0;
-}
-.column-offset-0 {
-  margin-left: 0;
-}
-.column-offset-1 {
-  margin-left: 8.33333333%;
-}
-.column-offset-2 {
-  margin-left: 16.66666667%;
-}
-.column-offset-3 {
-  margin-left: 25%;
-}
-.column-offset-4 {
-  margin-left: 33.33333333%;
-}
-.column-offset-5 {
-  margin-left: 41.66666667%;
-}
-.column-offset-6 {
-  margin-left: 50%;
-}
-.column-offset-7 {
-  margin-left: 58.33333333%;
-}
-.column-offset-8 {
-  margin-left: 66.66666667%;
-}
-.column-offset-9 {
-  margin-left: 75%;
-}
-.column-offset-10 {
-  margin-left: 83.33333333%;
-}
-.column-offset-11 {
-  margin-left: 91.66666667%;
-}
-.between {
-  justify-content: space-between;
-}
-.evenly {
-  justify-content: space-evenly;
-}
-.around {
-  justify-content: space-around;
-}
-.center {
-  justify-content: center;
-  text-align: center;
-}
-.start {
-  justify-content: flex-start;
-}
-.end {
-  justify-content: flex-end;
-}
-.top {
-  align-items: flex-start;
-}
-.bottom {
-  align-items: flex-end;
-}
-.middle {
-  align-items: center;
-}
-.first {
-  order: -1;
-}
-.last {
-  order: 1;
-}
-.vertical {
-  flex-flow: column wrap;
-}
-.row-align-center {
-  align-items: center;
-}
-.space-right {
-  margin-right: 10px;
-}
-.space-left {
-  margin-left: 10px;
-}
-.space-bottom {
-  margin-bottom: 10px;
-}
-.space-top {
-  margin-top: 10px;
-}
-@media screen and (max-width: 768px) {
-  .container {
-    overflow: auto;
-  }
-  .columns,
-  .column {
-    min-width: 100%;
-    margin: 10px 0;
-  }
-  .column-offset-0,
-  .column-offset-1,
-  .column-offset-2,
-  .column-offset-3,
-  .column-offset-4,
-  .column-offset-5,
-  .column-offset-6,
-  .column-offset-7,
-  .column-offset-8,
-  .column-offset-9,
-  .column-offset-10,
-  .column-offset-11 {
-    margin: unset;
-  }
-} /*# sourceMappingURL=dist/flexit.min.css.map */
-
 html, body {
 html, body {
   padding: 0;
   padding: 0;
   margin: 0;
   margin: 0;
@@ -188,11 +10,11 @@ body {
   background: #f9f9f9;
   background: #f9f9f9;
   font-family: "Open Sans", "Helvetica Neue", sans-serif;
   font-family: "Open Sans", "Helvetica Neue", sans-serif;
   font-size: 16px;
   font-size: 16px;
-  line-height: 28px;
+  line-height: 26px;
   color: #111;
   color: #111;
 }
 }
 a {
 a {
-  color: #7f2aff;
+  color: #0055d4;
 }
 }
 a:hover {
 a:hover {
   color: #111;
   color: #111;
@@ -216,14 +38,17 @@ input[type="text"], input[type="email"], select {
   border: 1px solid #888;
   border: 1px solid #888;
   border-radius: 3px;
   border-radius: 3px;
   width: 100%;
   width: 100%;
+  box-shadow: 2px 2px 0 #f3f3f3;
+  border: 1px solid #ddd;
+  font-size: 1em;
 }
 }
   input:focus {
   input:focus {
-    border-color: #7f2aff;
+    border-color: #0055d4;
   }
   }
 
 
 .button {
 .button {
-  background: #7f2aff;
-  padding: 15px 30px;
+  background: #0055d4;
+  padding: 10px 30px;
   border-radius: 3px;
   border-radius: 3px;
   border: 0;
   border: 0;
   cursor: pointer;
   cursor: pointer;
@@ -238,19 +63,22 @@ input[type="text"], input[type="email"], select {
 }
 }
 .button.button-outline {
 .button.button-outline {
   background: #fff;
   background: #fff;
-  border: 1px solid #7f2aff;
-  color: #7f2aff;
+  border: 1px solid #0055d4;
+  color: #0055d4;
 }
 }
 .button.button-outline:hover {
 .button.button-outline:hover {
-  background-color: #7f2aff;
+  background-color: #0055d4;
   color: #fff;
   color: #fff;
 }
 }
 
 
+.container {
+  margin: 60px auto 15px auto;
+  max-width: 550px;  
+}
+
 .wrap {
 .wrap {
   background: #fff;
   background: #fff;
-  margin-top: 60px;
-  max-width: 600px;
-  padding: 45px;
+  padding: 40px;
   box-shadow: 2px 2px 0 #f3f3f3;
   box-shadow: 2px 2px 0 #f3f3f3;
   border: 1px solid #eee;
   border: 1px solid #eee;
 }
 }
@@ -271,27 +99,33 @@ input[type="text"], input[type="email"], select {
   border-top: 1px solid #eee;
   border-top: 1px solid #eee;
 }
 }
 
 
+.row {
+  margin-bottom: 20px;
+}
 .form .lists {
 .form .lists {
   margin-top: 45px;
   margin-top: 45px;
+  list-style-type: none;
+  padding: 0;
 }
 }
   .form .nonce {
   .form .nonce {
     display: none;
     display: none;
   }
   }
 
 
-.footer {
+footer.container {
+  margin-top: 15px;
   text-align: center;
   text-align: center;
   color: #aaa;
   color: #aaa;
   font-size: 0.775em;
   font-size: 0.775em;
   margin-top: 30px;
   margin-top: 30px;
   margin-bottom: 30px;
   margin-bottom: 30px;
 }
 }
-.footer a {
-  color: #aaa;
-  text-decoration: none;
-}
-.footer a:hover {
-  color: #111;
-}
+  footer a {
+    color: #aaa;
+    text-decoration: none;
+  }
+  footer a:hover {
+    color: #111;
+  }
 
 
 @media screen and (max-width: 650px) {
 @media screen and (max-width: 650px) {
   .wrap {
   .wrap {

+ 3 - 7
static/public/templates/index.html

@@ -6,8 +6,6 @@
 	<title>{{ .Data.Title }}</title>
 	<title>{{ .Data.Title }}</title>
 	<meta name="description" content="{{ .Data.Description }}" />
 	<meta name="description" content="{{ .Data.Description }}" />
 	<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1" />
 	<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1" />
-
-	<link href="https://fonts.googleapis.com/css?family=IBM+Plex+Sans:400,600" rel="stylesheet">
 	<link href="/public/static/style.css" rel="stylesheet" type="text/css" />
 	<link href="/public/static/style.css" rel="stylesheet" type="text/css" />
 
 
 	{{ if ne .FaviconURL "" }}
 	{{ if ne .FaviconURL "" }}
@@ -32,11 +30,9 @@
 {{ define "footer" }}
 {{ define "footer" }}
 	</div>
 	</div>
 	
 	
-	<div class="container">
-		<footer class="footer">
-			Powered by <a target="_blank" href="https://listmonk.app">listmonk</a>
-		</footer>
-	</div>
+	<footer class="container">
+		Powered by <a target="_blank" href="https://listmonk.app">listmonk</a>
+	</footer>
 </body>
 </body>
 </html>
 </html>
 {{ end }}
 {{ end }}

+ 6 - 10
static/public/templates/subscription-form.html

@@ -15,19 +15,15 @@
                 <label>{{ L.T "public.subName" }}</label>
                 <label>{{ L.T "public.subName" }}</label>
                 <input name="name" type="text" placeholder="{{ L.T "public.subName" }}" >
                 <input name="name" type="text" placeholder="{{ L.T "public.subName" }}" >
             </p>
             </p>
-            <div class="lists">
+            <ul class="lists">
                 <h2>{{ L.T "globals.terms.lists" }}</h2>
                 <h2>{{ L.T "globals.terms.lists" }}</h2>
                 {{ range $i, $l := .Data.Lists }}
                 {{ range $i, $l := .Data.Lists }}
-                    <div class="row">
-                        <div class="one column">
-                            <input checked="true" id="l-{{ $l.UUID}}" type="checkbox" name="l" value="{{ $l.UUID }}" >
-                        </div>
-                        <div class="eleven columns">
-                            <label for="l-{{ $l.UUID}}">{{ $l.Name }}</label>
-                        </div>
-                    </div>
+                    <li>
+                        <input checked="true" id="l-{{ $l.UUID}}" type="checkbox" name="l" value="{{ $l.UUID }}" >
+                        <label for="l-{{ $l.UUID}}">{{ $l.Name }}</label>
+                    </li>
                 {{ end }}
                 {{ end }}
-            </div>
+            </ul>
             <p>
             <p>
                 <button type="submit" class="button">{{ L.T "public.sub" }}</button>
                 <button type="submit" class="button">{{ L.T "public.sub" }}</button>
             </p>
             </p>

+ 8 - 16
static/public/templates/subscription.html

@@ -25,27 +25,19 @@
         <h2>{{ L.T "public.privacyTitle" }}</h2>
         <h2>{{ L.T "public.privacyTitle" }}</h2>
         {{ if .Data.AllowExport }}
         {{ if .Data.AllowExport }}
         <div class="row">
         <div class="row">
-            <div class="one columns">
-                <input id="privacy-export" type="radio" name="data-action" value="export" required />
-            </div>
-            <div class="ten columns">
-                <label for="privacy-export"><strong>{{ L.T "public.privacyExport" }}</strong></label>
-                <br />
-                {{ L.T "public.privacyExportHelp" }}
-            </div>
+            <input id="privacy-export" type="radio" name="data-action" value="export" required />
+            <label for="privacy-export"><strong>{{ L.T "public.privacyExport" }}</strong></label>
+            <br />
+            {{ L.T "public.privacyExportHelp" }}
         </div>
         </div>
         {{ end }}
         {{ end }}
 
 
         {{ if .Data.AllowWipe }}
         {{ if .Data.AllowWipe }}
         <div class="row">
         <div class="row">
-            <div class="one columns">
-                <input id="privacy-wipe" type="radio" name="data-action" value="wipe" required />
-            </div>
-            <div class="ten columns">
-                <label for="privacy-wipe"><strong>{{ L.T "public.privacyWipe" }}</strong></label>
-                <br />
-                {{ L.T "public.privacyWipeHelp" }}
-            </div>
+            <input id="privacy-wipe" type="radio" name="data-action" value="wipe" required />
+            <label for="privacy-wipe"><strong>{{ L.T "public.privacyWipe" }}</strong></label>
+            <br />
+            {{ L.T "public.privacyWipeHelp" }}
         </div>
         </div>
         {{ end }}
         {{ end }}
         <p>
         <p>

Някои файлове не бяха показани, защото твърде много файлове са промени