Merge branch 'vue'
This commit is contained in:
commit
7f9a811897
11
Makefile
11
Makefile
|
@ -4,7 +4,12 @@ VERSION := $(shell git describe)
|
|||
BUILDSTR := ${VERSION} (${LAST_COMMIT} $(shell date -u +"%Y-%m-%dT%H:%M:%S%z"))
|
||||
|
||||
BIN := listmonk
|
||||
STATIC := config.toml.sample schema.sql queries.sql static/public:/public static/email-templates frontend/build:/frontend
|
||||
STATIC := config.toml.sample \
|
||||
schema.sql queries.sql \
|
||||
static/public:/public \
|
||||
static/email-templates \
|
||||
frontend/dist:/frontend \
|
||||
frontend/dist/frontend:/frontend
|
||||
|
||||
# Dependencies.
|
||||
.PHONY: deps
|
||||
|
@ -19,7 +24,7 @@ build:
|
|||
|
||||
.PHONY: build-frontend
|
||||
build-frontend:
|
||||
export REACT_APP_VERSION="${VERSION}" && cd frontend && yarn build
|
||||
export VUE_APP_VERSION="${VERSION}" && cd frontend && yarn build
|
||||
|
||||
.PHONY: run
|
||||
run: build
|
||||
|
@ -27,7 +32,7 @@ run: build
|
|||
|
||||
.PHONY: run-frontend
|
||||
run-frontend:
|
||||
export REACT_APP_VERSION="${VERSION}" && cd frontend && yarn start
|
||||
export VUE_APP_VERSION="${VERSION}" && cd frontend && yarn serve
|
||||
|
||||
.PHONY: test
|
||||
test:
|
||||
|
|
30
admin.go
30
admin.go
|
@ -12,15 +12,10 @@ import (
|
|||
|
||||
type configScript struct {
|
||||
RootURL string `json:"rootURL"`
|
||||
UploadURI string `json:"uploadURI"`
|
||||
FromEmail string `json:"fromEmail"`
|
||||
Messengers []string `json:"messengers"`
|
||||
}
|
||||
|
||||
type dashboardStats struct {
|
||||
Stats types.JSONText `db:"stats"`
|
||||
}
|
||||
|
||||
// handleGetConfigScript returns general configuration as a Javascript
|
||||
// variable that can be included in an HTML page directly.
|
||||
func handleGetConfigScript(c echo.Context) error {
|
||||
|
@ -41,17 +36,32 @@ func handleGetConfigScript(c echo.Context) error {
|
|||
return c.Blob(http.StatusOK, "application/javascript", b.Bytes())
|
||||
}
|
||||
|
||||
// handleGetDashboardStats returns general states for the dashboard.
|
||||
func handleGetDashboardStats(c echo.Context) error {
|
||||
// handleGetDashboardCharts returns chart data points to render ont he dashboard.
|
||||
func handleGetDashboardCharts(c echo.Context) error {
|
||||
var (
|
||||
app = c.Get("app").(*App)
|
||||
out dashboardStats
|
||||
out types.JSONText
|
||||
)
|
||||
|
||||
if err := app.queries.GetDashboardStats.Get(&out); err != nil {
|
||||
if err := app.queries.GetDashboardCharts.Get(&out); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError,
|
||||
fmt.Sprintf("Error fetching dashboard stats: %s", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, okResp{out.Stats})
|
||||
return c.JSON(http.StatusOK, okResp{out})
|
||||
}
|
||||
|
||||
// handleGetDashboardCounts returns stats counts to show on the dashboard.
|
||||
func handleGetDashboardCounts(c echo.Context) error {
|
||||
var (
|
||||
app = c.Get("app").(*App)
|
||||
out types.JSONText
|
||||
)
|
||||
|
||||
if err := app.queries.GetDashboardCounts.Get(&out); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError,
|
||||
fmt.Sprintf("Error fetching dashboard statsc counts: %s", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, okResp{out})
|
||||
}
|
||||
|
|
10
campaigns.go
10
campaigns.go
|
@ -301,7 +301,7 @@ func handleUpdateCampaign(c echo.Context) error {
|
|||
o = c
|
||||
}
|
||||
|
||||
res, err := app.queries.UpdateCampaign.Exec(cm.ID,
|
||||
_, err := app.queries.UpdateCampaign.Exec(cm.ID,
|
||||
o.Name,
|
||||
o.Subject,
|
||||
o.FromEmail,
|
||||
|
@ -318,10 +318,6 @@ func handleUpdateCampaign(c echo.Context) error {
|
|||
fmt.Sprintf("Error updating campaign: %s", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Campaign not found.")
|
||||
}
|
||||
|
||||
return handleGetCampaigns(c)
|
||||
}
|
||||
|
||||
|
@ -597,6 +593,10 @@ func validateCampaignFields(c campaignReq, app *App) (campaignReq, error) {
|
|||
}
|
||||
}
|
||||
|
||||
if len(c.ListIDs) == 0 {
|
||||
return c, errors.New("no lists selected")
|
||||
}
|
||||
|
||||
camp := models.Campaign{Body: c.Body, TemplateBody: tplTag}
|
||||
if err := c.CompileTemplate(app.manager.TemplateFuncs(&camp)); err != nil {
|
||||
return c, fmt.Errorf("Error compiling campaign body: %v", err)
|
||||
|
|
|
@ -1,5 +0,0 @@
|
|||
{
|
||||
"presets": ["env", "react"],
|
||||
"plugins": [["transform-react-jsx", { "pragma": "h" }]]
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
> 1%
|
||||
last 2 versions
|
||||
not dead
|
|
@ -0,0 +1,7 @@
|
|||
[*.{js,jsx,ts,tsx,vue}]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
end_of_line = lf
|
||||
trim_trailing_whitespace = true
|
||||
insert_final_newline = true
|
||||
max_line_length = 100
|
|
@ -0,0 +1,17 @@
|
|||
module.exports = {
|
||||
root: true,
|
||||
env: {
|
||||
node: true,
|
||||
},
|
||||
extends: [
|
||||
'plugin:vue/essential',
|
||||
'@vue/airbnb',
|
||||
],
|
||||
parserOptions: {
|
||||
parser: 'babel-eslint',
|
||||
},
|
||||
rules: {
|
||||
'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
|
||||
'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
|
||||
},
|
||||
};
|
|
@ -1,21 +1,22 @@
|
|||
# See https://help.github.com/ignore-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
node_modules
|
||||
/dist
|
||||
|
||||
# local env files
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Log files
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
|
||||
# Editor directories and files
|
||||
.idea
|
||||
.vscode
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
|
|
@ -0,0 +1,30 @@
|
|||
# listmonk frontend (Vue + Buefy)
|
||||
|
||||
It's best if the `listmonk/frontend` directory is opened in an IDE as a separate project where the frontend directory is the root of the project.
|
||||
|
||||
For developer setup instructions, refer to the main project's README.
|
||||
|
||||
## Globals
|
||||
`main.js` is where Buefy is injected globally into Vue. In addition two controllers, `$api` (collection of API calls from `api/index.js`) and `$utils` (util functions from `util.js`), are also attached globaly to Vue. They are accessible within Vue as `this.$api` and `this.$utils`.
|
||||
|
||||
Some constants are defined in `constants.js`.
|
||||
|
||||
## APIs and states
|
||||
The project uses a global `vuex` state to centrally store the responses to pretty much all APIs (eg: fetch lists, campaigns etc.) except for a few exceptions. These are called `models` and have been defined in `constants.js`. The definitions are in `store/index.js`.
|
||||
|
||||
There is a global state `loading` (eg: loading.campaigns, loading.lists) that indicates whether an API call for that particular "model" is running. This can be used anywhere in the project to show loading spinners for instance. All the API definitions are in `api/index.js`. It also describes how each API call sets the global `loading` status alongside storing the API responses.
|
||||
|
||||
*IMPORTANT*: All JSON field names in GET API responses are automatically camel-cased when they're pulled for the sake of consistentcy in the frontend code and for complying with the linter spec in the project (Vue/AirBnB schema). For example, `content_type` becomes `contentType`. When sending responses to the backend, however, they should be snake-cased manually.
|
||||
|
||||
|
||||
## Icon pack
|
||||
Buefy by default uses [Material Design Icons](https://materialdesignicons.com) (MDI) with icon classes prefixed by `mdi-`.
|
||||
|
||||
listmonk uses only a handful of icons from the massive MDI set packed as web font, using [Fontello](https://fontello.com). To add more icons to the set using fontello:
|
||||
|
||||
- Go to Fontello and drag and drop `frontend/fontello/config.json` (This is the full MDI set converted from TTF to SVG icons to work with Fontello).
|
||||
- Use the UI to search for icons and add them to the selection (add icons from under the `Custom` section)
|
||||
- Download the Fontello pack and from the ZIP:
|
||||
- Copy and overwrite `config.json` to `frontend/fontello`
|
||||
- Copy `fontello.woff2` to `frontend/src/assets/icons`.
|
||||
- Open `css/fontello.css` and copy the individual icon definitions and overwrite the ones in `frontend/src/assets/icons/fontello.css`
|
|
@ -0,0 +1,5 @@
|
|||
module.exports = {
|
||||
presets: [
|
||||
'@vue/cli-plugin-babel/preset',
|
||||
],
|
||||
};
|
|
@ -1,31 +0,0 @@
|
|||
const { injectBabelPlugin } = require("react-app-rewired")
|
||||
const rewireLess = require("react-app-rewire-less")
|
||||
|
||||
module.exports = function override(config, env) {
|
||||
config = injectBabelPlugin(
|
||||
[
|
||||
"import",
|
||||
{
|
||||
libraryName: "antd",
|
||||
libraryDirectory: "es",
|
||||
style: true
|
||||
}
|
||||
], // change importing css to less
|
||||
config
|
||||
)
|
||||
config = rewireLess.withLoaderOptions({
|
||||
modifyVars: {
|
||||
"@font-family":
|
||||
'"IBM Plex Sans", "Helvetica Neueue", "Segoe UI", "sans-serif"',
|
||||
"@font-size-base": "15px",
|
||||
"@primary-color": "#7f2aff",
|
||||
"@shadow-1-up": "0 -2px 3px @shadow-color",
|
||||
"@shadow-1-down": "0 2px 3px @shadow-color",
|
||||
"@shadow-1-left": "-2px 0 3px @shadow-color",
|
||||
"@shadow-1-right": "2px 0 3px @shadow-color",
|
||||
"@shadow-2": "0 2px 6px @shadow-color"
|
||||
},
|
||||
javascriptEnabled: true
|
||||
})(config, env)
|
||||
return config
|
||||
}
|
File diff suppressed because it is too large
Load Diff
|
@ -2,36 +2,41 @@
|
|||
"name": "listmonk",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"antd": "^3.6.5",
|
||||
"axios": "^0.18.0",
|
||||
"bizcharts": "^3.2.5-beta.4",
|
||||
"dayjs": "^1.7.5",
|
||||
"react": "^16.4.1",
|
||||
"react-app-rewire-less": "^2.1.3",
|
||||
"react-app-rewired": "^1.6.2",
|
||||
"react-dom": "^16.4.1",
|
||||
"react-quill": "^1.3.1",
|
||||
"react-router": "^4.3.1",
|
||||
"react-router-dom": "^4.3.1",
|
||||
"react-scripts": "1.1.4"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "react-app-rewired start",
|
||||
"build": "GENERATE_SOURCEMAP=false PUBLIC_URL=/frontend/ react-app-rewired build",
|
||||
"test": "react-app-rewired test --env=jsdom",
|
||||
"eject": "react-scripts eject"
|
||||
"serve": "vue-cli-service serve",
|
||||
"build": "vue-cli-service build",
|
||||
"build-report": "vue-cli-service build --report",
|
||||
"lint": "vue-cli-service lint"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": "react-app"
|
||||
"dependencies": {
|
||||
"axios": "^0.19.2",
|
||||
"buefy": "^0.8.20",
|
||||
"c3": "^0.7.18",
|
||||
"core-js": "^3.6.5",
|
||||
"dayjs": "^1.8.28",
|
||||
"humps": "^2.0.1",
|
||||
"node-sass": "^4.14.1",
|
||||
"qs": "^6.9.4",
|
||||
"quill": "^1.3.7",
|
||||
"quill-delta": "^4.2.2",
|
||||
"sass-loader": "^8.0.2",
|
||||
"vue": "^2.6.11",
|
||||
"vue-c3": "^1.2.11",
|
||||
"vue-quill-editor": "^3.0.6",
|
||||
"vue-router": "^3.2.0",
|
||||
"vuex": "^3.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"babel-plugin-import": "^1.11.0",
|
||||
"eslint-plugin-prettier": "^3.0.1",
|
||||
"less-plugin-npm-import": "^2.1.0",
|
||||
"prettier": "1.15.3"
|
||||
},
|
||||
"prettier": {
|
||||
"semi": false
|
||||
"@vue/cli-plugin-babel": "~4.4.0",
|
||||
"@vue/cli-plugin-eslint": "~4.4.0",
|
||||
"@vue/cli-plugin-router": "~4.4.0",
|
||||
"@vue/cli-plugin-vuex": "~4.4.0",
|
||||
"@vue/cli-service": "~4.4.0",
|
||||
"@vue/eslint-config-airbnb": "^5.0.2",
|
||||
"babel-eslint": "^10.1.0",
|
||||
"eslint": "^6.7.2",
|
||||
"eslint-plugin-import": "^2.20.2",
|
||||
"eslint-plugin-vue": "^6.2.2",
|
||||
"vue-template-compiler": "^2.6.11"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1 +0,0 @@
|
|||
<svg viewbox="0 0 18 18"><rect class="ql-stroke" height="10" width="12" x="3" y="4"></rect><circle class="ql-fill" cx="6" cy="7" r="1"></circle><polyline class="ql-even ql-fill" points="5 12 5 11 7 9 8 10 11 7 13 9 13 12 5 12"></polyline></svg>
|
Before Width: | Height: | Size: 244 B |
|
@ -1,21 +1,19 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||
<meta name="theme-color" content="#000000">
|
||||
<script src="/api/config.js" type="text/javascript"></script>
|
||||
<link href="https://fonts.googleapis.com/css?family=IBM+Plex+Sans:400,600" rel="stylesheet">
|
||||
<link rel="manifest" href="%PUBLIC_URL%/manifest.json">
|
||||
<link rel="shortcut icon" href="%PUBLIC_URL%/favicon.png">
|
||||
<title>listmonk</title>
|
||||
<script>VERSION = "%REACT_APP_VERSION%";</script>
|
||||
<meta charset="utf-8" />
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1.0" />
|
||||
<link rel="icon" href="<%= BASE_URL %>frontend/favicon.png" />
|
||||
<link href="https://fonts.googleapis.com/css?family=IBM+Plex+Sans:400,600" rel="stylesheet" />
|
||||
<title><%= htmlWebpackPlugin.options.title %></title>
|
||||
<script src="<%= BASE_URL %>api/config.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>
|
||||
You need to enable JavaScript to run this app.
|
||||
<strong>We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
|
||||
</noscript>
|
||||
|
||||
<div id="root"></div>
|
||||
<div id="app"></div>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
@ -1,15 +0,0 @@
|
|||
{
|
||||
"short_name": "React App",
|
||||
"name": "Create React App Sample",
|
||||
"icons": [
|
||||
{
|
||||
"src": "favicon.ico",
|
||||
"sizes": "64x64 32x32 24x24 16x16",
|
||||
"type": "image/x-icon"
|
||||
}
|
||||
],
|
||||
"start_url": "./index.html",
|
||||
"display": "standalone",
|
||||
"theme_color": "#000000",
|
||||
"background_color": "#ffffff"
|
||||
}
|
|
@ -1,193 +0,0 @@
|
|||
import React from "react"
|
||||
import Utils from "./utils"
|
||||
import { BrowserRouter } from "react-router-dom"
|
||||
import { Icon, notification } from "antd"
|
||||
import axios from "axios"
|
||||
import qs from "qs"
|
||||
|
||||
import logo from "./static/listmonk.svg"
|
||||
import Layout from "./Layout"
|
||||
import * as cs from "./constants"
|
||||
|
||||
/*
|
||||
App acts as a an "automagic" wrapper for all sub components. It is also the central
|
||||
store for data required by various child components. In addition, all HTTP requests
|
||||
are fired through App.requests(), where successful responses are set in App's state
|
||||
for child components to access via this.props.data[type]. The structure is as follows:
|
||||
App.state.data = {
|
||||
"lists": [],
|
||||
"subscribers": []
|
||||
// etc.
|
||||
}
|
||||
|
||||
A number of assumptions are made here for the "automagic" behaviour.
|
||||
1. All responses to resources return lists
|
||||
2. All PUT, POST, DELETE requests automatically append /:id to the API URIs.
|
||||
*/
|
||||
|
||||
class App extends React.PureComponent {
|
||||
models = [
|
||||
cs.ModelUsers,
|
||||
cs.ModelSubscribers,
|
||||
cs.ModelLists,
|
||||
cs.ModelCampaigns,
|
||||
cs.ModelTemplates
|
||||
]
|
||||
|
||||
state = {
|
||||
// Initialize empty states.
|
||||
reqStates: this.models.reduce(
|
||||
// eslint-disable-next-line
|
||||
(map, obj) => ((map[obj] = cs.StatePending), map),
|
||||
{}
|
||||
),
|
||||
// eslint-disable-next-line
|
||||
data: this.models.reduce((map, obj) => ((map[obj] = []), map), {}),
|
||||
modStates: {}
|
||||
}
|
||||
|
||||
componentDidMount = () => {
|
||||
axios.defaults.paramsSerializer = params => {
|
||||
return qs.stringify(params, { arrayFormat: "repeat" })
|
||||
}
|
||||
}
|
||||
|
||||
// modelRequest is an opinionated wrapper for model specific HTTP requests,
|
||||
// including setting model states.
|
||||
modelRequest = async (model, route, method, params) => {
|
||||
let url = replaceParams(route, params)
|
||||
|
||||
this.setState({
|
||||
reqStates: { ...this.state.reqStates, [model]: cs.StatePending }
|
||||
})
|
||||
try {
|
||||
let req = {
|
||||
method: method,
|
||||
url: url
|
||||
}
|
||||
|
||||
if (method === cs.MethodGet || method === cs.MethodDelete) {
|
||||
req.params = params ? params : {}
|
||||
} else {
|
||||
req.data = params ? params : {}
|
||||
}
|
||||
|
||||
let res = await axios(req)
|
||||
this.setState({
|
||||
reqStates: { ...this.state.reqStates, [model]: cs.StateDone }
|
||||
})
|
||||
|
||||
// If it's a GET call, set the response as the data state.
|
||||
if (method === cs.MethodGet) {
|
||||
this.setState({
|
||||
data: { ...this.state.data, [model]: res.data.data }
|
||||
})
|
||||
}
|
||||
return res
|
||||
} catch (e) {
|
||||
// If it's a GET call, throw a global notification.
|
||||
if (method === cs.MethodGet) {
|
||||
notification["error"]({
|
||||
placement: cs.MsgPosition,
|
||||
message: "Error fetching data",
|
||||
description: Utils.HttpError(e).message
|
||||
})
|
||||
}
|
||||
|
||||
// Set states and show the error on the layout.
|
||||
this.setState({
|
||||
reqStates: { ...this.state.reqStates, [model]: cs.StateDone }
|
||||
})
|
||||
throw Utils.HttpError(e)
|
||||
}
|
||||
}
|
||||
|
||||
// request is a wrapper for generic HTTP requests.
|
||||
request = async (url, method, params, headers) => {
|
||||
url = replaceParams(url, params)
|
||||
|
||||
this.setState({
|
||||
reqStates: { ...this.state.reqStates, [url]: cs.StatePending }
|
||||
})
|
||||
try {
|
||||
let req = {
|
||||
method: method,
|
||||
url: url,
|
||||
headers: headers ? headers : {}
|
||||
}
|
||||
|
||||
if (method === cs.MethodGet || method === cs.MethodDelete) {
|
||||
req.params = params ? params : {}
|
||||
} else {
|
||||
req.data = params ? params : {}
|
||||
}
|
||||
|
||||
let res = await axios(req)
|
||||
|
||||
this.setState({
|
||||
reqStates: { ...this.state.reqStates, [url]: cs.StateDone }
|
||||
})
|
||||
return res
|
||||
} catch (e) {
|
||||
this.setState({
|
||||
reqStates: { ...this.state.reqStates, [url]: cs.StateDone }
|
||||
})
|
||||
throw Utils.HttpError(e)
|
||||
}
|
||||
}
|
||||
|
||||
pageTitle = title => {
|
||||
document.title = title
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!window.CONFIG) {
|
||||
return (
|
||||
<div className="broken">
|
||||
<p>
|
||||
<img src={logo} alt="listmonk logo" />
|
||||
</p>
|
||||
<hr />
|
||||
|
||||
<h1>
|
||||
<Icon type="warning" /> Something's not right
|
||||
</h1>
|
||||
<p>
|
||||
The app configuration could not be loaded. Please ensure that the
|
||||
app is running and then refresh this page.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<Layout
|
||||
modelRequest={this.modelRequest}
|
||||
request={this.request}
|
||||
reqStates={this.state.reqStates}
|
||||
pageTitle={this.pageTitle}
|
||||
config={window.CONFIG}
|
||||
data={this.state.data}
|
||||
/>
|
||||
</BrowserRouter>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function replaceParams(route, params) {
|
||||
// Replace :params in the URL with params in the array.
|
||||
let uriParams = route.match(/:([a-z0-9\-_]+)/gi)
|
||||
if (uriParams && uriParams.length > 0) {
|
||||
uriParams.forEach(p => {
|
||||
let pName = p.slice(1) // Lose the ":" prefix
|
||||
if (params && params.hasOwnProperty(pName)) {
|
||||
route = route.replace(p, params[pName])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return route
|
||||
}
|
||||
|
||||
export default App
|
|
@ -0,0 +1,135 @@
|
|||
<template>
|
||||
<div id="app">
|
||||
<section class="sidebar">
|
||||
<b-sidebar
|
||||
type="is-white"
|
||||
position="static"
|
||||
mobile="reduce"
|
||||
:fullheight="true"
|
||||
:open="true"
|
||||
:can-cancel="false"
|
||||
>
|
||||
<div>
|
||||
<div class="logo">
|
||||
<a href="/"><img class="full" src="@/assets/logo.svg"/></a>
|
||||
<img class="favicon" src="@/assets/favicon.png"/>
|
||||
<p class="is-size-7 has-text-grey version">{{ version }}</p>
|
||||
</div>
|
||||
<b-menu :accordion="false">
|
||||
<b-menu-list>
|
||||
<b-menu-item :to="{name: 'dashboard'}" tag="router-link"
|
||||
:active="activeItem.dashboard"
|
||||
icon="view-dashboard-variant-outline" label="Dashboard">
|
||||
</b-menu-item><!-- dashboard -->
|
||||
|
||||
<b-menu-item :expanded="activeGroup.lists"
|
||||
icon="format-list-bulleted-square" label="Lists">
|
||||
<b-menu-item :to="{name: 'lists'}" tag="router-link"
|
||||
:active="activeItem.lists"
|
||||
icon="format-list-bulleted-square" label="All lists"></b-menu-item>
|
||||
|
||||
<b-menu-item :to="{name: 'forms'}" tag="router-link"
|
||||
:active="activeItem.forms"
|
||||
icon="newspaper-variant-outline" label="Forms"></b-menu-item>
|
||||
</b-menu-item><!-- lists -->
|
||||
|
||||
<b-menu-item :expanded="activeGroup.subscribers"
|
||||
icon="account-multiple" label="Subscribers">
|
||||
<b-menu-item :to="{name: 'subscribers'}" tag="router-link"
|
||||
:active="activeItem.subscribers"
|
||||
icon="account-multiple" label="All subscribers"></b-menu-item>
|
||||
|
||||
<b-menu-item :to="{name: 'import'}" tag="router-link"
|
||||
:active="activeItem.import"
|
||||
icon="file-upload-outline" label="Import"></b-menu-item>
|
||||
</b-menu-item><!-- subscribers -->
|
||||
|
||||
<b-menu-item :expanded="activeGroup.campaigns"
|
||||
icon="rocket-launch-outline" label="Campaigns">
|
||||
<b-menu-item :to="{name: 'campaigns'}" tag="router-link"
|
||||
:active="activeItem.campaigns"
|
||||
icon="rocket-launch-outline" label="All campaigns"></b-menu-item>
|
||||
|
||||
<b-menu-item :to="{name: 'campaign', params: {id: 'new'}}" tag="router-link"
|
||||
:active="activeItem.campaign"
|
||||
icon="plus" label="Create new"></b-menu-item>
|
||||
|
||||
<b-menu-item :to="{name: 'media'}" tag="router-link"
|
||||
:active="activeItem.media"
|
||||
icon="image-outline" label="Media"></b-menu-item>
|
||||
|
||||
<b-menu-item :to="{name: 'templates'}" tag="router-link"
|
||||
:active="activeItem.templates"
|
||||
icon="file-image-outline" label="Templates"></b-menu-item>
|
||||
</b-menu-item><!-- campaigns -->
|
||||
|
||||
<!-- <b-menu-item :to="{name: 'settings'}" tag="router-link"
|
||||
:active="activeItem.settings"
|
||||
icon="cog-outline" label="Settings"></b-menu-item> -->
|
||||
</b-menu-list>
|
||||
</b-menu>
|
||||
</div>
|
||||
</b-sidebar>
|
||||
</section>
|
||||
<!-- sidebar-->
|
||||
|
||||
<!-- body //-->
|
||||
<div class="main">
|
||||
<router-view :key="$route.fullPath" />
|
||||
</div>
|
||||
|
||||
<b-loading v-if="!isLoaded" active>
|
||||
<div class="has-text-centered">
|
||||
<h1 class="title">Oops</h1>
|
||||
<p>
|
||||
Can't connect to the listmonk backend.<br />
|
||||
Make sure it is running and refresh this page.
|
||||
</p>
|
||||
</div>
|
||||
</b-loading>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Vue from 'vue';
|
||||
|
||||
export default Vue.extend({
|
||||
name: 'App',
|
||||
|
||||
data() {
|
||||
return {
|
||||
activeItem: {},
|
||||
activeGroup: {},
|
||||
isLoaded: window.CONFIG,
|
||||
};
|
||||
},
|
||||
|
||||
watch: {
|
||||
$route(to) {
|
||||
// Set the current route name to true for active+expanded keys in the
|
||||
// menu to pick up.
|
||||
this.activeItem = { [to.name]: true };
|
||||
if (to.meta.group) {
|
||||
this.activeGroup = { [to.meta.group]: true };
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
mounted() {
|
||||
// Lists is required across different views. On app load, fetch the lists
|
||||
// and have them in the store.
|
||||
this.$api.getLists();
|
||||
},
|
||||
|
||||
computed: {
|
||||
version() {
|
||||
return process.env.VUE_APP_VERSION;
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@import "assets/style.scss";
|
||||
@import "assets/icons/fontello.css";
|
||||
</style>
|
|
@ -1,879 +0,0 @@
|
|||
import React from "react"
|
||||
import {
|
||||
Modal,
|
||||
Tabs,
|
||||
Row,
|
||||
Col,
|
||||
Form,
|
||||
Switch,
|
||||
Select,
|
||||
Radio,
|
||||
Tag,
|
||||
Input,
|
||||
Button,
|
||||
Icon,
|
||||
Spin,
|
||||
DatePicker,
|
||||
Popconfirm,
|
||||
notification
|
||||
} from "antd"
|
||||
import * as cs from "./constants"
|
||||
import Media from "./Media"
|
||||
import ModalPreview from "./ModalPreview"
|
||||
|
||||
import moment from "moment"
|
||||
import parseUrl from "querystring"
|
||||
import ReactQuill from "react-quill"
|
||||
import Delta from "quill-delta"
|
||||
import "react-quill/dist/quill.snow.css"
|
||||
|
||||
const formItemLayout = {
|
||||
labelCol: { xs: { span: 16 }, sm: { span: 10 }, md: { span: 4 } },
|
||||
wrapperCol: { xs: { span: 16 }, sm: { span: 14 }, md: { span: 10 } }
|
||||
}
|
||||
|
||||
class Editor extends React.PureComponent {
|
||||
state = {
|
||||
editor: null,
|
||||
quill: null,
|
||||
rawInput: null,
|
||||
selContentType: cs.CampaignContentTypeRichtext,
|
||||
contentType: cs.CampaignContentTypeRichtext,
|
||||
body: ""
|
||||
}
|
||||
|
||||
quillModules = {
|
||||
toolbar: {
|
||||
container: [
|
||||
[{ header: [1, 2, 3, false] }],
|
||||
["bold", "italic", "underline", "strike", "blockquote", "code"],
|
||||
[{ color: [] }, { background: [] }, { size: [] }],
|
||||
[
|
||||
{ list: "ordered" },
|
||||
{ list: "bullet" },
|
||||
{ indent: "-1" },
|
||||
{ indent: "+1" }
|
||||
],
|
||||
[
|
||||
{ align: "" },
|
||||
{ align: "center" },
|
||||
{ align: "right" },
|
||||
{ align: "justify" }
|
||||
],
|
||||
["link", "image"],
|
||||
["clean", "font"]
|
||||
],
|
||||
handlers: {
|
||||
image: () => {
|
||||
this.props.toggleMedia()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
componentDidMount = () => {
|
||||
// The editor component will only load once the individual campaign metadata
|
||||
// has loaded, i.e., record.body is guaranteed to be available here.
|
||||
if (this.props.record && this.props.record.id) {
|
||||
this.setState({
|
||||
body: this.props.record.body,
|
||||
contentType: this.props.record.content_type,
|
||||
selContentType: this.props.record.content_type
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Custom handler for inserting images from the media popup.
|
||||
insertMedia = uri => {
|
||||
const quill = this.state.quill.getEditor()
|
||||
let range = quill.getSelection(true)
|
||||
quill.updateContents(
|
||||
new Delta()
|
||||
.retain(range.index)
|
||||
.delete(range.length)
|
||||
.insert({ image: uri }),
|
||||
null
|
||||
)
|
||||
}
|
||||
|
||||
handleSelContentType = (_, e) => {
|
||||
this.setState({ selContentType: e.props.value })
|
||||
}
|
||||
|
||||
handleSwitchContentType = () => {
|
||||
this.setState({ contentType: this.state.selContentType })
|
||||
if (!this.state.quill || !this.state.quill.editor || !this.state.rawInput) {
|
||||
return
|
||||
}
|
||||
|
||||
// Switching from richtext to html.
|
||||
let body = ""
|
||||
if (this.state.selContentType === cs.CampaignContentTypeHTML) {
|
||||
body = this.state.quill.editor.container.firstChild.innerHTML
|
||||
// eslint-disable-next-line
|
||||
this.state.rawInput.value = body
|
||||
} else if (this.state.selContentType === cs.CampaignContentTypeRichtext) {
|
||||
body = this.state.rawInput.value
|
||||
this.state.quill.editor.clipboard.dangerouslyPasteHTML(body, "raw")
|
||||
}
|
||||
|
||||
this.props.setContent(this.state.selContentType, body)
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div>
|
||||
<header className="header">
|
||||
{!this.props.formDisabled && (
|
||||
<Row>
|
||||
<Col span={20}>
|
||||
<div className="content-type">
|
||||
<p>Content format</p>
|
||||
<Select
|
||||
name="content_type"
|
||||
onChange={this.handleSelContentType}
|
||||
style={{ minWidth: 200 }}
|
||||
value={this.state.selContentType}
|
||||
>
|
||||
<Select.Option value={ cs.CampaignContentTypeRichtext }>Rich Text</Select.Option>
|
||||
<Select.Option value={ cs.CampaignContentTypeHTML }>Raw HTML</Select.Option>
|
||||
</Select>
|
||||
{this.state.contentType !== this.state.selContentType && (
|
||||
<div className="actions">
|
||||
<Popconfirm
|
||||
title="The content may lose its formatting. Are you sure?"
|
||||
onConfirm={this.handleSwitchContentType}
|
||||
>
|
||||
<Button>
|
||||
<Icon type="save" /> Switch format
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Col>
|
||||
<Col span={4} />
|
||||
</Row>
|
||||
)}
|
||||
</header>
|
||||
<ReactQuill
|
||||
readOnly={this.props.formDisabled}
|
||||
style={{
|
||||
display: this.state.contentType === cs.CampaignContentTypeRichtext ? "block" : "none"
|
||||
}}
|
||||
modules={this.quillModules}
|
||||
defaultValue={this.props.record.body}
|
||||
ref={o => {
|
||||
if (!o) {
|
||||
return
|
||||
}
|
||||
|
||||
this.setState({ quill: o })
|
||||
document.querySelector(".ql-editor").focus()
|
||||
}}
|
||||
onChange={() => {
|
||||
if (!this.state.quill) {
|
||||
return
|
||||
}
|
||||
|
||||
this.props.setContent(
|
||||
this.state.contentType,
|
||||
this.state.quill.editor.root.innerHTML
|
||||
)
|
||||
}}
|
||||
/>
|
||||
|
||||
<Input.TextArea
|
||||
readOnly={this.props.formDisabled}
|
||||
placeholder="Your message here"
|
||||
style={{
|
||||
display: this.state.contentType === "html" ? "block" : "none"
|
||||
}}
|
||||
id="html-body"
|
||||
rows={10}
|
||||
autosize={{ minRows: 2, maxRows: 10 }}
|
||||
defaultValue={this.props.record.body}
|
||||
ref={o => {
|
||||
if (!o) {
|
||||
return
|
||||
}
|
||||
|
||||
this.setState({ rawInput: o.textAreaRef })
|
||||
}}
|
||||
onChange={e => {
|
||||
this.props.setContent(this.state.contentType, e.target.value)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
class TheFormDef extends React.PureComponent {
|
||||
state = {
|
||||
editorVisible: false,
|
||||
sendLater: false,
|
||||
loading: false
|
||||
}
|
||||
|
||||
componentWillReceiveProps(nextProps) {
|
||||
// On initial load, toggle the send_later switch if the record
|
||||
// has a "send_at" date.
|
||||
if (nextProps.record.send_at === this.props.record.send_at) {
|
||||
return
|
||||
}
|
||||
this.setState({
|
||||
sendLater: nextProps.isSingle && nextProps.record.send_at !== null
|
||||
})
|
||||
}
|
||||
|
||||
validateEmail = (rule, value, callback) => {
|
||||
if (!value.match(/(.+?)\s<(.+?)@(.+?)>/)) {
|
||||
return callback("Format should be: Your Name <email@address.com>")
|
||||
}
|
||||
|
||||
callback()
|
||||
}
|
||||
|
||||
handleSendLater = e => {
|
||||
this.setState({ sendLater: e })
|
||||
}
|
||||
|
||||
// Handle create / edit form submission.
|
||||
handleSubmit = cb => {
|
||||
if (this.state.loading) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!cb) {
|
||||
// Set a fake callback.
|
||||
cb = () => {}
|
||||
}
|
||||
|
||||
this.props.form.validateFields((err, values) => {
|
||||
if (err) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!values.tags) {
|
||||
values.tags = []
|
||||
}
|
||||
|
||||
values.type = cs.CampaignTypeRegular
|
||||
values.body = this.props.body
|
||||
values.content_type = this.props.contentType
|
||||
|
||||
if (values.send_at) {
|
||||
values.send_later = true
|
||||
} else {
|
||||
values.send_later = false
|
||||
}
|
||||
|
||||
// Create a new campaign.
|
||||
this.setState({ loading: true })
|
||||
if (!this.props.isSingle) {
|
||||
this.props
|
||||
.modelRequest(
|
||||
cs.ModelCampaigns,
|
||||
cs.Routes.CreateCampaign,
|
||||
cs.MethodPost,
|
||||
values
|
||||
)
|
||||
.then(resp => {
|
||||
notification["success"]({
|
||||
placement: cs.MsgPosition,
|
||||
message: "Campaign created",
|
||||
description: `"${values["name"]}" created`
|
||||
})
|
||||
|
||||
this.props.route.history.push({
|
||||
pathname: cs.Routes.ViewCampaign.replace(
|
||||
":id",
|
||||
resp.data.data.id
|
||||
),
|
||||
hash: "content-tab"
|
||||
})
|
||||
cb(true)
|
||||
})
|
||||
.catch(e => {
|
||||
notification["error"]({
|
||||
placement: cs.MsgPosition,
|
||||
message: "Error",
|
||||
description: e.message
|
||||
})
|
||||
this.setState({ loading: false })
|
||||
cb(false)
|
||||
})
|
||||
} else {
|
||||
this.props
|
||||
.modelRequest(
|
||||
cs.ModelCampaigns,
|
||||
cs.Routes.UpdateCampaign,
|
||||
cs.MethodPut,
|
||||
{
|
||||
...values,
|
||||
id: this.props.record.id
|
||||
}
|
||||
)
|
||||
.then(resp => {
|
||||
notification["success"]({
|
||||
placement: cs.MsgPosition,
|
||||
message: "Campaign updated",
|
||||
description: `"${values["name"]}" updated`
|
||||
})
|
||||
this.setState({ loading: false })
|
||||
this.props.setRecord(resp.data.data)
|
||||
cb(true)
|
||||
})
|
||||
.catch(e => {
|
||||
notification["error"]({
|
||||
placement: cs.MsgPosition,
|
||||
message: "Error",
|
||||
description: e.message
|
||||
})
|
||||
this.setState({ loading: false })
|
||||
cb(false)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
handleTestCampaign = e => {
|
||||
e.preventDefault()
|
||||
this.props.form.validateFields((err, values) => {
|
||||
if (err) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!values.tags) {
|
||||
values.tags = []
|
||||
}
|
||||
|
||||
values.id = this.props.record.id
|
||||
values.body = this.props.body
|
||||
values.content_type = this.props.contentType
|
||||
|
||||
this.setState({ loading: true })
|
||||
this.props
|
||||
.request(cs.Routes.TestCampaign, cs.MethodPost, values)
|
||||
.then(resp => {
|
||||
this.setState({ loading: false })
|
||||
notification["success"]({
|
||||
placement: cs.MsgPosition,
|
||||
message: "Test sent",
|
||||
description: `Test messages sent`
|
||||
})
|
||||
})
|
||||
.catch(e => {
|
||||
this.setState({ loading: false })
|
||||
notification["error"]({
|
||||
placement: cs.MsgPosition,
|
||||
message: "Error",
|
||||
description: e.message
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
render() {
|
||||
const { record } = this.props
|
||||
const { getFieldDecorator } = this.props.form
|
||||
|
||||
let subLists = []
|
||||
if (this.props.isSingle && record.lists) {
|
||||
subLists = record.lists
|
||||
.map(v => {
|
||||
// Exclude deleted lists.
|
||||
return v.id !== 0 ? v.id : null
|
||||
})
|
||||
.filter(v => v !== null)
|
||||
} else if (this.props.route.location.search) {
|
||||
// One or more list_id in the query params.
|
||||
const p = parseUrl.parse(this.props.route.location.search.substring(1))
|
||||
if (p.hasOwnProperty("list_id")) {
|
||||
if(Array.isArray(p.list_id)) {
|
||||
p.list_id.forEach(i => {
|
||||
// eslint-disable-next-line radix
|
||||
const id = parseInt(i)
|
||||
if (id) {
|
||||
subLists.push(id)
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// eslint-disable-next-line radix
|
||||
const id = parseInt(p.list_id)
|
||||
if (id) {
|
||||
subLists.push(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (this.record) {
|
||||
this.props.pageTitle(record.name + " / Campaigns")
|
||||
} else {
|
||||
this.props.pageTitle("New campaign")
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Spin spinning={this.state.loading}>
|
||||
<Form onSubmit={this.handleSubmit}>
|
||||
<Form.Item {...formItemLayout} label="Campaign name">
|
||||
{getFieldDecorator("name", {
|
||||
extra:
|
||||
"This is internal and will not be visible to subscribers",
|
||||
initialValue: record.name,
|
||||
rules: [{ required: true }]
|
||||
})(
|
||||
<Input
|
||||
disabled={this.props.formDisabled}
|
||||
autoFocus
|
||||
maxLength={200}
|
||||
/>
|
||||
)}
|
||||
</Form.Item>
|
||||
<Form.Item {...formItemLayout} label="Subject">
|
||||
{getFieldDecorator("subject", {
|
||||
initialValue: record.subject,
|
||||
rules: [{ required: true }]
|
||||
})(<Input disabled={this.props.formDisabled} maxLength={500} />)}
|
||||
</Form.Item>
|
||||
<Form.Item {...formItemLayout} label="From address">
|
||||
{getFieldDecorator("from_email", {
|
||||
initialValue: record.from_email
|
||||
? record.from_email
|
||||
: this.props.config.fromEmail,
|
||||
rules: [{ required: true }, { validator: this.validateEmail }]
|
||||
})(
|
||||
<Input
|
||||
disabled={this.props.formDisabled}
|
||||
placeholder="Company Name <email@company.com>"
|
||||
maxLength={200}
|
||||
/>
|
||||
)}
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
{...formItemLayout}
|
||||
label="Lists"
|
||||
extra="Lists to subscribe to"
|
||||
>
|
||||
{getFieldDecorator("lists", {
|
||||
initialValue:
|
||||
subLists.length > 0
|
||||
? subLists
|
||||
: this.props.data[cs.ModelLists].hasOwnProperty(
|
||||
"results"
|
||||
) && this.props.data[cs.ModelLists].results.length === 1
|
||||
? [this.props.data[cs.ModelLists].results[0].id]
|
||||
: undefined,
|
||||
rules: [{ required: true }]
|
||||
})(
|
||||
<Select disabled={this.props.formDisabled} mode="multiple">
|
||||
{this.props.data[cs.ModelLists].hasOwnProperty("results") &&
|
||||
[...this.props.data[cs.ModelLists].results].map((v) =>
|
||||
(record.type !== cs.CampaignTypeOptin || v.optin === cs.ListOptinDouble) && (
|
||||
<Select.Option value={v["id"]} key={v["id"]}>
|
||||
{v["name"]}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
)}
|
||||
</Form.Item>
|
||||
<Form.Item {...formItemLayout} label="Template" extra="Template">
|
||||
{getFieldDecorator("template_id", {
|
||||
initialValue: record.template_id
|
||||
? record.template_id
|
||||
: this.props.data[cs.ModelTemplates].length > 0
|
||||
? this.props.data[cs.ModelTemplates].filter(
|
||||
t => t.is_default
|
||||
)[0].id
|
||||
: undefined,
|
||||
rules: [{ required: true }]
|
||||
})(
|
||||
<Select disabled={this.props.formDisabled}>
|
||||
{this.props.data[cs.ModelTemplates].map((v, i) => (
|
||||
<Select.Option value={v["id"]} key={v["id"]}>
|
||||
{v["name"]}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
)}
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
{...formItemLayout}
|
||||
label="Tags"
|
||||
extra="Hit Enter after typing a word to add multiple tags"
|
||||
>
|
||||
{getFieldDecorator("tags", { initialValue: record.tags })(
|
||||
<Select disabled={this.props.formDisabled} mode="tags" />
|
||||
)}
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
{...formItemLayout}
|
||||
label="Messenger"
|
||||
style={{
|
||||
display:
|
||||
this.props.config.messengers.length === 1 ? "none" : "block"
|
||||
}}
|
||||
>
|
||||
{getFieldDecorator("messenger", {
|
||||
initialValue: record.messenger ? record.messenger : "email"
|
||||
})(
|
||||
<Radio.Group className="messengers">
|
||||
{[...this.props.config.messengers].map((v, i) => (
|
||||
<Radio disabled={this.props.formDisabled} value={v} key={v}>
|
||||
{v}
|
||||
</Radio>
|
||||
))}
|
||||
</Radio.Group>
|
||||
)}
|
||||
</Form.Item>
|
||||
|
||||
<hr />
|
||||
<Form.Item {...formItemLayout} label="Send later?">
|
||||
<Row>
|
||||
<Col lg={4}>
|
||||
{getFieldDecorator("send_later")(
|
||||
<Switch
|
||||
disabled={this.props.formDisabled}
|
||||
checked={this.state.sendLater}
|
||||
onChange={this.handleSendLater}
|
||||
/>
|
||||
)}
|
||||
</Col>
|
||||
<Col lg={20}>
|
||||
{this.state.sendLater &&
|
||||
getFieldDecorator("send_at", {
|
||||
initialValue:
|
||||
record && typeof record.send_at === "string"
|
||||
? moment(record.send_at)
|
||||
: moment(new Date())
|
||||
.add(1, "days")
|
||||
.startOf("day")
|
||||
})(
|
||||
<DatePicker
|
||||
disabled={this.props.formDisabled}
|
||||
showTime
|
||||
format="YYYY-MM-DD HH:mm:ss"
|
||||
placeholder="Select a date and time"
|
||||
/>
|
||||
)}
|
||||
</Col>
|
||||
</Row>
|
||||
</Form.Item>
|
||||
|
||||
{this.props.isSingle && (
|
||||
<div>
|
||||
<hr />
|
||||
<Form.Item
|
||||
{...formItemLayout}
|
||||
label="Send test messages"
|
||||
extra="Hit Enter after typing an address to add multiple recipients. The addresses must belong to existing subscribers."
|
||||
>
|
||||
{getFieldDecorator("subscribers")(
|
||||
<Select mode="tags" style={{ width: "100%" }} />
|
||||
)}
|
||||
</Form.Item>
|
||||
<Form.Item {...formItemLayout} label=" " colon={false}>
|
||||
<Button onClick={this.handleTestCampaign}>
|
||||
<Icon type="mail" /> Send test
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</div>
|
||||
)}
|
||||
</Form>
|
||||
</Spin>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
const TheForm = Form.create()(TheFormDef)
|
||||
|
||||
class Campaign extends React.PureComponent {
|
||||
state = {
|
||||
campaignID: this.props.route.match.params
|
||||
? parseInt(this.props.route.match.params.campaignID, 10)
|
||||
: 0,
|
||||
record: {},
|
||||
formRef: null,
|
||||
contentType: cs.CampaignContentTypeRichtext,
|
||||
previewRecord: null,
|
||||
body: "",
|
||||
currentTab: "form",
|
||||
editor: null,
|
||||
loading: true,
|
||||
mediaVisible: false,
|
||||
formDisabled: false
|
||||
}
|
||||
|
||||
componentDidMount = () => {
|
||||
// Fetch lists.
|
||||
this.props.modelRequest(cs.ModelLists, cs.Routes.GetLists, cs.MethodGet, {
|
||||
per_page: "all"
|
||||
})
|
||||
|
||||
// Fetch templates.
|
||||
this.props.modelRequest(
|
||||
cs.ModelTemplates,
|
||||
cs.Routes.GetTemplates,
|
||||
cs.MethodGet
|
||||
)
|
||||
|
||||
// Fetch campaign.
|
||||
if (this.state.campaignID) {
|
||||
this.fetchRecord(this.state.campaignID)
|
||||
} else {
|
||||
this.setState({ loading: false })
|
||||
}
|
||||
|
||||
// Content tab?
|
||||
if (document.location.hash === "#content-tab") {
|
||||
this.setCurrentTab("content")
|
||||
}
|
||||
}
|
||||
|
||||
setRecord = r => {
|
||||
this.setState({ record: r })
|
||||
}
|
||||
|
||||
fetchRecord = id => {
|
||||
this.props
|
||||
.request(cs.Routes.GetCampaign, cs.MethodGet, { id: id })
|
||||
.then(r => {
|
||||
const record = r.data.data
|
||||
this.setState({ loading: false })
|
||||
this.setRecord(record)
|
||||
|
||||
// The form for non draft and scheduled campaigns should be locked.
|
||||
if (
|
||||
record.status !== cs.CampaignStatusDraft &&
|
||||
record.status !== cs.CampaignStatusScheduled
|
||||
) {
|
||||
this.setState({ formDisabled: true })
|
||||
}
|
||||
})
|
||||
.catch(e => {
|
||||
notification["error"]({
|
||||
placement: cs.MsgPosition,
|
||||
message: "Error",
|
||||
description: e.message
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
setContent = (contentType, body) => {
|
||||
this.setState({ contentType: contentType, body: body })
|
||||
}
|
||||
|
||||
toggleMedia = () => {
|
||||
this.setState({ mediaVisible: !this.state.mediaVisible })
|
||||
}
|
||||
|
||||
setCurrentTab = tab => {
|
||||
this.setState({ currentTab: tab })
|
||||
}
|
||||
|
||||
handlePreview = record => {
|
||||
this.setState({ previewRecord: record })
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<section className="content campaign">
|
||||
<Row gutter={[2, 16]}>
|
||||
<Col span={24} md={12}>
|
||||
{!this.state.record.id && <h1>Create a campaign</h1>}
|
||||
{this.state.record.id && (
|
||||
<div>
|
||||
<h1>
|
||||
<Tag
|
||||
color={cs.CampaignStatusColors[this.state.record.status]}
|
||||
>
|
||||
{this.state.record.status}
|
||||
</Tag>
|
||||
{this.state.record.type === cs.CampaignStatusOptin && (
|
||||
<Tag className="campaign-type" color="geekblue">
|
||||
{this.state.record.type}
|
||||
</Tag>
|
||||
)}
|
||||
{this.state.record.name}
|
||||
</h1>
|
||||
<span className="text-tiny text-grey">
|
||||
ID {this.state.record.id} — UUID{" "}
|
||||
{this.state.record.uuid}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</Col>
|
||||
<Col span={24} md={12} className="right header-action-break">
|
||||
{!this.state.formDisabled && !this.state.loading && (
|
||||
<div>
|
||||
<Button
|
||||
type="primary"
|
||||
icon="save"
|
||||
onClick={() => {
|
||||
this.state.formRef.handleSubmit()
|
||||
}}
|
||||
>
|
||||
{!this.state.record.id ? "Continue" : "Save changes"}
|
||||
</Button>{" "}
|
||||
{this.state.record.status === cs.CampaignStatusDraft &&
|
||||
this.state.record.send_at && (
|
||||
<Popconfirm
|
||||
title="The campaign will start automatically at the scheduled date and time. Schedule now?"
|
||||
onConfirm={() => {
|
||||
this.state.formRef.handleSubmit(() => {
|
||||
this.props.route.history.push({
|
||||
pathname: cs.Routes.ViewCampaigns,
|
||||
state: {
|
||||
campaign: this.state.record,
|
||||
campaignStatus: cs.CampaignStatusScheduled
|
||||
}
|
||||
})
|
||||
})
|
||||
}}
|
||||
>
|
||||
<Button icon="clock-circle" type="primary">
|
||||
Schedule campaign
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
)}
|
||||
{this.state.record.status === cs.CampaignStatusDraft &&
|
||||
!this.state.record.send_at && (
|
||||
<Popconfirm
|
||||
title="Campaign properties cannot be changed once it starts. Save changes and start now?"
|
||||
onConfirm={() => {
|
||||
this.state.formRef.handleSubmit(() => {
|
||||
this.props.route.history.push({
|
||||
pathname: cs.Routes.ViewCampaigns,
|
||||
state: {
|
||||
campaign: this.state.record,
|
||||
campaignStatus: cs.CampaignStatusRunning
|
||||
}
|
||||
})
|
||||
})
|
||||
}}
|
||||
>
|
||||
<Button icon="rocket" type="primary">
|
||||
Start campaign
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Col>
|
||||
</Row>
|
||||
<br />
|
||||
|
||||
<Tabs
|
||||
type="card"
|
||||
activeKey={this.state.currentTab}
|
||||
onTabClick={t => {
|
||||
this.setState({ currentTab: t })
|
||||
}}
|
||||
>
|
||||
<Tabs.TabPane tab="Campaign" key="form">
|
||||
<Spin spinning={this.state.loading}>
|
||||
<TheForm
|
||||
{...this.props}
|
||||
wrappedComponentRef={r => {
|
||||
if (!r) {
|
||||
return
|
||||
}
|
||||
// Take the editor's reference and save it in the state
|
||||
// so that it's insertMedia() function can be passed to <Media />
|
||||
this.setState({ formRef: r })
|
||||
}}
|
||||
record={this.state.record}
|
||||
setRecord={this.setRecord}
|
||||
isSingle={this.state.record.id ? true : false}
|
||||
body={
|
||||
this.state.body ? this.state.body : this.state.record.body
|
||||
}
|
||||
contentType={this.state.contentType}
|
||||
formDisabled={this.state.formDisabled}
|
||||
fetchRecord={this.fetchRecord}
|
||||
setCurrentTab={this.setCurrentTab}
|
||||
/>
|
||||
</Spin>
|
||||
</Tabs.TabPane>
|
||||
<Tabs.TabPane
|
||||
tab="Content"
|
||||
disabled={this.state.record.id ? false : true}
|
||||
key="content"
|
||||
>
|
||||
{this.state.record.id && (
|
||||
<div>
|
||||
<Editor
|
||||
{...this.props}
|
||||
ref={r => {
|
||||
if (!r) {
|
||||
return
|
||||
}
|
||||
// Take the editor's reference and save it in the state
|
||||
// so that it's insertMedia() function can be passed to <Media />
|
||||
this.setState({ editor: r })
|
||||
}}
|
||||
isSingle={this.state.record.id ? true : false}
|
||||
record={this.state.record}
|
||||
visible={this.state.editorVisible}
|
||||
toggleMedia={this.toggleMedia}
|
||||
setContent={this.setContent}
|
||||
formDisabled={this.state.formDisabled}
|
||||
/>
|
||||
<div className="content-actions">
|
||||
<p>
|
||||
<Button
|
||||
icon="search"
|
||||
onClick={() => this.handlePreview(this.state.record)}
|
||||
>
|
||||
Preview
|
||||
</Button>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!this.state.record.id && <Spin className="empty-spinner" />}
|
||||
</Tabs.TabPane>
|
||||
</Tabs>
|
||||
|
||||
<Modal
|
||||
visible={this.state.mediaVisible}
|
||||
width="900px"
|
||||
title="Media"
|
||||
okText={"Ok"}
|
||||
onCancel={this.toggleMedia}
|
||||
onOk={this.toggleMedia}
|
||||
>
|
||||
<Media
|
||||
{...{
|
||||
...this.props,
|
||||
insertMedia: this.state.editor
|
||||
? this.state.editor.insertMedia
|
||||
: null,
|
||||
onCancel: this.toggleMedia,
|
||||
onOk: this.toggleMedia
|
||||
}}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
{this.state.previewRecord && (
|
||||
<ModalPreview
|
||||
title={this.state.previewRecord.name}
|
||||
body={this.state.body}
|
||||
previewURL={cs.Routes.PreviewCampaign.replace(
|
||||
":id",
|
||||
this.state.previewRecord.id
|
||||
)}
|
||||
onCancel={() => {
|
||||
this.setState({ previewRecord: null })
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default Campaign
|
|
@ -1,747 +0,0 @@
|
|||
import React from "react"
|
||||
import { Link } from "react-router-dom"
|
||||
import {
|
||||
Row,
|
||||
Col,
|
||||
Button,
|
||||
Table,
|
||||
Icon,
|
||||
Tooltip,
|
||||
Tag,
|
||||
Popconfirm,
|
||||
Progress,
|
||||
Modal,
|
||||
notification,
|
||||
Input
|
||||
} from "antd"
|
||||
import dayjs from "dayjs"
|
||||
import relativeTime from "dayjs/plugin/relativeTime"
|
||||
|
||||
import ModalPreview from "./ModalPreview"
|
||||
import * as cs from "./constants"
|
||||
|
||||
class Campaigns extends React.PureComponent {
|
||||
defaultPerPage = 20
|
||||
|
||||
state = {
|
||||
formType: null,
|
||||
pollID: -1,
|
||||
queryParams: {},
|
||||
stats: {},
|
||||
record: null,
|
||||
previewRecord: null,
|
||||
cloneName: "",
|
||||
cloneModalVisible: false,
|
||||
modalWaiting: false
|
||||
}
|
||||
|
||||
// Pagination config.
|
||||
paginationOptions = {
|
||||
hideOnSinglePage: false,
|
||||
showSizeChanger: true,
|
||||
showQuickJumper: true,
|
||||
defaultPageSize: this.defaultPerPage,
|
||||
pageSizeOptions: ["20", "50", "70", "100"],
|
||||
position: "both",
|
||||
showTotal: (total, range) => `${range[0]} to ${range[1]} of ${total}`
|
||||
}
|
||||
|
||||
constructor(props) {
|
||||
super(props)
|
||||
|
||||
this.columns = [
|
||||
{
|
||||
title: "Name",
|
||||
dataIndex: "name",
|
||||
sorter: true,
|
||||
width: "20%",
|
||||
vAlign: "top",
|
||||
filterIcon: filtered => (
|
||||
<Icon
|
||||
type="search"
|
||||
style={{ color: filtered ? "#1890ff" : undefined }}
|
||||
/>
|
||||
),
|
||||
filterDropdown: ({
|
||||
setSelectedKeys,
|
||||
selectedKeys,
|
||||
confirm,
|
||||
clearFilters
|
||||
}) => (
|
||||
<div style={{ padding: 8 }}>
|
||||
<Input
|
||||
ref={node => {
|
||||
this.searchInput = node
|
||||
}}
|
||||
placeholder={`Search`}
|
||||
onChange={e =>
|
||||
setSelectedKeys(e.target.value ? [e.target.value] : [])
|
||||
}
|
||||
onPressEnter={() => confirm()}
|
||||
style={{ width: 188, marginBottom: 8, display: "block" }}
|
||||
/>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => confirm()}
|
||||
icon="search"
|
||||
size="small"
|
||||
style={{ width: 90, marginRight: 8 }}
|
||||
>
|
||||
Search
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
clearFilters()
|
||||
}}
|
||||
size="small"
|
||||
style={{ width: 90 }}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
render: (text, record) => {
|
||||
const out = []
|
||||
out.push(
|
||||
<div className="name" key={`name-${record.id}`}>
|
||||
<Link to={`/campaigns/${record.id}`}>{text}</Link>{" "}
|
||||
{record.type === cs.CampaignStatusOptin && (
|
||||
<Tooltip title="Opt-in campaign" placement="top">
|
||||
<Tag className="campaign-type" color="geekblue">
|
||||
{record.type}
|
||||
</Tag>
|
||||
</Tooltip>
|
||||
)}
|
||||
<br />
|
||||
<span className="text-tiny">{record.subject}</span>
|
||||
</div>
|
||||
)
|
||||
|
||||
if (record.tags.length > 0) {
|
||||
for (let i = 0; i < record.tags.length; i++) {
|
||||
out.push(<Tag key={`tag-${i}`}>{record.tags[i]}</Tag>)
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
},
|
||||
{
|
||||
title: "Status",
|
||||
dataIndex: "status",
|
||||
className: "status",
|
||||
width: "10%",
|
||||
filters: [
|
||||
{ text: "Draft", value: "draft" },
|
||||
{ text: "Running", value: "running" },
|
||||
{ text: "Scheduled", value: "scheduled" },
|
||||
{ text: "Paused", value: "paused" },
|
||||
{ text: "Cancelled", value: "cancelled" },
|
||||
{ text: "Finished", value: "finished" }
|
||||
],
|
||||
render: (status, record) => {
|
||||
let color = cs.CampaignStatusColors.hasOwnProperty(status)
|
||||
? cs.CampaignStatusColors[status]
|
||||
: ""
|
||||
return (
|
||||
<div>
|
||||
<Tag color={color}>{status}</Tag>
|
||||
{record.send_at && (
|
||||
<span className="text-tiny date">
|
||||
Scheduled —{" "}
|
||||
{dayjs(record.send_at).format(cs.DateFormat)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
},
|
||||
{
|
||||
title: "Lists",
|
||||
dataIndex: "lists",
|
||||
width: "25%",
|
||||
align: "left",
|
||||
className: "lists",
|
||||
render: (lists, record) => {
|
||||
const out = []
|
||||
lists.forEach(l => {
|
||||
out.push(
|
||||
<Tag className="name" key={`name-${l.id}`}>
|
||||
<Link to={`/subscribers/lists/${l.id}`}>{l.name}</Link>
|
||||
</Tag>
|
||||
)
|
||||
})
|
||||
|
||||
return out
|
||||
}
|
||||
},
|
||||
{
|
||||
title: "Stats",
|
||||
className: "stats",
|
||||
width: "30%",
|
||||
render: (text, record) => {
|
||||
if (
|
||||
record.status !== cs.CampaignStatusDraft &&
|
||||
record.status !== cs.CampaignStatusScheduled
|
||||
) {
|
||||
return this.renderStats(record)
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
title: "",
|
||||
dataIndex: "actions",
|
||||
className: "actions",
|
||||
width: "15%",
|
||||
render: (text, record) => {
|
||||
return (
|
||||
<div className="actions">
|
||||
{record.status === cs.CampaignStatusPaused && (
|
||||
<Popconfirm
|
||||
title="Are you sure?"
|
||||
onConfirm={() =>
|
||||
this.handleUpdateStatus(record, cs.CampaignStatusRunning)
|
||||
}
|
||||
>
|
||||
<Tooltip title="Resume campaign" placement="bottom">
|
||||
<a role="button">
|
||||
<Icon type="rocket" />
|
||||
</a>
|
||||
</Tooltip>
|
||||
</Popconfirm>
|
||||
)}
|
||||
|
||||
{record.status === cs.CampaignStatusRunning && (
|
||||
<Popconfirm
|
||||
title="Are you sure?"
|
||||
onConfirm={() =>
|
||||
this.handleUpdateStatus(record, cs.CampaignStatusPaused)
|
||||
}
|
||||
>
|
||||
<Tooltip title="Pause campaign" placement="bottom">
|
||||
<a role="button">
|
||||
<Icon type="pause-circle-o" />
|
||||
</a>
|
||||
</Tooltip>
|
||||
</Popconfirm>
|
||||
)}
|
||||
|
||||
{/* Draft with send_at */}
|
||||
{record.status === cs.CampaignStatusDraft && record.send_at && (
|
||||
<Popconfirm
|
||||
title="The campaign will start automatically at the scheduled date and time. Schedule now?"
|
||||
onConfirm={() =>
|
||||
this.handleUpdateStatus(record, cs.CampaignStatusScheduled)
|
||||
}
|
||||
>
|
||||
<Tooltip title="Schedule campaign" placement="bottom">
|
||||
<a role="button">
|
||||
<Icon type="clock-circle" />
|
||||
</a>
|
||||
</Tooltip>
|
||||
</Popconfirm>
|
||||
)}
|
||||
|
||||
{record.status === cs.CampaignStatusDraft && !record.send_at && (
|
||||
<Popconfirm
|
||||
title="Campaign properties cannot be changed once it starts. Start now?"
|
||||
onConfirm={() =>
|
||||
this.handleUpdateStatus(record, cs.CampaignStatusRunning)
|
||||
}
|
||||
>
|
||||
<Tooltip title="Start campaign" placement="bottom">
|
||||
<a role="button">
|
||||
<Icon type="rocket" />
|
||||
</a>
|
||||
</Tooltip>
|
||||
</Popconfirm>
|
||||
)}
|
||||
|
||||
{(record.status === cs.CampaignStatusPaused ||
|
||||
record.status === cs.CampaignStatusRunning) && (
|
||||
<Popconfirm
|
||||
title="Are you sure?"
|
||||
onConfirm={() =>
|
||||
this.handleUpdateStatus(record, cs.CampaignStatusCancelled)
|
||||
}
|
||||
>
|
||||
<Tooltip title="Cancel campaign" placement="bottom">
|
||||
<a role="button">
|
||||
<Icon type="close-circle-o" />
|
||||
</a>
|
||||
</Tooltip>
|
||||
</Popconfirm>
|
||||
)}
|
||||
|
||||
<Tooltip title="Preview campaign" placement="bottom">
|
||||
<a
|
||||
role="button"
|
||||
onClick={() => {
|
||||
this.handlePreview(record)
|
||||
}}
|
||||
>
|
||||
<Icon type="search" />
|
||||
</a>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip title="Clone campaign" placement="bottom">
|
||||
<a
|
||||
role="button"
|
||||
onClick={() => {
|
||||
let r = {
|
||||
...record,
|
||||
lists: record.lists.map(i => {
|
||||
return i.id
|
||||
})
|
||||
}
|
||||
this.handleToggleCloneForm(r)
|
||||
}}
|
||||
>
|
||||
<Icon type="copy" />
|
||||
</a>
|
||||
</Tooltip>
|
||||
|
||||
{(record.status === cs.CampaignStatusDraft ||
|
||||
record.status === cs.CampaignStatusScheduled) && (
|
||||
<Popconfirm
|
||||
title="Are you sure?"
|
||||
onConfirm={() => this.handleDeleteRecord(record)}
|
||||
>
|
||||
<Tooltip title="Delete campaign" placement="bottom">
|
||||
<a role="button">
|
||||
<Icon type="delete" />
|
||||
</a>
|
||||
</Tooltip>
|
||||
</Popconfirm>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
progressPercent(record) {
|
||||
return Math.round(
|
||||
(this.getStatsField("sent", record) /
|
||||
this.getStatsField("to_send", record)) *
|
||||
100,
|
||||
2
|
||||
)
|
||||
}
|
||||
|
||||
isDone(record) {
|
||||
return (
|
||||
this.getStatsField("status", record) === cs.CampaignStatusFinished ||
|
||||
this.getStatsField("status", record) === cs.CampaignStatusCancelled
|
||||
)
|
||||
}
|
||||
|
||||
// getStatsField returns a stats field value of a given record if it
|
||||
// exists in the stats state, or the value from the record itself.
|
||||
getStatsField = (field, record) => {
|
||||
if (this.state.stats.hasOwnProperty(record.id)) {
|
||||
return this.state.stats[record.id][field]
|
||||
}
|
||||
|
||||
return record[field]
|
||||
}
|
||||
|
||||
renderStats = record => {
|
||||
let color = cs.CampaignStatusColors.hasOwnProperty(record.status)
|
||||
? cs.CampaignStatusColors[record.status]
|
||||
: ""
|
||||
const startedAt = this.getStatsField("started_at", record)
|
||||
const updatedAt = this.getStatsField("updated_at", record)
|
||||
const sent = this.getStatsField("sent", record)
|
||||
const toSend = this.getStatsField("to_send", record)
|
||||
const isDone = this.isDone(record)
|
||||
|
||||
const r = this.getStatsField("rate", record)
|
||||
const rate = r ? r : 0
|
||||
|
||||
return (
|
||||
<div>
|
||||
{!isDone && (
|
||||
<Progress
|
||||
strokeColor={color}
|
||||
status="active"
|
||||
type="line"
|
||||
percent={this.progressPercent(record)}
|
||||
/>
|
||||
)}
|
||||
<Row>
|
||||
<Col className="label" span={10}>
|
||||
Sent
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
{sent >= toSend && <span>{toSend}</span>}
|
||||
{sent < toSend && (
|
||||
<span>
|
||||
{sent} / {toSend}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{record.status === cs.CampaignStatusRunning && (
|
||||
<Icon type="loading" style={{ fontSize: 12 }} spin />
|
||||
)}
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{rate > 0 && (
|
||||
<Row>
|
||||
<Col className="label" span={10}>
|
||||
Rate
|
||||
</Col>
|
||||
<Col span={12}>{Math.round(rate, 2)} / min</Col>
|
||||
</Row>
|
||||
)}
|
||||
|
||||
<Row>
|
||||
<Col className="label" span={10}>
|
||||
Views
|
||||
</Col>
|
||||
<Col span={12}>{record.views}</Col>
|
||||
</Row>
|
||||
<Row>
|
||||
<Col className="label" span={10}>
|
||||
Clicks
|
||||
</Col>
|
||||
<Col span={12}>{record.clicks}</Col>
|
||||
</Row>
|
||||
<br />
|
||||
<Row>
|
||||
<Col className="label" span={10}>
|
||||
Created
|
||||
</Col>
|
||||
<Col span={12}>{dayjs(record.created_at).format(cs.DateFormat)}</Col>
|
||||
</Row>
|
||||
|
||||
{startedAt && (
|
||||
<Row>
|
||||
<Col className="label" span={10}>
|
||||
Started
|
||||
</Col>
|
||||
<Col span={12}>{dayjs(startedAt).format(cs.DateFormat)}</Col>
|
||||
</Row>
|
||||
)}
|
||||
{isDone && (
|
||||
<Row>
|
||||
<Col className="label" span={10}>
|
||||
Ended
|
||||
</Col>
|
||||
<Col span={12}>{dayjs(updatedAt).format(cs.DateFormat)}</Col>
|
||||
</Row>
|
||||
)}
|
||||
{startedAt && updatedAt && (
|
||||
<Row>
|
||||
<Col className="label" span={10}>
|
||||
Duration
|
||||
</Col>
|
||||
<Col className="duration" span={12}>
|
||||
{dayjs(updatedAt).from(dayjs(startedAt), true)}
|
||||
</Col>
|
||||
</Row>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.props.pageTitle("Campaigns")
|
||||
dayjs.extend(relativeTime)
|
||||
this.fetchRecords()
|
||||
|
||||
// Did we land here to start a campaign?
|
||||
let loc = this.props.route.location
|
||||
let state = loc.state
|
||||
if (state && state.hasOwnProperty("campaign")) {
|
||||
this.handleUpdateStatus(state.campaign, state.campaignStatus)
|
||||
delete state.campaign
|
||||
delete state.campaignStatus
|
||||
this.props.route.history.replace({ ...loc, state })
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
window.clearInterval(this.state.pollID)
|
||||
}
|
||||
|
||||
fetchRecords = params => {
|
||||
if (!params) {
|
||||
params = {}
|
||||
}
|
||||
let qParams = {
|
||||
page: this.state.queryParams.page,
|
||||
per_page: this.state.queryParams.per_page
|
||||
}
|
||||
|
||||
// Avoid sending blank string where the enum check will fail.
|
||||
if (!params.status) {
|
||||
delete params.status
|
||||
}
|
||||
|
||||
if (params) {
|
||||
qParams = { ...qParams, ...params }
|
||||
}
|
||||
|
||||
this.props
|
||||
.modelRequest(
|
||||
cs.ModelCampaigns,
|
||||
cs.Routes.GetCampaigns,
|
||||
cs.MethodGet,
|
||||
qParams
|
||||
)
|
||||
.then(r => {
|
||||
this.setState({
|
||||
queryParams: {
|
||||
...this.state.queryParams,
|
||||
total: this.props.data[cs.ModelCampaigns].total,
|
||||
per_page: this.props.data[cs.ModelCampaigns].per_page,
|
||||
page: this.props.data[cs.ModelCampaigns].page,
|
||||
query: this.props.data[cs.ModelCampaigns].query,
|
||||
status: params.status
|
||||
}
|
||||
})
|
||||
|
||||
this.startStatsPoll()
|
||||
})
|
||||
}
|
||||
|
||||
startStatsPoll = () => {
|
||||
window.clearInterval(this.state.pollID)
|
||||
this.setState({ stats: {} })
|
||||
|
||||
// If there's at least one running campaign, start polling.
|
||||
let hasRunning = false
|
||||
this.props.data[cs.ModelCampaigns].results.forEach(c => {
|
||||
if (c.status === cs.CampaignStatusRunning) {
|
||||
hasRunning = true
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
if (!hasRunning) {
|
||||
return
|
||||
}
|
||||
|
||||
// Poll for campaign stats.
|
||||
let pollID = window.setInterval(() => {
|
||||
this.props
|
||||
.request(cs.Routes.GetRunningCampaignStats, cs.MethodGet)
|
||||
.then(r => {
|
||||
// No more running campaigns.
|
||||
if (r.data.data.length === 0) {
|
||||
window.clearInterval(this.state.pollID)
|
||||
this.fetchRecords()
|
||||
return
|
||||
}
|
||||
|
||||
let stats = {}
|
||||
r.data.data.forEach(s => {
|
||||
stats[s.id] = s
|
||||
})
|
||||
|
||||
this.setState({ stats: stats })
|
||||
})
|
||||
.catch(e => {
|
||||
console.log(e.message)
|
||||
})
|
||||
}, 3000)
|
||||
|
||||
this.setState({ pollID: pollID })
|
||||
}
|
||||
|
||||
handleUpdateStatus = (record, status) => {
|
||||
this.props
|
||||
.modelRequest(
|
||||
cs.ModelCampaigns,
|
||||
cs.Routes.UpdateCampaignStatus,
|
||||
cs.MethodPut,
|
||||
{ id: record.id, status: status }
|
||||
)
|
||||
.then(() => {
|
||||
notification["success"]({
|
||||
placement: cs.MsgPosition,
|
||||
message: `Campaign ${status}`,
|
||||
description: `"${record.name}" ${status}`
|
||||
})
|
||||
|
||||
// Reload the table.
|
||||
this.fetchRecords()
|
||||
})
|
||||
.catch(e => {
|
||||
notification["error"]({
|
||||
placement: cs.MsgPosition,
|
||||
message: "Error",
|
||||
description: e.message
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
handleDeleteRecord = record => {
|
||||
this.props
|
||||
.modelRequest(
|
||||
cs.ModelCampaigns,
|
||||
cs.Routes.DeleteCampaign,
|
||||
cs.MethodDelete,
|
||||
{ id: record.id }
|
||||
)
|
||||
.then(() => {
|
||||
notification["success"]({
|
||||
placement: cs.MsgPosition,
|
||||
message: "Campaign deleted",
|
||||
description: `"${record.name}" deleted`
|
||||
})
|
||||
|
||||
// Reload the table.
|
||||
this.fetchRecords()
|
||||
})
|
||||
.catch(e => {
|
||||
notification["error"]({
|
||||
placement: cs.MsgPosition,
|
||||
message: "Error",
|
||||
description: e.message
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
handleToggleCloneForm = record => {
|
||||
this.setState({
|
||||
cloneModalVisible: !this.state.cloneModalVisible,
|
||||
record: record,
|
||||
cloneName: record.name
|
||||
})
|
||||
}
|
||||
|
||||
handleCloneCampaign = record => {
|
||||
this.setState({ modalWaiting: true })
|
||||
this.props
|
||||
.modelRequest(
|
||||
cs.ModelCampaigns,
|
||||
cs.Routes.CreateCampaign,
|
||||
cs.MethodPost,
|
||||
record
|
||||
)
|
||||
.then(resp => {
|
||||
notification["success"]({
|
||||
placement: cs.MsgPosition,
|
||||
message: "Campaign created",
|
||||
description: `${record.name} created`
|
||||
})
|
||||
|
||||
this.setState({ record: null, modalWaiting: false })
|
||||
this.props.route.history.push(
|
||||
cs.Routes.ViewCampaign.replace(":id", resp.data.data.id)
|
||||
)
|
||||
})
|
||||
.catch(e => {
|
||||
notification["error"]({
|
||||
placement: cs.MsgPosition,
|
||||
message: "Error",
|
||||
description: e.message
|
||||
})
|
||||
this.setState({ modalWaiting: false })
|
||||
})
|
||||
}
|
||||
|
||||
handlePreview = record => {
|
||||
this.setState({ previewRecord: record })
|
||||
}
|
||||
|
||||
render() {
|
||||
const pagination = {
|
||||
...this.paginationOptions,
|
||||
...this.state.queryParams
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="content campaigns">
|
||||
<Row>
|
||||
<Col xs={24} sm={14}>
|
||||
<h1>Campaigns</h1>
|
||||
</Col>
|
||||
<Col xs={24} sm={10} className="right header-action-break">
|
||||
<Link to="/campaigns/new">
|
||||
<Button type="primary" icon="plus" role="link">
|
||||
New campaign
|
||||
</Button>
|
||||
</Link>
|
||||
</Col>
|
||||
</Row>
|
||||
<br />
|
||||
|
||||
<Table
|
||||
className="campaigns"
|
||||
columns={this.columns}
|
||||
rowKey={record => record.uuid}
|
||||
dataSource={(() => {
|
||||
if (
|
||||
!this.props.data[cs.ModelCampaigns] ||
|
||||
!this.props.data[cs.ModelCampaigns].hasOwnProperty("results")
|
||||
) {
|
||||
return []
|
||||
}
|
||||
return this.props.data[cs.ModelCampaigns].results
|
||||
})()}
|
||||
loading={this.props.reqStates[cs.ModelCampaigns] !== cs.StateDone}
|
||||
pagination={pagination}
|
||||
onChange={(pagination, filters, sorter, records) => {
|
||||
this.fetchRecords({
|
||||
per_page: pagination.pageSize,
|
||||
page: pagination.current,
|
||||
status:
|
||||
filters.status && filters.status.length > 0
|
||||
? filters.status
|
||||
: "",
|
||||
query:
|
||||
filters.name && filters.name.length > 0 ? filters.name[0] : ""
|
||||
})
|
||||
}}
|
||||
/>
|
||||
|
||||
{this.state.previewRecord && (
|
||||
<ModalPreview
|
||||
title={this.state.previewRecord.name}
|
||||
previewURL={cs.Routes.PreviewCampaign.replace(
|
||||
":id",
|
||||
this.state.previewRecord.id
|
||||
)}
|
||||
onCancel={() => {
|
||||
this.setState({ previewRecord: null })
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{this.state.cloneModalVisible && this.state.record && (
|
||||
<Modal
|
||||
visible={this.state.record !== null}
|
||||
width="500px"
|
||||
className="clone-campaign-modal"
|
||||
title={"Clone " + this.state.record.name}
|
||||
okText="Clone"
|
||||
confirmLoading={this.state.modalWaiting}
|
||||
onCancel={this.handleToggleCloneForm}
|
||||
onOk={() => {
|
||||
this.handleCloneCampaign({
|
||||
...this.state.record,
|
||||
name: this.state.cloneName
|
||||
})
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
autoFocus
|
||||
defaultValue={this.state.record.name}
|
||||
style={{ width: "100%" }}
|
||||
onChange={e => {
|
||||
this.setState({ cloneName: e.target.value })
|
||||
}}
|
||||
/>
|
||||
</Modal>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default Campaigns
|
|
@ -1,26 +0,0 @@
|
|||
.App {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.App-logo {
|
||||
animation: App-logo-spin infinite 20s linear;
|
||||
height: 80px;
|
||||
}
|
||||
|
||||
.App-header {
|
||||
height: 150px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.App-title {
|
||||
font-size: 1.5em;
|
||||
}
|
||||
|
||||
.App-intro {
|
||||
font-size: large;
|
||||
}
|
||||
|
||||
@keyframes App-logo-spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
|
@ -1,190 +0,0 @@
|
|||
import { Col, Row, notification, Card, Spin } from "antd"
|
||||
import React from "react"
|
||||
import { Chart, Geom, Tooltip as BizTooltip } from "bizcharts"
|
||||
|
||||
import * as cs from "./constants"
|
||||
|
||||
class Dashboard extends React.PureComponent {
|
||||
state = {
|
||||
stats: null,
|
||||
loading: true
|
||||
}
|
||||
|
||||
campaignTypes = [
|
||||
"running",
|
||||
"finished",
|
||||
"paused",
|
||||
"draft",
|
||||
"scheduled",
|
||||
"cancelled"
|
||||
]
|
||||
|
||||
componentDidMount = () => {
|
||||
this.props.pageTitle("Dashboard")
|
||||
this.props
|
||||
.request(cs.Routes.GetDashboarcStats, cs.MethodGet)
|
||||
.then(resp => {
|
||||
this.setState({ stats: resp.data.data, loading: false })
|
||||
})
|
||||
.catch(e => {
|
||||
notification["error"]({ message: "Error", description: e.message })
|
||||
this.setState({ loading: false })
|
||||
})
|
||||
}
|
||||
|
||||
orZero(v) {
|
||||
return v ? v : 0
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<section className="dashboard">
|
||||
<h1>Welcome</h1>
|
||||
<hr />
|
||||
<Spin spinning={this.state.loading}>
|
||||
{this.state.stats && (
|
||||
<div className="stats">
|
||||
<Row>
|
||||
<Col xs={24} sm={24} xl={16}>
|
||||
<Row gutter={24}>
|
||||
<Col xs={24} sm={12} md={8}>
|
||||
<Card title="Active subscribers" bordered={false}>
|
||||
<h1 className="count">
|
||||
{this.orZero(this.state.stats.subscribers.enabled)}
|
||||
</h1>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={8}>
|
||||
<Card title="Blacklisted subscribers" bordered={false}>
|
||||
<h1 className="count">
|
||||
{this.orZero(
|
||||
this.state.stats.subscribers.blacklisted
|
||||
)}
|
||||
</h1>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={8}>
|
||||
<Card title="Orphaned subscribers" bordered={false}>
|
||||
<h1 className="count">
|
||||
{this.orZero(this.state.stats.orphan_subscribers)}
|
||||
</h1>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</Col>
|
||||
<Col xs={24} sm={24} xl={{ span: 6, offset: 2 }}>
|
||||
<Row gutter={24}>
|
||||
<Col xs={24} sm={12}>
|
||||
<Card title="Public lists" bordered={false}>
|
||||
<h1 className="count">
|
||||
{this.orZero(this.state.stats.lists.public)}
|
||||
</h1>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={12}>
|
||||
<Card title="Private lists" bordered={false}>
|
||||
<h1 className="count">
|
||||
{this.orZero(this.state.stats.lists.private)}
|
||||
</h1>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</Col>
|
||||
</Row>
|
||||
<hr />
|
||||
<Row>
|
||||
<Col xs={24} sm={24} xl={16}>
|
||||
<Row gutter={24}>
|
||||
<Col xs={24} sm={12}>
|
||||
<Card
|
||||
title="Campaign views (last 3 months)"
|
||||
bordered={false}
|
||||
>
|
||||
<h1 className="count">
|
||||
{this.state.stats.campaign_views.reduce(
|
||||
(total, v) => total + v.count,
|
||||
0
|
||||
)}{" "}
|
||||
views
|
||||
</h1>
|
||||
<Chart
|
||||
height={220}
|
||||
padding={[0, 0, 0, 0]}
|
||||
data={this.state.stats.campaign_views}
|
||||
forceFit
|
||||
>
|
||||
<BizTooltip crosshairs={{ type: "y" }} />
|
||||
<Geom
|
||||
type="area"
|
||||
position="date*count"
|
||||
size={0}
|
||||
color="#7f2aff"
|
||||
/>
|
||||
<Geom type="point" position="date*count" size={0} />
|
||||
</Chart>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={12}>
|
||||
<Card
|
||||
title="Link clicks (last 3 months)"
|
||||
bordered={false}
|
||||
>
|
||||
<h1 className="count">
|
||||
{this.state.stats.link_clicks.reduce(
|
||||
(total, v) => total + v.count,
|
||||
0
|
||||
)}{" "}
|
||||
clicks
|
||||
</h1>
|
||||
<Chart
|
||||
height={220}
|
||||
padding={[0, 0, 0, 0]}
|
||||
data={this.state.stats.link_clicks}
|
||||
forceFit
|
||||
>
|
||||
<BizTooltip crosshairs={{ type: "y" }} />
|
||||
<Geom
|
||||
type="area"
|
||||
position="date*count"
|
||||
size={0}
|
||||
color="#7f2aff"
|
||||
/>
|
||||
<Geom type="point" position="date*count" size={0} />
|
||||
</Chart>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</Col>
|
||||
|
||||
<Col xs={24} sm={12} xl={{ span: 6, offset: 2 }}>
|
||||
<Card
|
||||
title="Campaigns"
|
||||
bordered={false}
|
||||
className="campaign-counts"
|
||||
>
|
||||
{this.campaignTypes.map(key => (
|
||||
<Row key={`stats-campaigns-${key}`}>
|
||||
<Col span={18}>
|
||||
<h1 className="name">{key}</h1>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<h1 className="count">
|
||||
{this.state.stats.campaigns.hasOwnProperty(key)
|
||||
? this.state.stats.campaigns[key]
|
||||
: 0}
|
||||
</h1>
|
||||
</Col>
|
||||
</Row>
|
||||
))}
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
)}
|
||||
</Spin>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default Dashboard
|
|
@ -1,139 +0,0 @@
|
|||
import React from "react"
|
||||
import {
|
||||
Row,
|
||||
Col,
|
||||
Checkbox,
|
||||
} from "antd"
|
||||
|
||||
import * as cs from "./constants"
|
||||
|
||||
class Forms extends React.PureComponent {
|
||||
state = {
|
||||
lists: [],
|
||||
selected: [],
|
||||
selectedUUIDs: [],
|
||||
indeterminate: false,
|
||||
checkAll: false
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.props.pageTitle("Subscription forms")
|
||||
this.props
|
||||
.modelRequest(cs.ModelLists, cs.Routes.GetLists, cs.MethodGet, {
|
||||
per_page: "all"
|
||||
})
|
||||
.then(() => {
|
||||
this.setState({ lists: this.props.data[cs.ModelLists].results })
|
||||
})
|
||||
}
|
||||
|
||||
handleSelectAll = e => {
|
||||
const uuids = this.state.lists.map(l => l.uuid)
|
||||
this.setState({
|
||||
selectedUUIDs: e.target.checked ? uuids : [],
|
||||
indeterminate: false,
|
||||
checkAll: e.target.checked
|
||||
})
|
||||
this.handleSelection(e.target.checked ? uuids : [])
|
||||
}
|
||||
|
||||
handleSelection(sel) {
|
||||
let out = []
|
||||
sel.forEach(s => {
|
||||
const item = this.state.lists.find(l => {
|
||||
return l.uuid === s
|
||||
})
|
||||
if (item) {
|
||||
out.push(item)
|
||||
}
|
||||
})
|
||||
|
||||
this.setState({
|
||||
selected: out,
|
||||
selectedUUIDs: sel,
|
||||
indeterminate: sel.length > 0 && sel.length < this.state.lists.length,
|
||||
checkAll: sel.length === this.state.lists.length
|
||||
})
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<section className="content list-form">
|
||||
<h1>Subscription forms</h1>
|
||||
<hr />
|
||||
<Row gutter={[16, 40]}>
|
||||
<Col span={24} md={8}>
|
||||
<h2>Lists</h2>
|
||||
<Checkbox
|
||||
indeterminate={this.state.indeterminate}
|
||||
onChange={this.handleSelectAll}
|
||||
checked={this.state.checkAll}
|
||||
>
|
||||
Select all
|
||||
</Checkbox>
|
||||
<hr />
|
||||
<Checkbox.Group
|
||||
className="lists"
|
||||
options={this.state.lists.map(l => {
|
||||
return { label: l.name, value: l.uuid }
|
||||
})}
|
||||
onChange={sel => this.handleSelection(sel)}
|
||||
value={this.state.selectedUUIDs}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={24} md={16}>
|
||||
<h2>Form HTML</h2>
|
||||
<p>
|
||||
Use the following HTML to show a subscription form on an external
|
||||
webpage.
|
||||
</p>
|
||||
<p>
|
||||
The form should have the{" "}
|
||||
<code>
|
||||
<strong>email</strong>
|
||||
</code>{" "}
|
||||
field and one or more{" "}
|
||||
<code>
|
||||
<strong>l</strong>
|
||||
</code>{" "}
|
||||
(list UUID) fields. The{" "}
|
||||
<code>
|
||||
<strong>name</strong>
|
||||
</code>{" "}
|
||||
field is optional.
|
||||
</p>
|
||||
<pre className="html">
|
||||
{`<form method="post" action="${
|
||||
window.CONFIG.rootURL
|
||||
}/subscription/form" class="listmonk-form">
|
||||
<div>
|
||||
<h3>Subscribe</h3>
|
||||
<p><input type="text" name="email" placeholder="E-mail" /></p>
|
||||
<p><input type="text" name="name" placeholder="Name (optional)" /></p>`}
|
||||
{(() => {
|
||||
let out = []
|
||||
this.state.selected.forEach(l => {
|
||||
out.push(`
|
||||
<p>
|
||||
<input type="checkbox" name="l" value="${
|
||||
l.uuid
|
||||
}" id="${l.uuid.substr(0, 5)}" />
|
||||
<label for="${l.uuid.substr(0, 5)}">${l.name}</label>
|
||||
</p>`)
|
||||
})
|
||||
return out
|
||||
})()}
|
||||
{`
|
||||
<p><input type="submit" value="Subscribe" /></p>
|
||||
</div>
|
||||
</form>
|
||||
`}
|
||||
</pre>
|
||||
</Col>
|
||||
</Row>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default Forms
|
|
@ -1,469 +0,0 @@
|
|||
import React from "react"
|
||||
import {
|
||||
Row,
|
||||
Col,
|
||||
Form,
|
||||
Select,
|
||||
Input,
|
||||
Upload,
|
||||
Button,
|
||||
Radio,
|
||||
Icon,
|
||||
Spin,
|
||||
Progress,
|
||||
Popconfirm,
|
||||
Tag,
|
||||
notification
|
||||
} from "antd"
|
||||
import * as cs from "./constants"
|
||||
|
||||
const StatusNone = "none"
|
||||
const StatusImporting = "importing"
|
||||
const StatusStopping = "stopping"
|
||||
const StatusFinished = "finished"
|
||||
const StatusFailed = "failed"
|
||||
|
||||
class TheFormDef extends React.PureComponent {
|
||||
state = {
|
||||
confirmDirty: false,
|
||||
fileList: [],
|
||||
formLoading: false,
|
||||
mode: "subscribe"
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
// Fetch lists.
|
||||
this.props.modelRequest(cs.ModelLists, cs.Routes.GetLists, cs.MethodGet, {
|
||||
per_page: "all"
|
||||
})
|
||||
}
|
||||
|
||||
// Handle create / edit form submission.
|
||||
handleSubmit = e => {
|
||||
e.preventDefault()
|
||||
var err = null,
|
||||
values = {}
|
||||
this.props.form.validateFields((e, v) => {
|
||||
err = e
|
||||
values = v
|
||||
})
|
||||
if (err) {
|
||||
return
|
||||
}
|
||||
|
||||
if (this.state.fileList.length < 1) {
|
||||
notification["error"]({
|
||||
placement: cs.MsgPosition,
|
||||
message: "Error",
|
||||
description: "Select a valid file to upload"
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
this.setState({ formLoading: true })
|
||||
let params = new FormData()
|
||||
params.set("params", JSON.stringify(values))
|
||||
params.append("file", this.state.fileList[0])
|
||||
this.props
|
||||
.request(cs.Routes.UploadRouteImport, cs.MethodPost, params)
|
||||
.then(() => {
|
||||
notification["info"]({
|
||||
placement: cs.MsgPosition,
|
||||
message: "File uploaded",
|
||||
description: "Please wait while the import is running"
|
||||
})
|
||||
this.props.fetchimportState()
|
||||
this.setState({ formLoading: false })
|
||||
})
|
||||
.catch(e => {
|
||||
notification["error"]({
|
||||
placement: cs.MsgPosition,
|
||||
message: "Error",
|
||||
description: e.message
|
||||
})
|
||||
this.props.fetchimportState()
|
||||
this.setState({ formLoading: false })
|
||||
})
|
||||
}
|
||||
|
||||
handleConfirmBlur = e => {
|
||||
const value = e.target.value
|
||||
this.setState({ confirmDirty: this.state.confirmDirty || !!value })
|
||||
}
|
||||
|
||||
onFileChange = f => {
|
||||
let fileList = [f]
|
||||
this.setState({ fileList })
|
||||
return false
|
||||
}
|
||||
|
||||
render() {
|
||||
const { getFieldDecorator } = this.props.form
|
||||
|
||||
const formItemLayout = {
|
||||
labelCol: { sm: { span: 24 }, md: { span: 5 } },
|
||||
wrapperCol: { sm: { span: 24 }, md: { span: 10 } }
|
||||
}
|
||||
|
||||
const formItemTailLayout = {
|
||||
wrapperCol: { xs: { span: 24, offset: 0 }, sm: { span: 10, offset: 4 } }
|
||||
}
|
||||
|
||||
return (
|
||||
<Spin spinning={this.state.formLoading}>
|
||||
<Form onSubmit={this.handleSubmit}>
|
||||
<Form.Item {...formItemLayout} label="Mode">
|
||||
{getFieldDecorator("mode", {
|
||||
rules: [{ required: true }],
|
||||
initialValue: "subscribe"
|
||||
})(
|
||||
<Radio.Group
|
||||
className="mode"
|
||||
onChange={e => {
|
||||
this.setState({ mode: e.target.value })
|
||||
}}
|
||||
>
|
||||
<Radio disabled={this.props.formDisabled} value="subscribe">
|
||||
Subscribe
|
||||
</Radio>
|
||||
<Radio disabled={this.props.formDisabled} value="blacklist">
|
||||
Blacklist
|
||||
</Radio>
|
||||
</Radio.Group>
|
||||
)}
|
||||
</Form.Item>
|
||||
{this.state.mode === "subscribe" && (
|
||||
<React.Fragment>
|
||||
<Form.Item
|
||||
{...formItemLayout}
|
||||
label="Lists"
|
||||
extra="Lists to subscribe to"
|
||||
>
|
||||
{getFieldDecorator("lists", { rules: [{ required: true }] })(
|
||||
<Select mode="multiple">
|
||||
{[...this.props.lists].map((v, i) => (
|
||||
<Select.Option value={v["id"]} key={v["id"]}>
|
||||
{v["name"]}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
)}
|
||||
</Form.Item>
|
||||
</React.Fragment>
|
||||
)}
|
||||
{this.state.mode === "blacklist" && (
|
||||
<Form.Item {...formItemTailLayout}>
|
||||
<p className="ant-form-extra">
|
||||
All existing subscribers found in the import will be marked as
|
||||
'blacklisted' and will be unsubscribed from their existing
|
||||
subscriptions. New subscribers will be imported and marked as
|
||||
'blacklisted'.
|
||||
</p>
|
||||
</Form.Item>
|
||||
)}
|
||||
<Form.Item
|
||||
{...formItemLayout}
|
||||
label="CSV delimiter"
|
||||
extra="Default delimiter is comma"
|
||||
>
|
||||
{getFieldDecorator("delim", {
|
||||
initialValue: ","
|
||||
})(<Input maxLength={1} style={{ maxWidth: 40 }} />)}
|
||||
</Form.Item>
|
||||
<Form.Item {...formItemLayout} label="CSV or ZIP file">
|
||||
<div className="dropbox">
|
||||
{getFieldDecorator("file", {
|
||||
valuePropName: "file",
|
||||
getValueFromEvent: this.normFile,
|
||||
rules: [{ required: true }]
|
||||
})(
|
||||
<Upload.Dragger
|
||||
name="files"
|
||||
multiple={false}
|
||||
fileList={this.state.fileList}
|
||||
beforeUpload={this.onFileChange}
|
||||
accept=".zip,.csv"
|
||||
>
|
||||
<p className="ant-upload-drag-icon">
|
||||
<Icon type="inbox" />
|
||||
</p>
|
||||
<p className="ant-upload-text">
|
||||
Click or drag a CSV or ZIP file here
|
||||
</p>
|
||||
</Upload.Dragger>
|
||||
)}
|
||||
</div>
|
||||
</Form.Item>
|
||||
<Form.Item {...formItemTailLayout}>
|
||||
<p className="ant-form-extra">
|
||||
For existing subscribers, the names and attributes will be
|
||||
overwritten with the values in the CSV.
|
||||
</p>
|
||||
<Button type="primary" htmlType="submit">
|
||||
<Icon type="upload" /> Upload
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Spin>
|
||||
)
|
||||
}
|
||||
}
|
||||
const TheForm = Form.create()(TheFormDef)
|
||||
|
||||
class Importing extends React.PureComponent {
|
||||
state = {
|
||||
pollID: -1,
|
||||
logs: ""
|
||||
}
|
||||
|
||||
stopImport = () => {
|
||||
// Get the import status.
|
||||
this.props
|
||||
.request(cs.Routes.UploadRouteImport, cs.MethodDelete)
|
||||
.then(r => {
|
||||
this.props.fetchimportState()
|
||||
})
|
||||
.catch(e => {
|
||||
notification["error"]({
|
||||
placement: cs.MsgPosition,
|
||||
message: "Error",
|
||||
description: e.message
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
// Poll for stats until it's finished or failed.
|
||||
let pollID = window.setInterval(() => {
|
||||
this.props.fetchimportState()
|
||||
this.fetchLogs()
|
||||
if (
|
||||
this.props.importState.status === StatusFinished ||
|
||||
this.props.importState.status === StatusFailed
|
||||
) {
|
||||
window.clearInterval(this.state.pollID)
|
||||
}
|
||||
}, 1000)
|
||||
|
||||
this.setState({ pollID: pollID })
|
||||
}
|
||||
componentWillUnmount() {
|
||||
window.clearInterval(this.state.pollID)
|
||||
}
|
||||
|
||||
fetchLogs() {
|
||||
this.props
|
||||
.request(cs.Routes.GetRouteImportLogs, cs.MethodGet)
|
||||
.then(r => {
|
||||
this.setState({ logs: r.data.data })
|
||||
let t = document.querySelector("#log-textarea")
|
||||
t.scrollTop = t.scrollHeight
|
||||
})
|
||||
.catch(e => {
|
||||
notification["error"]({
|
||||
placement: cs.MsgPosition,
|
||||
message: "Error",
|
||||
description: e.message
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
render() {
|
||||
let progressPercent = 0
|
||||
if (this.props.importState.status === StatusFinished) {
|
||||
progressPercent = 100
|
||||
} else {
|
||||
progressPercent = Math.floor(
|
||||
(this.props.importState.imported / this.props.importState.total) * 100
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="content import">
|
||||
<h1>Importing — {this.props.importState.name}</h1>
|
||||
{this.props.importState.status === StatusImporting && (
|
||||
<p>
|
||||
Import is in progress. It is safe to navigate away from this page.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{this.props.importState.status !== StatusImporting && (
|
||||
<p>Import has finished.</p>
|
||||
)}
|
||||
|
||||
<Row className="import-container">
|
||||
<Col span={10} offset={3}>
|
||||
<div className="stats center">
|
||||
<div>
|
||||
<Progress type="line" percent={progressPercent} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3>{this.props.importState.imported} records</h3>
|
||||
<br />
|
||||
|
||||
{this.props.importState.status === StatusImporting && (
|
||||
<Popconfirm
|
||||
title="Are you sure?"
|
||||
onConfirm={() => this.stopImport()}
|
||||
>
|
||||
<p>
|
||||
<Icon type="loading" />
|
||||
</p>
|
||||
<Button type="primary">Stop import</Button>
|
||||
</Popconfirm>
|
||||
)}
|
||||
{this.props.importState.status === StatusStopping && (
|
||||
<div>
|
||||
<p>
|
||||
<Icon type="loading" />
|
||||
</p>
|
||||
<h4>Stopping</h4>
|
||||
</div>
|
||||
)}
|
||||
{this.props.importState.status !== StatusImporting &&
|
||||
this.props.importState.status !== StatusStopping && (
|
||||
<div>
|
||||
{this.props.importState.status !== StatusFinished && (
|
||||
<div>
|
||||
<Tag color="red">{this.props.importState.status}</Tag>
|
||||
<br />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<br />
|
||||
<Button type="primary" onClick={() => this.stopImport()}>
|
||||
Done
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="logs">
|
||||
<h3>Import log</h3>
|
||||
<Spin spinning={this.state.logs === ""}>
|
||||
<Input.TextArea
|
||||
placeholder="Import logs"
|
||||
id="log-textarea"
|
||||
rows={10}
|
||||
value={this.state.logs}
|
||||
autosize={{ minRows: 2, maxRows: 10 }}
|
||||
/>
|
||||
</Spin>
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
class Import extends React.PureComponent {
|
||||
state = {
|
||||
importState: { status: "" }
|
||||
}
|
||||
|
||||
fetchimportState = () => {
|
||||
// Get the import status.
|
||||
this.props
|
||||
.request(cs.Routes.GetRouteImportStats, cs.MethodGet)
|
||||
.then(r => {
|
||||
this.setState({ importState: r.data.data })
|
||||
})
|
||||
.catch(e => {
|
||||
notification["error"]({
|
||||
placement: cs.MsgPosition,
|
||||
message: "Error",
|
||||
description: e.message
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.props.pageTitle("Import subscribers")
|
||||
this.fetchimportState()
|
||||
}
|
||||
render() {
|
||||
if (this.state.importState.status === "") {
|
||||
// Fetching the status.
|
||||
return (
|
||||
<section className="content center">
|
||||
<Spin />
|
||||
</section>
|
||||
)
|
||||
} else if (this.state.importState.status !== StatusNone) {
|
||||
// There's an import state
|
||||
return (
|
||||
<Importing
|
||||
{...this.props}
|
||||
importState={this.state.importState}
|
||||
fetchimportState={this.fetchimportState}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="content import">
|
||||
<Row>
|
||||
<Col span={22}>
|
||||
<h1>Import subscribers</h1>
|
||||
</Col>
|
||||
<Col span={2} />
|
||||
</Row>
|
||||
|
||||
<TheForm
|
||||
{...this.props}
|
||||
fetchimportState={this.fetchimportState}
|
||||
lists={
|
||||
this.props.data[cs.ModelLists].hasOwnProperty("results")
|
||||
? this.props.data[cs.ModelLists].results
|
||||
: []
|
||||
}
|
||||
/>
|
||||
|
||||
<hr />
|
||||
<div className="help">
|
||||
<h2>Instructions</h2>
|
||||
<p>
|
||||
Upload a CSV file or a ZIP file with a single CSV file in it to bulk
|
||||
import subscribers. The CSV file should have the following headers
|
||||
with the exact column names. <code>attributes</code> (optional)
|
||||
should be a valid JSON string with double escaped quotes.
|
||||
</p>
|
||||
|
||||
<blockquote className="csv-example">
|
||||
<code className="csv-headers">
|
||||
<span>email,</span>
|
||||
<span>name,</span>
|
||||
<span>attributes</span>
|
||||
</code>
|
||||
</blockquote>
|
||||
|
||||
<h3>Example raw CSV</h3>
|
||||
<blockquote className="csv-example">
|
||||
<code className="csv-headers">
|
||||
<span>email,</span>
|
||||
<span>name,</span>
|
||||
<span>attributes</span>
|
||||
</code>
|
||||
<code className="csv-row">
|
||||
<span>user1@mail.com,</span>
|
||||
<span>"User One",</span>
|
||||
<span>{'"{""age"": 32, ""city"": ""Bangalore""}"'}</span>
|
||||
</code>
|
||||
<code className="csv-row">
|
||||
<span>user2@mail.com,</span>
|
||||
<span>"User Two",</span>
|
||||
<span>
|
||||
{'"{""age"": 25, ""occupation"": ""Time Traveller""}"'}
|
||||
</span>
|
||||
</code>
|
||||
</blockquote>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default Import
|
|
@ -1,275 +0,0 @@
|
|||
import React from "react"
|
||||
import { Switch, Route } from "react-router-dom"
|
||||
import { Link } from "react-router-dom"
|
||||
import { Layout, Menu, Icon } from "antd"
|
||||
|
||||
import logo from "./static/listmonk.svg"
|
||||
|
||||
// Views.
|
||||
import Dashboard from "./Dashboard"
|
||||
import Lists from "./Lists"
|
||||
import Forms from "./Forms"
|
||||
import Subscribers from "./Subscribers"
|
||||
import Subscriber from "./Subscriber"
|
||||
import Templates from "./Templates"
|
||||
import Import from "./Import"
|
||||
import Campaigns from "./Campaigns"
|
||||
import Campaign from "./Campaign"
|
||||
import Media from "./Media"
|
||||
|
||||
const { Content, Footer, Sider } = Layout
|
||||
const SubMenu = Menu.SubMenu
|
||||
const year = new Date().getUTCFullYear()
|
||||
|
||||
class Base extends React.Component {
|
||||
state = {
|
||||
basePath: "/" + window.location.pathname.split("/")[1],
|
||||
error: null,
|
||||
collapsed: false
|
||||
}
|
||||
|
||||
onCollapse = collapsed => {
|
||||
this.setState({ collapsed })
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
// For small screen devices collapse the menu by default.
|
||||
if (window.screen.width < 768) {
|
||||
this.setState({ collapsed: true })
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<Layout style={{ minHeight: "100vh" }}>
|
||||
<Sider
|
||||
collapsible
|
||||
collapsed={this.state.collapsed}
|
||||
onCollapse={this.onCollapse}
|
||||
theme="light"
|
||||
>
|
||||
<div className="logo">
|
||||
<Link to="/">
|
||||
<img src={logo} alt="listmonk logo" />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<Menu
|
||||
defaultSelectedKeys={["/"]}
|
||||
selectedKeys={[window.location.pathname]}
|
||||
defaultOpenKeys={[this.state.basePath]}
|
||||
mode="inline"
|
||||
>
|
||||
<Menu.Item key="/">
|
||||
<Link to="/">
|
||||
<Icon type="dashboard" />
|
||||
<span>Dashboard</span>
|
||||
</Link>
|
||||
</Menu.Item>
|
||||
<SubMenu
|
||||
key="/lists"
|
||||
title={
|
||||
<span>
|
||||
<Icon type="bars" />
|
||||
<span>Lists</span>
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<Menu.Item key="/lists">
|
||||
<Link to="/lists">
|
||||
<Icon type="bars" />
|
||||
<span>All lists</span>
|
||||
</Link>
|
||||
</Menu.Item>
|
||||
<Menu.Item key="/lists/forms">
|
||||
<Link to="/lists/forms">
|
||||
<Icon type="form" />
|
||||
<span>Forms</span>
|
||||
</Link>
|
||||
</Menu.Item>
|
||||
</SubMenu>
|
||||
<SubMenu
|
||||
key="/subscribers"
|
||||
title={
|
||||
<span>
|
||||
<Icon type="team" />
|
||||
<span>Subscribers</span>
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<Menu.Item key="/subscribers">
|
||||
<Link to="/subscribers">
|
||||
<Icon type="team" /> All subscribers
|
||||
</Link>
|
||||
</Menu.Item>
|
||||
<Menu.Item key="/subscribers/import">
|
||||
<Link to="/subscribers/import">
|
||||
<Icon type="upload" /> Import
|
||||
</Link>
|
||||
</Menu.Item>
|
||||
</SubMenu>
|
||||
|
||||
<SubMenu
|
||||
key="/campaigns"
|
||||
title={
|
||||
<span>
|
||||
<Icon type="rocket" />
|
||||
<span>Campaigns</span>
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<Menu.Item key="/campaigns">
|
||||
<Link to="/campaigns">
|
||||
<Icon type="rocket" /> All campaigns
|
||||
</Link>
|
||||
</Menu.Item>
|
||||
<Menu.Item key="/campaigns/new">
|
||||
<Link to="/campaigns/new">
|
||||
<Icon type="plus" /> Create new
|
||||
</Link>
|
||||
</Menu.Item>
|
||||
<Menu.Item key="/campaigns/media">
|
||||
<Link to="/campaigns/media">
|
||||
<Icon type="picture" /> Media
|
||||
</Link>
|
||||
</Menu.Item>
|
||||
<Menu.Item key="/campaigns/templates">
|
||||
<Link to="/campaigns/templates">
|
||||
<Icon type="code-o" /> Templates
|
||||
</Link>
|
||||
</Menu.Item>
|
||||
</SubMenu>
|
||||
</Menu>
|
||||
</Sider>
|
||||
|
||||
<Layout>
|
||||
<Content style={{ margin: "0 16px" }}>
|
||||
<div className="content-body">
|
||||
<div id="alert-container" />
|
||||
<Switch>
|
||||
<Route
|
||||
exact
|
||||
key="/"
|
||||
path="/"
|
||||
render={props => (
|
||||
<Dashboard {...{ ...this.props, route: props }} />
|
||||
)}
|
||||
/>
|
||||
<Route
|
||||
exact
|
||||
key="/lists"
|
||||
path="/lists"
|
||||
render={props => (
|
||||
<Lists {...{ ...this.props, route: props }} />
|
||||
)}
|
||||
/>
|
||||
<Route
|
||||
exact
|
||||
key="/lists/forms"
|
||||
path="/lists/forms"
|
||||
render={props => (
|
||||
<Forms {...{ ...this.props, route: props }} />
|
||||
)}
|
||||
/>
|
||||
<Route
|
||||
exact
|
||||
key="/subscribers"
|
||||
path="/subscribers"
|
||||
render={props => (
|
||||
<Subscribers {...{ ...this.props, route: props }} />
|
||||
)}
|
||||
/>
|
||||
<Route
|
||||
exact
|
||||
key="/subscribers/lists/:listID"
|
||||
path="/subscribers/lists/:listID"
|
||||
render={props => (
|
||||
<Subscribers {...{ ...this.props, route: props }} />
|
||||
)}
|
||||
/>
|
||||
<Route
|
||||
exact
|
||||
key="/subscribers/import"
|
||||
path="/subscribers/import"
|
||||
render={props => (
|
||||
<Import {...{ ...this.props, route: props }} />
|
||||
)}
|
||||
/>
|
||||
<Route
|
||||
exact
|
||||
key="/subscribers/:subID"
|
||||
path="/subscribers/:subID"
|
||||
render={props => (
|
||||
<Subscriber {...{ ...this.props, route: props }} />
|
||||
)}
|
||||
/>
|
||||
<Route
|
||||
exact
|
||||
key="/campaigns"
|
||||
path="/campaigns"
|
||||
render={props => (
|
||||
<Campaigns {...{ ...this.props, route: props }} />
|
||||
)}
|
||||
/>
|
||||
<Route
|
||||
exact
|
||||
key="/campaigns/new"
|
||||
path="/campaigns/new"
|
||||
render={props => (
|
||||
<Campaign {...{ ...this.props, route: props }} />
|
||||
)}
|
||||
/>
|
||||
<Route
|
||||
exact
|
||||
key="/campaigns/media"
|
||||
path="/campaigns/media"
|
||||
render={props => (
|
||||
<Media {...{ ...this.props, route: props }} />
|
||||
)}
|
||||
/>
|
||||
<Route
|
||||
exact
|
||||
key="/campaigns/templates"
|
||||
path="/campaigns/templates"
|
||||
render={props => (
|
||||
<Templates {...{ ...this.props, route: props }} />
|
||||
)}
|
||||
/>
|
||||
<Route
|
||||
exact
|
||||
key="/campaigns/:campaignID"
|
||||
path="/campaigns/:campaignID"
|
||||
render={props => (
|
||||
<Campaign {...{ ...this.props, route: props }} />
|
||||
)}
|
||||
/>
|
||||
</Switch>
|
||||
</div>
|
||||
</Content>
|
||||
<Footer>
|
||||
<span className="text-small">
|
||||
<a
|
||||
href="https://listmonk.app"
|
||||
rel="noreferrer noopener"
|
||||
target="_blank"
|
||||
>
|
||||
listmonk
|
||||
</a>{" "}
|
||||
© 2019 {year !== 2019 ? " - " + year : ""}. Version{" "}
|
||||
{process.env.REACT_APP_VERSION} —{" "}
|
||||
<a
|
||||
href="https://listmonk.app/docs"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Docs
|
||||
</a>
|
||||
</span>
|
||||
</Footer>
|
||||
</Layout>
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default Base
|
|
@ -1,496 +0,0 @@
|
|||
import React from "react"
|
||||
import { Link } from "react-router-dom"
|
||||
import {
|
||||
Row,
|
||||
Col,
|
||||
Modal,
|
||||
Form,
|
||||
Input,
|
||||
Select,
|
||||
Button,
|
||||
Table,
|
||||
Icon,
|
||||
Tooltip,
|
||||
Tag,
|
||||
Popconfirm,
|
||||
Spin,
|
||||
notification
|
||||
} from "antd"
|
||||
|
||||
import Utils from "./utils"
|
||||
import * as cs from "./constants"
|
||||
|
||||
const tagColors = {
|
||||
private: "orange",
|
||||
public: "green"
|
||||
}
|
||||
|
||||
class CreateFormDef extends React.PureComponent {
|
||||
state = {
|
||||
confirmDirty: false,
|
||||
modalWaiting: false
|
||||
}
|
||||
|
||||
// Handle create / edit form submission.
|
||||
handleSubmit = e => {
|
||||
e.preventDefault()
|
||||
this.props.form.validateFields((err, values) => {
|
||||
if (err) {
|
||||
return
|
||||
}
|
||||
|
||||
this.setState({ modalWaiting: true })
|
||||
if (this.props.formType === cs.FormCreate) {
|
||||
// Create a new list.
|
||||
this.props
|
||||
.modelRequest(
|
||||
cs.ModelLists,
|
||||
cs.Routes.CreateList,
|
||||
cs.MethodPost,
|
||||
values
|
||||
)
|
||||
.then(() => {
|
||||
notification["success"]({
|
||||
placement: cs.MsgPosition,
|
||||
message: "List created",
|
||||
description: `"${values["name"]}" created`
|
||||
})
|
||||
this.props.fetchRecords()
|
||||
this.props.onClose()
|
||||
this.setState({ modalWaiting: false })
|
||||
})
|
||||
.catch(e => {
|
||||
notification["error"]({ message: "Error", description: e.message })
|
||||
this.setState({ modalWaiting: false })
|
||||
})
|
||||
} else {
|
||||
// Edit a list.
|
||||
this.props
|
||||
.modelRequest(cs.ModelLists, cs.Routes.UpdateList, cs.MethodPut, {
|
||||
...values,
|
||||
id: this.props.record.id
|
||||
})
|
||||
.then(() => {
|
||||
notification["success"]({
|
||||
placement: cs.MsgPosition,
|
||||
message: "List modified",
|
||||
description: `"${values["name"]}" modified`
|
||||
})
|
||||
this.props.fetchRecords()
|
||||
this.props.onClose()
|
||||
this.setState({ modalWaiting: false })
|
||||
})
|
||||
.catch(e => {
|
||||
notification["error"]({
|
||||
placement: cs.MsgPosition,
|
||||
message: "Error",
|
||||
description: e.message
|
||||
})
|
||||
this.setState({ modalWaiting: false })
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
modalTitle(formType, record) {
|
||||
if (formType === cs.FormCreate) {
|
||||
return "Create a list"
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Tag
|
||||
color={
|
||||
tagColors.hasOwnProperty(record.type) ? tagColors[record.type] : ""
|
||||
}
|
||||
>
|
||||
{record.type}
|
||||
</Tag>{" "}
|
||||
{record.name}
|
||||
<br />
|
||||
<span className="text-tiny text-grey">
|
||||
ID {record.id} / UUID {record.uuid}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
render() {
|
||||
const { formType, record, onClose } = this.props
|
||||
const { getFieldDecorator } = this.props.form
|
||||
|
||||
const formItemLayout = {
|
||||
labelCol: { xs: { span: 16 }, sm: { span: 4 } },
|
||||
wrapperCol: { xs: { span: 16 }, sm: { span: 18 } }
|
||||
}
|
||||
|
||||
if (formType === null) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={true}
|
||||
title={this.modalTitle(formType, record)}
|
||||
okText={this.state.form === cs.FormCreate ? "Create" : "Save"}
|
||||
confirmLoading={this.state.modalWaiting}
|
||||
onCancel={onClose}
|
||||
onOk={this.handleSubmit}
|
||||
>
|
||||
<div id="modal-alert-container" />
|
||||
|
||||
<Spin
|
||||
spinning={this.props.reqStates[cs.ModelLists] === cs.StatePending}
|
||||
>
|
||||
<Form onSubmit={this.handleSubmit}>
|
||||
<Form.Item {...formItemLayout} label="Name">
|
||||
{getFieldDecorator("name", {
|
||||
initialValue: record.name,
|
||||
rules: [{ required: true }]
|
||||
})(<Input autoFocus maxLength={200} />)}
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
{...formItemLayout}
|
||||
name="type"
|
||||
label="Type"
|
||||
extra="Public lists are open to the world to subscribe and their
|
||||
names may appear on public pages such as the subscription management page."
|
||||
>
|
||||
{getFieldDecorator("type", {
|
||||
initialValue: record.type ? record.type : "private",
|
||||
rules: [{ required: true }]
|
||||
})(
|
||||
<Select style={{ maxWidth: 120 }}>
|
||||
<Select.Option value="private">Private</Select.Option>
|
||||
<Select.Option value="public">Public</Select.Option>
|
||||
</Select>
|
||||
)}
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
{...formItemLayout}
|
||||
name="optin"
|
||||
label="Opt-in"
|
||||
extra="Double opt-in sends an e-mail to the subscriber asking for confirmation.
|
||||
On Double opt-in lists, campaigns are only sent to confirmed subscribers."
|
||||
>
|
||||
{getFieldDecorator("optin", {
|
||||
initialValue: record.optin ? record.optin : "single",
|
||||
rules: [{ required: true }]
|
||||
})(
|
||||
<Select style={{ maxWidth: 120 }}>
|
||||
<Select.Option value="single">Single</Select.Option>
|
||||
<Select.Option value="double">Double</Select.Option>
|
||||
</Select>
|
||||
)}
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
{...formItemLayout}
|
||||
label="Tags"
|
||||
extra="Hit Enter after typing a word to add multiple tags"
|
||||
>
|
||||
{getFieldDecorator("tags", { initialValue: record.tags })(
|
||||
<Select mode="tags" />
|
||||
)}
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Spin>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const CreateForm = Form.create()(CreateFormDef)
|
||||
|
||||
class Lists extends React.PureComponent {
|
||||
defaultPerPage = 20
|
||||
state = {
|
||||
formType: null,
|
||||
record: {},
|
||||
queryParams: {}
|
||||
}
|
||||
|
||||
// Pagination config.
|
||||
paginationOptions = {
|
||||
hideOnSinglePage: false,
|
||||
showSizeChanger: true,
|
||||
showQuickJumper: true,
|
||||
defaultPageSize: this.defaultPerPage,
|
||||
pageSizeOptions: ["20", "50", "70", "100"],
|
||||
position: "both",
|
||||
showTotal: (total, range) => `${range[0]} to ${range[1]} of ${total}`,
|
||||
onChange: (page, perPage) => {
|
||||
this.fetchRecords({ page: page, per_page: perPage })
|
||||
},
|
||||
onShowSizeChange: (page, perPage) => {
|
||||
this.fetchRecords({ page: page, per_page: perPage })
|
||||
}
|
||||
}
|
||||
|
||||
constructor(props) {
|
||||
super(props)
|
||||
|
||||
this.columns = [
|
||||
{
|
||||
title: "Name",
|
||||
dataIndex: "name",
|
||||
sorter: true,
|
||||
width: "40%",
|
||||
render: (text, record) => {
|
||||
const out = []
|
||||
out.push(
|
||||
<div className="name" key={`name-${record.id}`}>
|
||||
<Link to={`/subscribers/lists/${record.id}`}>{text}</Link>
|
||||
</div>
|
||||
)
|
||||
|
||||
if (record.tags.length > 0) {
|
||||
for (let i = 0; i < record.tags.length; i++) {
|
||||
out.push(<Tag key={`tag-${i}`}>{record.tags[i]}</Tag>)
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
},
|
||||
{
|
||||
title: "Type",
|
||||
dataIndex: "type",
|
||||
width: "15%",
|
||||
render: (type, record) => {
|
||||
let color = type === "private" ? "orange" : "green"
|
||||
return (
|
||||
<div>
|
||||
<p>
|
||||
<Tag color={color}>{type}</Tag>
|
||||
<Tag>{record.optin}</Tag>
|
||||
</p>
|
||||
{record.optin === cs.ListOptinDouble && (
|
||||
<p className="text-small">
|
||||
<Tooltip title="Send a campaign to unconfirmed subscribers to opt-in">
|
||||
<Link onClick={ e => { e.preventDefault(); this.makeOptinCampaign(record)} } to={`/campaigns/new?type=optin&list_id=${record.id}`}>
|
||||
<Icon type="rocket" /> Send opt-in campaign
|
||||
</Link>
|
||||
</Tooltip>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
},
|
||||
{
|
||||
title: "Subscribers",
|
||||
dataIndex: "subscriber_count",
|
||||
width: "10%",
|
||||
align: "center",
|
||||
render: (text, record) => {
|
||||
return (
|
||||
<div className="name" key={`name-${record.id}`}>
|
||||
<Link to={`/subscribers/lists/${record.id}`}>{text}</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
},
|
||||
{
|
||||
title: "Created",
|
||||
dataIndex: "created_at",
|
||||
render: (date, _) => {
|
||||
return Utils.DateString(date)
|
||||
}
|
||||
},
|
||||
{
|
||||
title: "Updated",
|
||||
dataIndex: "updated_at",
|
||||
render: (date, _) => {
|
||||
return Utils.DateString(date)
|
||||
}
|
||||
},
|
||||
{
|
||||
title: "",
|
||||
dataIndex: "actions",
|
||||
width: "10%",
|
||||
render: (text, record) => {
|
||||
return (
|
||||
<div className="actions">
|
||||
<Tooltip title="Send a campaign">
|
||||
<Link to={`/campaigns/new?list_id=${record.id}`}>
|
||||
<Icon type="rocket" />
|
||||
</Link>
|
||||
</Tooltip>
|
||||
<Tooltip title="Edit list">
|
||||
<a
|
||||
role="button"
|
||||
onClick={() => this.handleShowEditForm(record)}
|
||||
>
|
||||
<Icon type="edit" />
|
||||
</a>
|
||||
</Tooltip>
|
||||
<Popconfirm
|
||||
title="Are you sure?"
|
||||
onConfirm={() => this.deleteRecord(record)}
|
||||
>
|
||||
<Tooltip title="Delete list" placement="bottom">
|
||||
<a role="button">
|
||||
<Icon type="delete" />
|
||||
</a>
|
||||
</Tooltip>
|
||||
</Popconfirm>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.props.pageTitle("Lists")
|
||||
this.fetchRecords()
|
||||
}
|
||||
|
||||
fetchRecords = params => {
|
||||
let qParams = {
|
||||
page: this.state.queryParams.page,
|
||||
per_page: this.state.queryParams.per_page
|
||||
}
|
||||
if (params) {
|
||||
qParams = { ...qParams, ...params }
|
||||
}
|
||||
|
||||
this.props
|
||||
.modelRequest(cs.ModelLists, cs.Routes.GetLists, cs.MethodGet, qParams)
|
||||
.then(() => {
|
||||
this.setState({
|
||||
queryParams: {
|
||||
...this.state.queryParams,
|
||||
total: this.props.data[cs.ModelLists].total,
|
||||
perPage: this.props.data[cs.ModelLists].per_page,
|
||||
page: this.props.data[cs.ModelLists].page,
|
||||
query: this.props.data[cs.ModelLists].query
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
deleteRecord = record => {
|
||||
this.props
|
||||
.modelRequest(cs.ModelLists, cs.Routes.DeleteList, cs.MethodDelete, {
|
||||
id: record.id
|
||||
})
|
||||
.then(() => {
|
||||
notification["success"]({
|
||||
placement: cs.MsgPosition,
|
||||
message: "List deleted",
|
||||
description: `"${record.name}" deleted`
|
||||
})
|
||||
|
||||
// Reload the table.
|
||||
this.fetchRecords()
|
||||
})
|
||||
.catch(e => {
|
||||
notification["error"]({
|
||||
placement: cs.MsgPosition,
|
||||
message: "Error",
|
||||
description: e.message
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
makeOptinCampaign = record => {
|
||||
this.props
|
||||
.modelRequest(
|
||||
cs.ModelCampaigns,
|
||||
cs.Routes.CreateCampaign,
|
||||
cs.MethodPost,
|
||||
{
|
||||
type: cs.CampaignTypeOptin,
|
||||
name: "Optin: "+ record.name,
|
||||
subject: "Confirm your subscriptions",
|
||||
messenger: "email",
|
||||
content_type: cs.CampaignContentTypeRichtext,
|
||||
lists: [record.id]
|
||||
}
|
||||
)
|
||||
.then(resp => {
|
||||
notification["success"]({
|
||||
placement: cs.MsgPosition,
|
||||
message: "Opt-in campaign created",
|
||||
description: "Opt-in campaign created"
|
||||
})
|
||||
|
||||
// Redirect to the newly created campaign.
|
||||
this.props.route.history.push({
|
||||
pathname: cs.Routes.ViewCampaign.replace(
|
||||
":id",
|
||||
resp.data.data.id
|
||||
)
|
||||
})
|
||||
})
|
||||
.catch(e => {
|
||||
notification["error"]({
|
||||
placement: cs.MsgPosition,
|
||||
message: "Error",
|
||||
description: e.message
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
handleHideForm = () => {
|
||||
this.setState({ formType: null })
|
||||
}
|
||||
|
||||
handleShowCreateForm = () => {
|
||||
this.setState({ formType: cs.FormCreate, record: {} })
|
||||
}
|
||||
|
||||
handleShowEditForm = record => {
|
||||
this.setState({ formType: cs.FormEdit, record: record })
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<section className="content">
|
||||
<Row>
|
||||
<Col xs={12} sm={18}>
|
||||
<h1>Lists ({this.props.data[cs.ModelLists].total}) </h1>
|
||||
</Col>
|
||||
<Col xs={12} sm={6} className="right">
|
||||
<Button
|
||||
type="primary"
|
||||
icon="plus"
|
||||
onClick={this.handleShowCreateForm}
|
||||
>
|
||||
Create list
|
||||
</Button>
|
||||
</Col>
|
||||
</Row>
|
||||
<br />
|
||||
|
||||
<Table
|
||||
className="lists"
|
||||
columns={this.columns}
|
||||
rowKey={record => record.uuid}
|
||||
dataSource={(() => {
|
||||
if (
|
||||
!this.props.data[cs.ModelLists] ||
|
||||
!this.props.data[cs.ModelLists].hasOwnProperty("results")
|
||||
) {
|
||||
return []
|
||||
}
|
||||
return this.props.data[cs.ModelLists].results
|
||||
})()}
|
||||
loading={this.props.reqStates[cs.ModelLists] !== cs.StateDone}
|
||||
pagination={{ ...this.paginationOptions, ...this.state.queryParams }}
|
||||
/>
|
||||
|
||||
<CreateForm
|
||||
{...this.props}
|
||||
formType={this.state.formType}
|
||||
record={this.state.record}
|
||||
onClose={this.handleHideForm}
|
||||
fetchRecords={this.fetchRecords}
|
||||
/>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default Lists
|
|
@ -1,176 +0,0 @@
|
|||
import React from "react"
|
||||
import {
|
||||
Row,
|
||||
Col,
|
||||
Form,
|
||||
Upload,
|
||||
Icon,
|
||||
Spin,
|
||||
Popconfirm,
|
||||
Tooltip,
|
||||
notification
|
||||
} from "antd"
|
||||
import * as cs from "./constants"
|
||||
|
||||
class TheFormDef extends React.PureComponent {
|
||||
state = {
|
||||
confirmDirty: false
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.props.pageTitle("Media")
|
||||
this.fetchRecords()
|
||||
}
|
||||
|
||||
fetchRecords = () => {
|
||||
this.props.modelRequest(cs.ModelMedia, cs.Routes.GetMedia, cs.MethodGet)
|
||||
}
|
||||
|
||||
handleDeleteRecord = record => {
|
||||
this.props
|
||||
.modelRequest(cs.ModelMedia, cs.Routes.DeleteMedia, cs.MethodDelete, {
|
||||
id: record.id
|
||||
})
|
||||
.then(() => {
|
||||
notification["success"]({
|
||||
placement: cs.MsgPosition,
|
||||
message: "Image deleted",
|
||||
description: `"${record.filename}" deleted`
|
||||
})
|
||||
|
||||
// Reload the table.
|
||||
this.fetchRecords()
|
||||
})
|
||||
.catch(e => {
|
||||
notification["error"]({
|
||||
placement: cs.MsgPosition,
|
||||
message: "Error",
|
||||
description: e.message
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
handleInsertMedia = record => {
|
||||
// The insertMedia callback may be passed down by the invoker (Campaign)
|
||||
if (!this.props.insertMedia) {
|
||||
return false
|
||||
}
|
||||
|
||||
this.props.insertMedia(record.uri)
|
||||
return false
|
||||
}
|
||||
|
||||
onFileChange = f => {
|
||||
if (
|
||||
f.file.error &&
|
||||
f.file.response &&
|
||||
f.file.response.hasOwnProperty("message")
|
||||
) {
|
||||
notification["error"]({
|
||||
placement: cs.MsgPosition,
|
||||
message: "Error uploading file",
|
||||
description: f.file.response.message
|
||||
})
|
||||
} else if (f.file.status === "done") {
|
||||
this.fetchRecords()
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
render() {
|
||||
const { getFieldDecorator } = this.props.form
|
||||
const formItemLayout = {
|
||||
labelCol: { xs: { span: 16 }, sm: { span: 4 } },
|
||||
wrapperCol: { xs: { span: 16 }, sm: { span: 10 } }
|
||||
}
|
||||
|
||||
return (
|
||||
<Spin spinning={false}>
|
||||
<Form>
|
||||
<Form.Item {...formItemLayout} label="Upload images">
|
||||
<div className="dropbox">
|
||||
{getFieldDecorator("file", {
|
||||
valuePropName: "file",
|
||||
getValueFromEvent: this.normFile,
|
||||
rules: [{ required: true }]
|
||||
})(
|
||||
<Upload.Dragger
|
||||
name="file"
|
||||
action="/api/media"
|
||||
multiple={true}
|
||||
listType="picture"
|
||||
onChange={this.onFileChange}
|
||||
accept=".gif, .jpg, .jpeg, .png"
|
||||
>
|
||||
<p className="ant-upload-drag-icon">
|
||||
<Icon type="inbox" />
|
||||
</p>
|
||||
<p className="ant-upload-text">Click or drag file here</p>
|
||||
</Upload.Dragger>
|
||||
)}
|
||||
</div>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
<section className="gallery">
|
||||
{this.props.media &&
|
||||
this.props.media.map((record, i) => (
|
||||
<div key={i} className="image">
|
||||
<a
|
||||
onClick={() => {
|
||||
this.handleInsertMedia(record)
|
||||
if (this.props.onCancel) {
|
||||
this.props.onCancel()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<img alt={record.filename} src={record.thumb_uri} />
|
||||
</a>
|
||||
<div className="actions">
|
||||
<Tooltip title="View" placement="bottom">
|
||||
<a role="button" href={record.uri} target="_blank">
|
||||
<Icon type="login" />
|
||||
</a>
|
||||
</Tooltip>
|
||||
<Popconfirm
|
||||
title="Are you sure?"
|
||||
onConfirm={() => this.handleDeleteRecord(record)}
|
||||
>
|
||||
<Tooltip title="Delete" placement="bottom">
|
||||
<a role="button">
|
||||
<Icon type="delete" />
|
||||
</a>
|
||||
</Tooltip>
|
||||
</Popconfirm>
|
||||
</div>
|
||||
<div className="name" title={record.filename}>
|
||||
{record.filename}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
</Spin>
|
||||
)
|
||||
}
|
||||
}
|
||||
const TheForm = Form.create()(TheFormDef)
|
||||
|
||||
class Media extends React.PureComponent {
|
||||
render() {
|
||||
return (
|
||||
<section className="content media">
|
||||
<Row>
|
||||
<Col span={22}>
|
||||
<h1>Images</h1>
|
||||
</Col>
|
||||
<Col span={2} />
|
||||
</Row>
|
||||
|
||||
<TheForm {...this.props} media={this.props.data[cs.ModelMedia]} />
|
||||
</section>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default Media
|
|
@ -1,75 +0,0 @@
|
|||
import React from "react"
|
||||
import { Modal } from "antd"
|
||||
import * as cs from "./constants"
|
||||
|
||||
import { Spin } from "antd"
|
||||
|
||||
class ModalPreview extends React.PureComponent {
|
||||
makeForm(body) {
|
||||
let form = document.createElement("form")
|
||||
form.method = cs.MethodPost
|
||||
form.action = this.props.previewURL
|
||||
form.target = "preview-iframe"
|
||||
|
||||
let input = document.createElement("input")
|
||||
input.type = "hidden"
|
||||
input.name = "body"
|
||||
input.value = body
|
||||
form.appendChild(input)
|
||||
document.body.appendChild(form)
|
||||
form.submit()
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<Modal
|
||||
visible={true}
|
||||
title={this.props.title}
|
||||
className="preview-modal"
|
||||
width="90%"
|
||||
height={900}
|
||||
onCancel={this.props.onCancel}
|
||||
onOk={this.props.onCancel}
|
||||
>
|
||||
<div className="preview-iframe-container">
|
||||
<Spin className="preview-iframe-spinner" />
|
||||
<iframe
|
||||
key="preview-iframe"
|
||||
onLoad={() => {
|
||||
// If state is used to manage the spinner, it causes
|
||||
// the iframe to re-render and reload everything.
|
||||
// Hack the spinner away from the DOM directly instead.
|
||||
let spin = document.querySelector(".preview-iframe-spinner")
|
||||
if (spin) {
|
||||
spin.parentNode.removeChild(spin)
|
||||
}
|
||||
// this.setState({ loading: false })
|
||||
}}
|
||||
title={this.props.title ? this.props.title : "Preview"}
|
||||
name="preview-iframe"
|
||||
id="preview-iframe"
|
||||
className="preview-iframe"
|
||||
ref={o => {
|
||||
if (!o) {
|
||||
return
|
||||
}
|
||||
|
||||
// When the DOM reference for the iframe is ready,
|
||||
// see if there's a body to post with the form hack.
|
||||
if (this.props.body !== undefined && this.props.body !== null) {
|
||||
this.makeForm(this.props.body)
|
||||
} else {
|
||||
if (this.props.previewURL) {
|
||||
o.src = this.props.previewURL
|
||||
}
|
||||
}
|
||||
}}
|
||||
src="about:blank"
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default ModalPreview
|
|
@ -1,458 +0,0 @@
|
|||
import React from "react"
|
||||
import { Link } from "react-router-dom"
|
||||
import {
|
||||
Row,
|
||||
Col,
|
||||
Form,
|
||||
Input,
|
||||
Select,
|
||||
Button,
|
||||
Tag,
|
||||
Tooltip,
|
||||
Icon,
|
||||
Spin,
|
||||
Popconfirm,
|
||||
notification
|
||||
} from "antd"
|
||||
|
||||
import * as cs from "./constants"
|
||||
|
||||
const tagColors = {
|
||||
enabled: "green",
|
||||
blacklisted: "red"
|
||||
}
|
||||
const formItemLayoutModal = {
|
||||
labelCol: { xs: { span: 24 }, sm: { span: 4 } },
|
||||
wrapperCol: { xs: { span: 24 }, sm: { span: 18 } }
|
||||
}
|
||||
const formItemLayout = {
|
||||
labelCol: { xs: { span: 16 }, sm: { span: 4 } },
|
||||
wrapperCol: { xs: { span: 16 }, sm: { span: 10 } }
|
||||
}
|
||||
const formItemTailLayout = {
|
||||
wrapperCol: { xs: { span: 24, offset: 0 }, sm: { span: 10, offset: 4 } }
|
||||
}
|
||||
|
||||
class CreateFormDef extends React.PureComponent {
|
||||
state = {
|
||||
confirmDirty: false,
|
||||
loading: false
|
||||
}
|
||||
|
||||
// Handle create / edit form submission.
|
||||
handleSubmit = (e, cb) => {
|
||||
e.preventDefault()
|
||||
if (!cb) {
|
||||
// Set a fake callback.
|
||||
cb = () => {}
|
||||
}
|
||||
|
||||
var err = null,
|
||||
values = {}
|
||||
this.props.form.validateFields((e, v) => {
|
||||
err = e
|
||||
values = v
|
||||
})
|
||||
if (err) {
|
||||
return
|
||||
}
|
||||
|
||||
let a = values["attribs"]
|
||||
values["attribs"] = {}
|
||||
if (a && a.length > 0) {
|
||||
try {
|
||||
values["attribs"] = JSON.parse(a)
|
||||
if (values["attribs"] instanceof Array) {
|
||||
notification["error"]({
|
||||
message: "Invalid JSON type",
|
||||
description: "Attributes should be a map {} and not an array []"
|
||||
})
|
||||
return
|
||||
}
|
||||
} catch (e) {
|
||||
notification["error"]({
|
||||
message: "Invalid JSON in attributes",
|
||||
description: e.toString()
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
this.setState({ loading: true })
|
||||
if (this.props.formType === cs.FormCreate) {
|
||||
// Add a subscriber.
|
||||
this.props
|
||||
.modelRequest(
|
||||
cs.ModelSubscribers,
|
||||
cs.Routes.CreateSubscriber,
|
||||
cs.MethodPost,
|
||||
values
|
||||
)
|
||||
.then(() => {
|
||||
notification["success"]({
|
||||
message: "Subscriber added",
|
||||
description: `${values["email"]} added`
|
||||
})
|
||||
if (!this.props.isModal) {
|
||||
this.props.fetchRecord(this.props.record.id)
|
||||
}
|
||||
cb(true)
|
||||
this.setState({ loading: false })
|
||||
})
|
||||
.catch(e => {
|
||||
notification["error"]({ message: "Error", description: e.message })
|
||||
cb(false)
|
||||
this.setState({ loading: false })
|
||||
})
|
||||
} else {
|
||||
// Edit a subscriber.
|
||||
delete values["keys"]
|
||||
delete values["vals"]
|
||||
this.props
|
||||
.modelRequest(
|
||||
cs.ModelSubscribers,
|
||||
cs.Routes.UpdateSubscriber,
|
||||
cs.MethodPut,
|
||||
{ ...values, id: this.props.record.id }
|
||||
)
|
||||
.then(resp => {
|
||||
notification["success"]({
|
||||
message: "Subscriber modified",
|
||||
description: `${values["email"]} modified`
|
||||
})
|
||||
if (!this.props.isModal) {
|
||||
this.props.fetchRecord(this.props.record.id)
|
||||
}
|
||||
cb(true)
|
||||
this.setState({ loading: false })
|
||||
})
|
||||
.catch(e => {
|
||||
notification["error"]({ message: "Error", description: e.message })
|
||||
cb(false)
|
||||
this.setState({ loading: false })
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
handleDeleteRecord = record => {
|
||||
this.props
|
||||
.modelRequest(
|
||||
cs.ModelSubscribers,
|
||||
cs.Routes.DeleteSubscriber,
|
||||
cs.MethodDelete,
|
||||
{ id: record.id }
|
||||
)
|
||||
.then(() => {
|
||||
notification["success"]({
|
||||
message: "Subscriber deleted",
|
||||
description: `${record.email} deleted`
|
||||
})
|
||||
|
||||
this.props.route.history.push({
|
||||
pathname: cs.Routes.ViewSubscribers
|
||||
})
|
||||
})
|
||||
.catch(e => {
|
||||
notification["error"]({ message: "Error", description: e.message })
|
||||
})
|
||||
}
|
||||
|
||||
handleSendOptinMail = record => {
|
||||
this.props
|
||||
.request(cs.Routes.SendSubscriberOptinMail, cs.MethodPost, {
|
||||
id: record.id
|
||||
})
|
||||
.then(r => {
|
||||
notification["success"]({
|
||||
placement: cs.MsgPosition,
|
||||
message: "Sent",
|
||||
description: `Opt-in e-mail sentto ${record.email}`
|
||||
})
|
||||
})
|
||||
.catch(e => {
|
||||
notification["error"]({
|
||||
placement: cs.MsgPosition,
|
||||
message: "Error",
|
||||
description: e.message
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
render() {
|
||||
const { formType, record } = this.props
|
||||
const { getFieldDecorator } = this.props.form
|
||||
|
||||
if (formType === null) {
|
||||
return null
|
||||
}
|
||||
|
||||
let subListIDs = []
|
||||
let subStatuses = {}
|
||||
if (this.props.record && this.props.record.lists) {
|
||||
subListIDs = this.props.record.lists.map(v => {
|
||||
return v["id"]
|
||||
})
|
||||
subStatuses = this.props.record.lists.reduce(
|
||||
(o, item) => ({ ...o, [item.id]: item.subscription_status }),
|
||||
{}
|
||||
)
|
||||
} else if (this.props.list) {
|
||||
subListIDs = [this.props.list.id]
|
||||
}
|
||||
|
||||
const layout = this.props.isModal ? formItemLayoutModal : formItemLayout
|
||||
return (
|
||||
<Spin spinning={this.state.loading}>
|
||||
<Form onSubmit={this.handleSubmit}>
|
||||
<Form.Item {...layout} label="E-mail">
|
||||
{getFieldDecorator("email", {
|
||||
initialValue: record.email,
|
||||
rules: [{ required: true }]
|
||||
})(<Input autoFocus pattern="(.+?)@(.+?)" maxLength={200} />)}
|
||||
</Form.Item>
|
||||
<Form.Item {...layout} label="Name">
|
||||
{getFieldDecorator("name", {
|
||||
initialValue: record.name,
|
||||
rules: [{ required: true }]
|
||||
})(<Input maxLength={200} />)}
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
{...layout}
|
||||
name="status"
|
||||
label="Status"
|
||||
extra="Blacklisted users will not receive any e-mails ever"
|
||||
>
|
||||
{getFieldDecorator("status", {
|
||||
initialValue: record.status ? record.status : "enabled",
|
||||
rules: [{ required: true, message: "Type is required" }]
|
||||
})(
|
||||
<Select style={{ maxWidth: 120 }}>
|
||||
<Select.Option value="enabled">Enabled</Select.Option>
|
||||
<Select.Option value="blacklisted">Blacklisted</Select.Option>
|
||||
</Select>
|
||||
)}
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
{...layout}
|
||||
label="Lists"
|
||||
extra="Lists to subscribe to. Lists from which subscribers have unsubscribed themselves cannot be removed."
|
||||
>
|
||||
{getFieldDecorator("lists", { initialValue: subListIDs })(
|
||||
<Select mode="multiple">
|
||||
{[...this.props.lists].map((v, i) => (
|
||||
<Select.Option
|
||||
value={v.id}
|
||||
key={v.id}
|
||||
disabled={
|
||||
subStatuses[v.id] === cs.SubscriptionStatusUnsubscribed
|
||||
}
|
||||
>
|
||||
<span>
|
||||
{v.name}
|
||||
{subStatuses[v.id] && (
|
||||
<sup
|
||||
className={"subscription-status " + subStatuses[v.id]}
|
||||
>
|
||||
{" "}
|
||||
{subStatuses[v.id]}
|
||||
</sup>
|
||||
)}
|
||||
</span>
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
)}
|
||||
{record.lists &&
|
||||
record.lists.some(l => {
|
||||
return (
|
||||
l.subscription_status === cs.SubscriptionStatusUnConfirmed
|
||||
)
|
||||
}) && (
|
||||
<Tooltip title="Send an opt-in e-mail to the subscriber to confirm subscriptions">
|
||||
<Link
|
||||
onClick={e => {
|
||||
e.preventDefault()
|
||||
this.handleSendOptinMail(record)
|
||||
}}
|
||||
to={`/`}
|
||||
>
|
||||
<Icon type="rocket" /> Send opt-in e-mail
|
||||
</Link>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Form.Item>
|
||||
<Form.Item {...layout} label="Attributes" colon={false}>
|
||||
<div>
|
||||
{getFieldDecorator("attribs", {
|
||||
initialValue: record.attribs
|
||||
? JSON.stringify(record.attribs, null, 4)
|
||||
: ""
|
||||
})(
|
||||
<Input.TextArea
|
||||
placeholder="{}"
|
||||
rows={10}
|
||||
readOnly={false}
|
||||
autosize={{ minRows: 5, maxRows: 10 }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<p className="ant-form-extra">
|
||||
Attributes are defined as a JSON map, for example:
|
||||
{' {"age": 30, "color": "red", "is_user": true}'}.{" "}
|
||||
<a
|
||||
href="https://listmonk.app/docs/concepts"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
More info
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</Form.Item>
|
||||
{!this.props.isModal && (
|
||||
<Form.Item {...formItemTailLayout}>
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
icon={this.props.formType === cs.FormCreate ? "plus" : "save"}
|
||||
>
|
||||
{this.props.formType === cs.FormCreate ? "Add" : "Save"}
|
||||
</Button>{" "}
|
||||
{this.props.formType === cs.FormEdit && (
|
||||
<Popconfirm
|
||||
title="Are you sure?"
|
||||
onConfirm={() => {
|
||||
this.handleDeleteRecord(record)
|
||||
}}
|
||||
>
|
||||
<Button icon="delete">Delete</Button>
|
||||
</Popconfirm>
|
||||
)}
|
||||
</Form.Item>
|
||||
)}
|
||||
</Form>
|
||||
</Spin>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const CreateForm = Form.create()(CreateFormDef)
|
||||
|
||||
class Subscriber extends React.PureComponent {
|
||||
state = {
|
||||
loading: true,
|
||||
formRef: null,
|
||||
record: {},
|
||||
subID: this.props.route.match.params
|
||||
? parseInt(this.props.route.match.params.subID, 10)
|
||||
: 0
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
// When this component is invoked within a modal from the subscribers list page,
|
||||
// the necessary context is supplied and there's no need to fetch anything.
|
||||
if (!this.props.isModal) {
|
||||
// Fetch lists.
|
||||
this.props.modelRequest(cs.ModelLists, cs.Routes.GetLists, cs.MethodGet)
|
||||
|
||||
// Fetch subscriber.
|
||||
this.fetchRecord(this.state.subID)
|
||||
} else {
|
||||
this.setState({ record: this.props.record, loading: false })
|
||||
}
|
||||
}
|
||||
|
||||
fetchRecord = id => {
|
||||
this.props
|
||||
.request(cs.Routes.GetSubscriber, cs.MethodGet, { id: id })
|
||||
.then(r => {
|
||||
this.setState({ record: r.data.data, loading: false })
|
||||
})
|
||||
.catch(e => {
|
||||
notification["error"]({
|
||||
placement: cs.MsgPosition,
|
||||
message: "Error",
|
||||
description: e.message
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
setFormRef = r => {
|
||||
this.setState({ formRef: r })
|
||||
}
|
||||
|
||||
submitForm = (e, cb) => {
|
||||
if (this.state.formRef) {
|
||||
this.state.formRef.handleSubmit(e, cb)
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<section className="content">
|
||||
<header className="header">
|
||||
<Row>
|
||||
<Col span={20}>
|
||||
{!this.state.record.id && <h1>Add subscriber</h1>}
|
||||
{this.state.record.id && (
|
||||
<div>
|
||||
<h1>
|
||||
<Tag
|
||||
className="subscriber-status"
|
||||
color={
|
||||
tagColors.hasOwnProperty(this.state.record.status)
|
||||
? tagColors[this.state.record.status]
|
||||
: ""
|
||||
}
|
||||
>
|
||||
{this.state.record.status}
|
||||
</Tag>{" "}
|
||||
<span className="subscriber-name">
|
||||
{this.state.record.name} ({this.state.record.email})
|
||||
</span>
|
||||
</h1>
|
||||
<span className="text-small text-grey">
|
||||
ID {this.state.record.id} / UUID {this.state.record.uuid}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</Col>
|
||||
<Col span={4} className="right subscriber-export">
|
||||
<Tooltip title="Export subscriber data" placement="top">
|
||||
<a
|
||||
role="button"
|
||||
href={"/api/subscribers/" + this.state.record.id + "/export"}
|
||||
>
|
||||
Export <Icon type="export" />
|
||||
</a>
|
||||
</Tooltip>
|
||||
</Col>
|
||||
</Row>
|
||||
</header>
|
||||
<div>
|
||||
<Spin spinning={this.state.loading}>
|
||||
<CreateForm
|
||||
{...this.props}
|
||||
formType={this.props.formType ? this.props.formType : cs.FormEdit}
|
||||
record={this.state.record}
|
||||
fetchRecord={this.fetchRecord}
|
||||
lists={this.props.data[cs.ModelLists].results}
|
||||
wrappedComponentRef={r => {
|
||||
if (!r) {
|
||||
return
|
||||
}
|
||||
|
||||
// Save the form's reference so that when this component
|
||||
// is used as a modal, the invoker of the model can submit
|
||||
// it via submitForm()
|
||||
this.setState({ formRef: r })
|
||||
}}
|
||||
/>
|
||||
</Spin>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default Subscriber
|
|
@ -1,850 +0,0 @@
|
|||
import React from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import {
|
||||
Row,
|
||||
Col,
|
||||
Modal,
|
||||
Form,
|
||||
Input,
|
||||
Select,
|
||||
Button,
|
||||
Table,
|
||||
Icon,
|
||||
Tooltip,
|
||||
Tag,
|
||||
Popconfirm,
|
||||
notification,
|
||||
Radio
|
||||
} from "antd";
|
||||
|
||||
import Utils from "./utils";
|
||||
import Subscriber from "./Subscriber";
|
||||
import * as cs from "./constants";
|
||||
|
||||
const tagColors = {
|
||||
enabled: "green",
|
||||
blacklisted: "red"
|
||||
};
|
||||
|
||||
class ListsFormDef extends React.PureComponent {
|
||||
state = {
|
||||
modalWaiting: false
|
||||
};
|
||||
|
||||
// Handle create / edit form submission.
|
||||
handleSubmit = e => {
|
||||
e.preventDefault();
|
||||
|
||||
var err = null,
|
||||
values = {};
|
||||
this.props.form.validateFields((e, v) => {
|
||||
err = e;
|
||||
values = v;
|
||||
});
|
||||
if (err) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.props.allRowsSelected) {
|
||||
values["list_ids"] = this.props.listIDs;
|
||||
values["query"] = this.props.query;
|
||||
} else {
|
||||
values["ids"] = this.props.selectedRows.map(r => r.id);
|
||||
}
|
||||
|
||||
this.setState({ modalWaiting: true });
|
||||
this.props
|
||||
.request(
|
||||
!this.props.allRowsSelected
|
||||
? cs.Routes.AddSubscribersToLists
|
||||
: cs.Routes.AddSubscribersToListsByQuery,
|
||||
cs.MethodPut,
|
||||
values
|
||||
)
|
||||
.then(() => {
|
||||
notification["success"]({
|
||||
message: "Lists changed",
|
||||
description: `Lists changed for selected subscribers`
|
||||
});
|
||||
this.props.clearSelectedRows();
|
||||
this.props.fetchRecords();
|
||||
this.setState({ modalWaiting: false });
|
||||
this.props.onClose();
|
||||
})
|
||||
.catch(e => {
|
||||
notification["error"]({ message: "Error", description: e.message });
|
||||
this.setState({ modalWaiting: false });
|
||||
});
|
||||
};
|
||||
|
||||
render() {
|
||||
const { getFieldDecorator } = this.props.form;
|
||||
const formItemLayout = {
|
||||
labelCol: { xs: { span: 16 }, sm: { span: 4 } },
|
||||
wrapperCol: { xs: { span: 16 }, sm: { span: 18 } }
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={true}
|
||||
width="750px"
|
||||
className="subscriber-lists-modal"
|
||||
title="Manage lists"
|
||||
okText="Ok"
|
||||
confirmLoading={this.state.modalWaiting}
|
||||
onCancel={this.props.onClose}
|
||||
onOk={this.handleSubmit}
|
||||
>
|
||||
<Form onSubmit={this.handleSubmit}>
|
||||
<Form.Item {...formItemLayout} label="Action">
|
||||
{getFieldDecorator("action", {
|
||||
initialValue: "add",
|
||||
rules: [{ required: true }]
|
||||
})(
|
||||
<Radio.Group>
|
||||
<Radio value="add">Add</Radio>
|
||||
<Radio value="remove">Remove</Radio>
|
||||
<Radio value="unsubscribe">Mark as unsubscribed</Radio>
|
||||
</Radio.Group>
|
||||
)}
|
||||
</Form.Item>
|
||||
<Form.Item {...formItemLayout} label="Lists">
|
||||
{getFieldDecorator("target_list_ids", {
|
||||
rules: [{ required: true }]
|
||||
})(
|
||||
<Select mode="multiple">
|
||||
{[...this.props.lists].map((v, i) => (
|
||||
<Select.Option value={v.id} key={v.id}>
|
||||
{v.name}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
)}
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const ListsForm = Form.create()(ListsFormDef);
|
||||
|
||||
class Subscribers extends React.PureComponent {
|
||||
defaultPerPage = 20;
|
||||
|
||||
state = {
|
||||
formType: null,
|
||||
listsFormVisible: false,
|
||||
modalForm: null,
|
||||
record: {},
|
||||
queryParams: {
|
||||
page: 1,
|
||||
total: 0,
|
||||
perPage: this.defaultPerPage,
|
||||
listID: this.props.route.match.params.listID
|
||||
? parseInt(this.props.route.match.params.listID, 10)
|
||||
: 0,
|
||||
list: null,
|
||||
query: null,
|
||||
targetLists: []
|
||||
},
|
||||
listModalVisible: false,
|
||||
allRowsSelected: false,
|
||||
selectedRows: []
|
||||
};
|
||||
|
||||
// Pagination config.
|
||||
paginationOptions = {
|
||||
hideOnSinglePage: true,
|
||||
showSizeChanger: true,
|
||||
showQuickJumper: true,
|
||||
defaultPageSize: this.defaultPerPage,
|
||||
pageSizeOptions: ["20", "50", "70", "100"],
|
||||
position: "both",
|
||||
showTotal: (total, range) => `${range[0]} to ${range[1]} of ${total}`,
|
||||
onChange: (page, perPage) => {
|
||||
this.fetchRecords({ page: page, per_page: perPage });
|
||||
},
|
||||
onShowSizeChange: (page, perPage) => {
|
||||
this.fetchRecords({ page: page, per_page: perPage });
|
||||
}
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
// Table layout.
|
||||
this.columns = [
|
||||
{
|
||||
title: "E-mail",
|
||||
dataIndex: "email",
|
||||
sorter: true,
|
||||
width: "25%",
|
||||
render: (text, record) => {
|
||||
const out = [];
|
||||
out.push(
|
||||
<div key={`sub-email-${record.id}`} className="sub-name">
|
||||
<Link
|
||||
to={`/subscribers/${record.id}`}
|
||||
onClick={e => {
|
||||
// Open the individual subscriber page on ctrl+click
|
||||
// and the modal otherwise.
|
||||
if (!e.ctrlKey) {
|
||||
this.handleShowEditForm(record);
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (record.lists.length > 0) {
|
||||
for (let i = 0; i < record.lists.length; i++) {
|
||||
out.push(
|
||||
<Tag
|
||||
className="list"
|
||||
key={`sub-${record.id}-list-${record.lists[i].id}`}
|
||||
>
|
||||
<Link to={`/subscribers/lists/${record.lists[i].id}`}>
|
||||
{record.lists[i].name}
|
||||
</Link>
|
||||
<sup
|
||||
className={
|
||||
"subscription-status " +
|
||||
record.lists[i].subscription_status
|
||||
}
|
||||
>
|
||||
{" "}
|
||||
{record.lists[i].subscription_status}
|
||||
</sup>
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
},
|
||||
{
|
||||
title: "Name",
|
||||
dataIndex: "name",
|
||||
sorter: true,
|
||||
width: "15%",
|
||||
render: (text, record) => {
|
||||
return (
|
||||
<Link
|
||||
to={`/subscribers/${record.id}`}
|
||||
onClick={e => {
|
||||
// Open the individual subscriber page on ctrl+click
|
||||
// and the modal otherwise.
|
||||
if (!e.ctrlKey) {
|
||||
this.handleShowEditForm(record);
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
title: "Status",
|
||||
dataIndex: "status",
|
||||
width: "5%",
|
||||
render: (status, _) => {
|
||||
return (
|
||||
<Tag
|
||||
color={tagColors.hasOwnProperty(status) ? tagColors[status] : ""}
|
||||
>
|
||||
{status}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
title: "Lists",
|
||||
dataIndex: "lists",
|
||||
width: "10%",
|
||||
align: "center",
|
||||
render: (lists, _) => {
|
||||
return (
|
||||
<span>
|
||||
{lists.reduce(
|
||||
(def, item) =>
|
||||
def +
|
||||
(item.subscription_status !==
|
||||
cs.SubscriptionStatusUnsubscribed
|
||||
? 1
|
||||
: 0),
|
||||
0
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
title: "Created",
|
||||
width: "10%",
|
||||
dataIndex: "created_at",
|
||||
render: (date, _) => {
|
||||
return Utils.DateString(date);
|
||||
}
|
||||
},
|
||||
{
|
||||
title: "Updated",
|
||||
width: "10%",
|
||||
dataIndex: "updated_at",
|
||||
render: (date, _) => {
|
||||
return Utils.DateString(date);
|
||||
}
|
||||
},
|
||||
{
|
||||
title: "",
|
||||
dataIndex: "actions",
|
||||
width: "10%",
|
||||
render: (text, record) => {
|
||||
return (
|
||||
<div className="actions">
|
||||
{/* <Tooltip title="Send an e-mail"><a role="button"><Icon type="rocket" /></a></Tooltip> */}
|
||||
<Tooltip title="Edit subscriber">
|
||||
<a
|
||||
role="button"
|
||||
onClick={() => this.handleShowEditForm(record)}
|
||||
>
|
||||
<Icon type="edit" />
|
||||
</a>
|
||||
</Tooltip>
|
||||
<Popconfirm
|
||||
title="Are you sure?"
|
||||
onConfirm={() => this.handleDeleteRecord(record)}
|
||||
>
|
||||
<Tooltip title="Delete subscriber" placement="bottom">
|
||||
<a role="button">
|
||||
<Icon type="delete" />
|
||||
</a>
|
||||
</Tooltip>
|
||||
</Popconfirm>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
// Load lists on boot.
|
||||
this.props
|
||||
.modelRequest(cs.ModelLists, cs.Routes.GetLists, cs.MethodGet)
|
||||
.then(() => {
|
||||
// If this is an individual list's view, pick up that list.
|
||||
if (this.state.queryParams.listID) {
|
||||
this.props.data[cs.ModelLists].results.forEach(l => {
|
||||
if (l.id === this.state.queryParams.listID) {
|
||||
this.setState({
|
||||
queryParams: { ...this.state.queryParams, list: l }
|
||||
});
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
this.fetchRecords();
|
||||
}
|
||||
|
||||
fetchRecords = params => {
|
||||
let qParams = {
|
||||
page: this.state.queryParams.page,
|
||||
per_page: this.state.queryParams.per_page,
|
||||
list_id: this.state.queryParams.listID,
|
||||
query: this.state.queryParams.query
|
||||
};
|
||||
|
||||
// The records are for a specific list.
|
||||
if (this.state.queryParams.listID) {
|
||||
qParams.list_id = this.state.queryParams.listID;
|
||||
}
|
||||
|
||||
if (params) {
|
||||
qParams = { ...qParams, ...params };
|
||||
}
|
||||
|
||||
this.props
|
||||
.modelRequest(
|
||||
cs.ModelSubscribers,
|
||||
cs.Routes.GetSubscribers,
|
||||
cs.MethodGet,
|
||||
qParams
|
||||
)
|
||||
.then(() => {
|
||||
this.setState({
|
||||
queryParams: {
|
||||
...this.state.queryParams,
|
||||
total: this.props.data[cs.ModelSubscribers].total,
|
||||
perPage: this.props.data[cs.ModelSubscribers].per_page,
|
||||
page: this.props.data[cs.ModelSubscribers].page,
|
||||
query: this.props.data[cs.ModelSubscribers].query
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
handleDeleteRecord = record => {
|
||||
this.props
|
||||
.modelRequest(
|
||||
cs.ModelSubscribers,
|
||||
cs.Routes.DeleteSubscriber,
|
||||
cs.MethodDelete,
|
||||
{ id: record.id }
|
||||
)
|
||||
.then(() => {
|
||||
notification["success"]({
|
||||
message: "Subscriber deleted",
|
||||
description: `${record.email} deleted`
|
||||
});
|
||||
|
||||
// Reload the table.
|
||||
this.fetchRecords();
|
||||
})
|
||||
.catch(e => {
|
||||
notification["error"]({ message: "Error", description: e.message });
|
||||
});
|
||||
};
|
||||
|
||||
handleDeleteRecords = records => {
|
||||
this.props
|
||||
.modelRequest(
|
||||
cs.ModelSubscribers,
|
||||
cs.Routes.DeleteSubscribers,
|
||||
cs.MethodDelete,
|
||||
{ id: records.map(r => r.id) }
|
||||
)
|
||||
.then(() => {
|
||||
notification["success"]({
|
||||
message: "Subscriber(s) deleted",
|
||||
description: "Selected subscribers deleted"
|
||||
});
|
||||
|
||||
// Reload the table.
|
||||
this.fetchRecords();
|
||||
})
|
||||
.catch(e => {
|
||||
notification["error"]({ message: "Error", description: e.message });
|
||||
});
|
||||
};
|
||||
|
||||
handleBlacklistSubscribers = records => {
|
||||
this.props
|
||||
.request(cs.Routes.BlacklistSubscribers, cs.MethodPut, {
|
||||
ids: records.map(r => r.id)
|
||||
})
|
||||
.then(() => {
|
||||
notification["success"]({
|
||||
message: "Subscriber(s) blacklisted",
|
||||
description: "Selected subscribers blacklisted"
|
||||
});
|
||||
|
||||
// Reload the table.
|
||||
this.fetchRecords();
|
||||
})
|
||||
.catch(e => {
|
||||
notification["error"]({ message: "Error", description: e.message });
|
||||
});
|
||||
};
|
||||
|
||||
// Arbitrary query based calls.
|
||||
handleDeleteRecordsByQuery = (listIDs, query) => {
|
||||
this.props
|
||||
.modelRequest(
|
||||
cs.ModelSubscribers,
|
||||
cs.Routes.DeleteSubscribersByQuery,
|
||||
cs.MethodPost,
|
||||
{ list_ids: listIDs, query: query }
|
||||
)
|
||||
.then(() => {
|
||||
notification["success"]({
|
||||
message: "Subscriber(s) deleted",
|
||||
description: "Selected subscribers have been deleted"
|
||||
});
|
||||
|
||||
// Reload the table.
|
||||
this.fetchRecords();
|
||||
})
|
||||
.catch(e => {
|
||||
notification["error"]({ message: "Error", description: e.message });
|
||||
});
|
||||
};
|
||||
|
||||
handleBlacklistSubscribersByQuery = (listIDs, query) => {
|
||||
this.props
|
||||
.request(cs.Routes.BlacklistSubscribersByQuery, cs.MethodPut, {
|
||||
list_ids: listIDs,
|
||||
query: query
|
||||
})
|
||||
.then(() => {
|
||||
notification["success"]({
|
||||
message: "Subscriber(s) blacklisted",
|
||||
description: "Selected subscribers have been blacklisted"
|
||||
});
|
||||
|
||||
// Reload the table.
|
||||
this.fetchRecords();
|
||||
})
|
||||
.catch(e => {
|
||||
notification["error"]({ message: "Error", description: e.message });
|
||||
});
|
||||
};
|
||||
|
||||
handleQuerySubscribersIntoLists = (query, sourceList, targetLists) => {
|
||||
let params = {
|
||||
query: query,
|
||||
source_list: sourceList,
|
||||
target_lists: targetLists
|
||||
};
|
||||
|
||||
this.props
|
||||
.request(cs.Routes.QuerySubscribersIntoLists, cs.MethodPost, params)
|
||||
.then(res => {
|
||||
notification["success"]({
|
||||
message: "Subscriber(s) added",
|
||||
description: `${res.data.data.count} added`
|
||||
});
|
||||
this.handleToggleListModal();
|
||||
})
|
||||
.catch(e => {
|
||||
notification["error"]({ message: "Error", description: e.message });
|
||||
});
|
||||
};
|
||||
|
||||
handleHideForm = () => {
|
||||
this.setState({ formType: null });
|
||||
};
|
||||
|
||||
handleShowCreateForm = () => {
|
||||
this.setState({ formType: cs.FormCreate, attribs: [], record: {} });
|
||||
};
|
||||
|
||||
handleShowEditForm = record => {
|
||||
this.setState({ formType: cs.FormEdit, record: record });
|
||||
};
|
||||
|
||||
handleToggleListsForm = () => {
|
||||
this.setState({ listsFormVisible: !this.state.listsFormVisible });
|
||||
};
|
||||
|
||||
handleSearch = q => {
|
||||
q = q.trim().toLowerCase();
|
||||
if (q === "") {
|
||||
this.fetchRecords({ query: null });
|
||||
return;
|
||||
}
|
||||
|
||||
q = q.replace(/'/g, "''");
|
||||
const query = `(name ~* '${q}' OR email ~* '${q}')`;
|
||||
this.fetchRecords({ query: query });
|
||||
};
|
||||
|
||||
handleSelectRow = (_, records) => {
|
||||
this.setState({ allRowsSelected: false, selectedRows: records });
|
||||
};
|
||||
|
||||
handleSelectAllRows = () => {
|
||||
this.setState({
|
||||
allRowsSelected: true,
|
||||
selectedRows: this.props.data[cs.ModelSubscribers].results
|
||||
});
|
||||
};
|
||||
|
||||
clearSelectedRows = (_, records) => {
|
||||
this.setState({ allRowsSelected: false, selectedRows: [] });
|
||||
};
|
||||
|
||||
handleToggleQueryForm = () => {
|
||||
this.setState({ queryFormVisible: !this.state.queryFormVisible });
|
||||
};
|
||||
|
||||
handleToggleListModal = () => {
|
||||
this.setState({ listModalVisible: !this.state.listModalVisible });
|
||||
};
|
||||
|
||||
render() {
|
||||
const pagination = {
|
||||
...this.paginationOptions,
|
||||
...this.state.queryParams
|
||||
};
|
||||
|
||||
if (this.state.queryParams.list) {
|
||||
this.props.pageTitle(this.state.queryParams.list.name + " / Subscribers");
|
||||
} else {
|
||||
this.props.pageTitle("Subscribers");
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="content subscribers">
|
||||
<header className="header">
|
||||
<Row>
|
||||
<Col xs={24} sm={14}>
|
||||
<h1>
|
||||
Subscribers
|
||||
{this.props.data[cs.ModelSubscribers].total > 0 && (
|
||||
<span> ({this.props.data[cs.ModelSubscribers].total})</span>
|
||||
)}
|
||||
{this.state.queryParams.list && (
|
||||
<span> » {this.state.queryParams.list.name}</span>
|
||||
)}
|
||||
</h1>
|
||||
</Col>
|
||||
<Col xs={24} sm={10} className="right header-action-break">
|
||||
<Button
|
||||
type="primary"
|
||||
icon="plus"
|
||||
onClick={this.handleShowCreateForm}
|
||||
>
|
||||
Add subscriber
|
||||
</Button>
|
||||
</Col>
|
||||
</Row>
|
||||
</header>
|
||||
|
||||
<div className="subscriber-query">
|
||||
<Row>
|
||||
<Col sm={24} md={10}>
|
||||
<Row>
|
||||
<Row>
|
||||
<label>Search subscribers</label>
|
||||
<Input.Search
|
||||
name="name"
|
||||
placeholder="Name or e-mail"
|
||||
enterButton
|
||||
onSearch={this.handleSearch}
|
||||
/>{" "}
|
||||
</Row>
|
||||
<Row style={{ marginTop: "10px" }}>
|
||||
<a role="button" onClick={this.handleToggleQueryForm}>
|
||||
<Icon type="setting" /> Advanced
|
||||
</a>
|
||||
</Row>
|
||||
</Row>
|
||||
{this.state.queryFormVisible && (
|
||||
<div className="advanced-query">
|
||||
<p>
|
||||
<label>Advanced query</label>
|
||||
<Input.TextArea
|
||||
placeholder="subscribers.name LIKE '%user%' or subscribers.status='blacklisted'"
|
||||
id="subscriber-query"
|
||||
rows={10}
|
||||
onChange={e => {
|
||||
this.setState({
|
||||
queryParams: {
|
||||
...this.state.queryParams,
|
||||
query: e.target.value
|
||||
}
|
||||
});
|
||||
}}
|
||||
value={this.state.queryParams.query}
|
||||
autosize={{ minRows: 2, maxRows: 10 }}
|
||||
/>
|
||||
<span className="text-tiny text-small">
|
||||
Partial SQL expression to query subscriber attributes.{" "}
|
||||
<a
|
||||
href="https://listmonk.app/docs/querying-and-segmentation"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Learn more <Icon type="link" />.
|
||||
</a>
|
||||
</span>
|
||||
</p>
|
||||
<p>
|
||||
<Button
|
||||
disabled={this.state.queryParams.query === ""}
|
||||
type="primary"
|
||||
icon="search"
|
||||
onClick={() => {
|
||||
this.fetchRecords();
|
||||
}}
|
||||
>
|
||||
Query
|
||||
</Button>{" "}
|
||||
<Button
|
||||
disabled={this.state.queryParams.query === ""}
|
||||
icon="refresh"
|
||||
onClick={() => {
|
||||
this.fetchRecords({ query: null });
|
||||
}}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</Col>
|
||||
<Col sm={24} md={{ span: 12, offset: 2 }} className="slc-subs-section">
|
||||
{this.state.selectedRows.length > 0 && (
|
||||
<nav className="table-options">
|
||||
<p>
|
||||
<strong>
|
||||
{this.state.allRowsSelected
|
||||
? this.state.queryParams.total
|
||||
: this.state.selectedRows.length}
|
||||
</strong>{" "}
|
||||
subscriber(s) selected
|
||||
{!this.state.allRowsSelected &&
|
||||
this.state.queryParams.total >
|
||||
this.state.queryParams.perPage && (
|
||||
<span>
|
||||
{" "}
|
||||
—{" "}
|
||||
<a role="button" onClick={this.handleSelectAllRows}>
|
||||
Select all {this.state.queryParams.total}?
|
||||
</a>
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
<p class="slc-subs-actions">
|
||||
<a role="button" onClick={this.handleToggleListsForm}>
|
||||
<Icon type="bars" /> Manage lists
|
||||
</a>
|
||||
<a role="button">
|
||||
<Icon type="rocket" /> Send campaign
|
||||
</a>
|
||||
<Popconfirm
|
||||
title="Are you sure?"
|
||||
onConfirm={() => {
|
||||
if (this.state.allRowsSelected) {
|
||||
this.handleDeleteRecordsByQuery(
|
||||
this.state.queryParams.listID
|
||||
? [this.state.queryParams.listID]
|
||||
: [],
|
||||
this.state.queryParams.query
|
||||
);
|
||||
this.clearSelectedRows();
|
||||
} else {
|
||||
this.handleDeleteRecords(this.state.selectedRows);
|
||||
this.clearSelectedRows();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<a role="button">
|
||||
<Icon type="delete" /> Delete
|
||||
</a>
|
||||
</Popconfirm>
|
||||
<Popconfirm
|
||||
title="Are you sure?"
|
||||
onConfirm={() => {
|
||||
if (this.state.allRowsSelected) {
|
||||
this.handleBlacklistSubscribersByQuery(
|
||||
this.state.queryParams.listID
|
||||
? [this.state.queryParams.listID]
|
||||
: [],
|
||||
this.state.queryParams.query
|
||||
);
|
||||
this.clearSelectedRows();
|
||||
} else {
|
||||
this.handleBlacklistSubscribers(
|
||||
this.state.selectedRows
|
||||
);
|
||||
this.clearSelectedRows();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<a role="button">
|
||||
<Icon type="close" /> Blacklist
|
||||
</a>
|
||||
</Popconfirm>
|
||||
</p>
|
||||
</nav>
|
||||
)}
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
|
||||
<Table
|
||||
columns={this.columns}
|
||||
rowKey={record => `sub-${record.id}`}
|
||||
dataSource={(() => {
|
||||
if (
|
||||
!this.props.data[cs.ModelSubscribers] ||
|
||||
!this.props.data[cs.ModelSubscribers].hasOwnProperty("results")
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
return this.props.data[cs.ModelSubscribers].results;
|
||||
})()}
|
||||
loading={this.props.reqStates[cs.ModelSubscribers] !== cs.StateDone}
|
||||
pagination={pagination}
|
||||
rowSelection={{
|
||||
columnWidth: "5%",
|
||||
onChange: this.handleSelectRow,
|
||||
selectedRowKeys: this.state.selectedRows.map(r => `sub-${r.id}`)
|
||||
}}
|
||||
/>
|
||||
|
||||
{this.state.formType !== null && (
|
||||
<Modal
|
||||
visible={true}
|
||||
width="750px"
|
||||
className="subscriber-modal"
|
||||
okText={this.state.form === cs.FormCreate ? "Add" : "Save"}
|
||||
confirmLoading={this.state.modalWaiting}
|
||||
onOk={e => {
|
||||
if (!this.state.modalForm) {
|
||||
return;
|
||||
}
|
||||
|
||||
// This submits the form embedded in the Subscriber component.
|
||||
this.state.modalForm.submitForm(e, ok => {
|
||||
if (ok) {
|
||||
this.handleHideForm();
|
||||
this.fetchRecords();
|
||||
}
|
||||
});
|
||||
}}
|
||||
onCancel={this.handleHideForm}
|
||||
okButtonProps={{
|
||||
disabled:
|
||||
this.props.reqStates[cs.ModelSubscribers] === cs.StatePending
|
||||
}}
|
||||
>
|
||||
<Subscriber
|
||||
{...this.props}
|
||||
isModal={true}
|
||||
formType={this.state.formType}
|
||||
record={this.state.record}
|
||||
ref={r => {
|
||||
if (!r) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState({ modalForm: r });
|
||||
}}
|
||||
/>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{this.state.listsFormVisible && (
|
||||
<ListsForm
|
||||
{...this.props}
|
||||
lists={this.props.data[cs.ModelLists].results}
|
||||
allRowsSelected={this.state.allRowsSelected}
|
||||
selectedRows={this.state.selectedRows}
|
||||
selectedLists={
|
||||
this.state.queryParams.listID
|
||||
? [this.state.queryParams.listID]
|
||||
: []
|
||||
}
|
||||
clearSelectedRows={this.clearSelectedRows}
|
||||
query={this.state.queryParams.query}
|
||||
fetchRecords={this.fetchRecords}
|
||||
onClose={this.handleToggleListsForm}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default Subscribers;
|
|
@ -1,443 +0,0 @@
|
|||
import React from "react";
|
||||
import {
|
||||
Row,
|
||||
Col,
|
||||
Modal,
|
||||
Form,
|
||||
Input,
|
||||
Button,
|
||||
Table,
|
||||
Icon,
|
||||
Tooltip,
|
||||
Tag,
|
||||
Popconfirm,
|
||||
Spin,
|
||||
notification
|
||||
} from "antd";
|
||||
|
||||
import ModalPreview from "./ModalPreview";
|
||||
import Utils from "./utils";
|
||||
import * as cs from "./constants";
|
||||
|
||||
class CreateFormDef extends React.PureComponent {
|
||||
state = {
|
||||
confirmDirty: false,
|
||||
modalWaiting: false,
|
||||
previewName: "",
|
||||
previewBody: ""
|
||||
};
|
||||
|
||||
// Handle create / edit form submission.
|
||||
handleSubmit = e => {
|
||||
e.preventDefault();
|
||||
this.props.form.validateFields((err, values) => {
|
||||
if (err) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState({ modalWaiting: true });
|
||||
if (this.props.formType === cs.FormCreate) {
|
||||
// Create a new list.
|
||||
this.props
|
||||
.modelRequest(
|
||||
cs.ModelTemplates,
|
||||
cs.Routes.CreateTemplate,
|
||||
cs.MethodPost,
|
||||
values
|
||||
)
|
||||
.then(() => {
|
||||
notification["success"]({
|
||||
placement: cs.MsgPosition,
|
||||
message: "Template added",
|
||||
description: `"${values["name"]}" added`
|
||||
});
|
||||
this.props.fetchRecords();
|
||||
this.props.onClose();
|
||||
this.setState({ modalWaiting: false });
|
||||
})
|
||||
.catch(e => {
|
||||
notification["error"]({
|
||||
placement: cs.MsgPosition,
|
||||
message: "Error",
|
||||
description: e.message
|
||||
});
|
||||
this.setState({ modalWaiting: false });
|
||||
});
|
||||
} else {
|
||||
// Edit a list.
|
||||
this.props
|
||||
.modelRequest(
|
||||
cs.ModelTemplates,
|
||||
cs.Routes.UpdateTemplate,
|
||||
cs.MethodPut,
|
||||
{ ...values, id: this.props.record.id }
|
||||
)
|
||||
.then(() => {
|
||||
notification["success"]({
|
||||
placement: cs.MsgPosition,
|
||||
message: "Template updated",
|
||||
description: `"${values["name"]}" modified`
|
||||
});
|
||||
this.props.fetchRecords();
|
||||
this.props.onClose();
|
||||
this.setState({ modalWaiting: false });
|
||||
})
|
||||
.catch(e => {
|
||||
notification["error"]({
|
||||
placement: cs.MsgPosition,
|
||||
message: "Error",
|
||||
description: e.message
|
||||
});
|
||||
this.setState({ modalWaiting: false });
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
handleConfirmBlur = e => {
|
||||
const value = e.target.value;
|
||||
this.setState({ confirmDirty: this.state.confirmDirty || !!value });
|
||||
};
|
||||
|
||||
handlePreview = (name, body) => {
|
||||
this.setState({ previewName: name, previewBody: body });
|
||||
};
|
||||
|
||||
render() {
|
||||
const { formType, record, onClose } = this.props;
|
||||
const { getFieldDecorator } = this.props.form;
|
||||
|
||||
const formItemLayout = {
|
||||
labelCol: { xs: { span: 16 }, sm: { span: 4 } },
|
||||
wrapperCol: { xs: { span: 16 }, sm: { span: 18 } }
|
||||
};
|
||||
|
||||
if (formType === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Modal
|
||||
visible={true}
|
||||
title={formType === cs.FormCreate ? "Add template" : record.name}
|
||||
okText={this.state.form === cs.FormCreate ? "Add" : "Save"}
|
||||
width="90%"
|
||||
height={900}
|
||||
confirmLoading={this.state.modalWaiting}
|
||||
onCancel={onClose}
|
||||
onOk={this.handleSubmit}
|
||||
>
|
||||
<Spin
|
||||
spinning={
|
||||
this.props.reqStates[cs.ModelTemplates] === cs.StatePending
|
||||
}
|
||||
>
|
||||
<Form onSubmit={this.handleSubmit}>
|
||||
<Form.Item {...formItemLayout} label="Name">
|
||||
{getFieldDecorator("name", {
|
||||
initialValue: record.name,
|
||||
rules: [{ required: true }]
|
||||
})(<Input autoFocus maxLength={200} />)}
|
||||
</Form.Item>
|
||||
<Form.Item {...formItemLayout} name="body" label="Raw HTML">
|
||||
{getFieldDecorator("body", {
|
||||
initialValue: record.body ? record.body : "",
|
||||
rules: [{ required: true }]
|
||||
})(<Input.TextArea autosize={{ minRows: 10, maxRows: 30 }} />)}
|
||||
</Form.Item>
|
||||
{this.props.form.getFieldValue("body") !== "" && (
|
||||
<Form.Item {...formItemLayout} colon={false} label=" ">
|
||||
<Button
|
||||
icon="search"
|
||||
onClick={() =>
|
||||
this.handlePreview(
|
||||
this.props.form.getFieldValue("name"),
|
||||
this.props.form.getFieldValue("body")
|
||||
)
|
||||
}
|
||||
>
|
||||
Preview
|
||||
</Button>
|
||||
</Form.Item>
|
||||
)}
|
||||
</Form>
|
||||
</Spin>
|
||||
<Row>
|
||||
<Col span="4" />
|
||||
<Col span="18" className="text-grey text-small">
|
||||
The placeholder{" "}
|
||||
<code>
|
||||
{"{"}
|
||||
{"{"} template "content" . {"}"}
|
||||
{"}"}
|
||||
</code>{" "}
|
||||
should appear in the template.{" "}
|
||||
<a
|
||||
href="https://listmonk.app/docs/templating"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Learn more <Icon type="link" />.
|
||||
</a>
|
||||
.
|
||||
</Col>
|
||||
</Row>
|
||||
</Modal>
|
||||
|
||||
{this.state.previewBody && (
|
||||
<ModalPreview
|
||||
title={
|
||||
this.state.previewName
|
||||
? this.state.previewName
|
||||
: "Template preview"
|
||||
}
|
||||
previewURL={cs.Routes.PreviewNewTemplate}
|
||||
body={this.state.previewBody}
|
||||
onCancel={() => {
|
||||
this.setState({ previewBody: null, previewName: null });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const CreateForm = Form.create()(CreateFormDef);
|
||||
|
||||
class Templates extends React.PureComponent {
|
||||
state = {
|
||||
formType: null,
|
||||
record: {},
|
||||
previewRecord: null
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.columns = [
|
||||
{
|
||||
title: "Name",
|
||||
dataIndex: "name",
|
||||
sorter: true,
|
||||
width: "50%",
|
||||
render: (text, record) => {
|
||||
return (
|
||||
<div className="name">
|
||||
<a role="button" onClick={() => this.handleShowEditForm(record)}>
|
||||
{text}
|
||||
</a>
|
||||
{record.is_default && (
|
||||
<div>
|
||||
<Tag>Default</Tag>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
title: "Created",
|
||||
dataIndex: "created_at",
|
||||
render: (date, _) => {
|
||||
return Utils.DateString(date);
|
||||
}
|
||||
},
|
||||
{
|
||||
title: "Updated",
|
||||
dataIndex: "updated_at",
|
||||
render: (date, _) => {
|
||||
return Utils.DateString(date);
|
||||
}
|
||||
},
|
||||
{
|
||||
title: "",
|
||||
dataIndex: "actions",
|
||||
width: "20%",
|
||||
className: "actions",
|
||||
render: (text, record) => {
|
||||
return (
|
||||
<div className="actions">
|
||||
<Tooltip
|
||||
title="Preview template"
|
||||
onClick={() => this.handlePreview(record)}
|
||||
>
|
||||
<a role="button">
|
||||
<Icon type="search" />
|
||||
</a>
|
||||
</Tooltip>
|
||||
|
||||
{!record.is_default && (
|
||||
<Popconfirm
|
||||
title="Are you sure?"
|
||||
onConfirm={() => this.handleSetDefault(record)}
|
||||
>
|
||||
<Tooltip title="Set as default" placement="bottom">
|
||||
<a role="button">
|
||||
<Icon type="check" />
|
||||
</a>
|
||||
</Tooltip>
|
||||
</Popconfirm>
|
||||
)}
|
||||
|
||||
<Tooltip title="Edit template">
|
||||
<a
|
||||
role="button"
|
||||
onClick={() => this.handleShowEditForm(record)}
|
||||
>
|
||||
<Icon type="edit" />
|
||||
</a>
|
||||
</Tooltip>
|
||||
|
||||
{record.id !== 1 && (
|
||||
<Popconfirm
|
||||
title="Are you sure?"
|
||||
onConfirm={() => this.handleDeleteRecord(record)}
|
||||
>
|
||||
<Tooltip title="Delete template" placement="bottom">
|
||||
<a role="button">
|
||||
<Icon type="delete" />
|
||||
</a>
|
||||
</Tooltip>
|
||||
</Popconfirm>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.props.pageTitle("Templates");
|
||||
this.fetchRecords();
|
||||
}
|
||||
|
||||
fetchRecords = () => {
|
||||
this.props.modelRequest(
|
||||
cs.ModelTemplates,
|
||||
cs.Routes.GetTemplates,
|
||||
cs.MethodGet
|
||||
);
|
||||
};
|
||||
|
||||
handleDeleteRecord = record => {
|
||||
this.props
|
||||
.modelRequest(
|
||||
cs.ModelTemplates,
|
||||
cs.Routes.DeleteTemplate,
|
||||
cs.MethodDelete,
|
||||
{ id: record.id }
|
||||
)
|
||||
.then(() => {
|
||||
notification["success"]({
|
||||
placement: cs.MsgPosition,
|
||||
message: "Template deleted",
|
||||
description: `"${record.name}" deleted`
|
||||
});
|
||||
|
||||
// Reload the table.
|
||||
this.fetchRecords();
|
||||
})
|
||||
.catch(e => {
|
||||
notification["error"]({ message: "Error", description: e.message });
|
||||
});
|
||||
};
|
||||
|
||||
handleSetDefault = record => {
|
||||
this.props
|
||||
.modelRequest(
|
||||
cs.ModelTemplates,
|
||||
cs.Routes.SetDefaultTemplate,
|
||||
cs.MethodPut,
|
||||
{ id: record.id }
|
||||
)
|
||||
.then(() => {
|
||||
notification["success"]({
|
||||
placement: cs.MsgPosition,
|
||||
message: "Template updated",
|
||||
description: `"${record.name}" set as default`
|
||||
});
|
||||
|
||||
// Reload the table.
|
||||
this.fetchRecords();
|
||||
})
|
||||
.catch(e => {
|
||||
notification["error"]({
|
||||
placement: cs.MsgPosition,
|
||||
message: "Error",
|
||||
description: e.message
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
handlePreview = record => {
|
||||
this.setState({ previewRecord: record });
|
||||
};
|
||||
|
||||
hideForm = () => {
|
||||
this.setState({ formType: null });
|
||||
};
|
||||
|
||||
handleShowCreateForm = () => {
|
||||
this.setState({ formType: cs.FormCreate, record: {} });
|
||||
};
|
||||
|
||||
handleShowEditForm = record => {
|
||||
this.setState({ formType: cs.FormEdit, record: record });
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
<section className="content templates">
|
||||
<Row>
|
||||
<Col xs={24} sm={14}>
|
||||
<h1>Templates ({this.props.data[cs.ModelTemplates].length}) </h1>
|
||||
</Col>
|
||||
<Col xs={24} sm={10} className="right header-action-break">
|
||||
<Button
|
||||
type="primary"
|
||||
icon="plus"
|
||||
onClick={this.handleShowCreateForm}
|
||||
>
|
||||
Add template
|
||||
</Button>
|
||||
</Col>
|
||||
</Row>
|
||||
<br />
|
||||
|
||||
<Table
|
||||
columns={this.columns}
|
||||
rowKey={record => record.id}
|
||||
dataSource={this.props.data[cs.ModelTemplates]}
|
||||
loading={this.props.reqStates[cs.ModelTemplates] !== cs.StateDone}
|
||||
pagination={false}
|
||||
/>
|
||||
|
||||
<CreateForm
|
||||
{...this.props}
|
||||
formType={this.state.formType}
|
||||
record={this.state.record}
|
||||
onClose={this.hideForm}
|
||||
fetchRecords={this.fetchRecords}
|
||||
/>
|
||||
|
||||
{this.state.previewRecord && (
|
||||
<ModalPreview
|
||||
title={this.state.previewRecord.name}
|
||||
previewURL={cs.Routes.PreviewTemplate.replace(
|
||||
":id",
|
||||
this.state.previewRecord.id
|
||||
)}
|
||||
onCancel={() => {
|
||||
this.setState({ previewRecord: null });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default Templates;
|
|
@ -0,0 +1,190 @@
|
|||
import { ToastProgrammatic as Toast } from 'buefy';
|
||||
import axios from 'axios';
|
||||
import humps from 'humps';
|
||||
import qs from 'qs';
|
||||
import store from '../store';
|
||||
import { models } from '../constants';
|
||||
|
||||
const http = axios.create({
|
||||
baseURL: process.env.BASE_URL,
|
||||
withCredentials: false,
|
||||
responseType: 'json',
|
||||
transformResponse: [
|
||||
// Apply the defaut transformations as well.
|
||||
...axios.defaults.transformResponse,
|
||||
(resp) => {
|
||||
if (!resp) {
|
||||
return resp;
|
||||
}
|
||||
|
||||
// There's an error message.
|
||||
if ('message' in resp && resp.message !== '') {
|
||||
return resp;
|
||||
}
|
||||
|
||||
const data = humps.camelizeKeys(resp.data);
|
||||
return data;
|
||||
},
|
||||
],
|
||||
|
||||
// Override the default serializer to switch params from becoming []id=a&[]id=b ...
|
||||
// in GET and DELETE requests to id=a&id=b.
|
||||
paramsSerializer: (params) => qs.stringify(params, { arrayFormat: 'repeat' }),
|
||||
});
|
||||
|
||||
|
||||
// Intercept requests to set the 'loading' state of a model.
|
||||
http.interceptors.request.use((config) => {
|
||||
if ('loading' in config) {
|
||||
store.commit('setLoading', { model: config.loading, status: true });
|
||||
}
|
||||
return config;
|
||||
}, (error) => Promise.reject(error));
|
||||
|
||||
// Intercept responses to set them to store.
|
||||
http.interceptors.response.use((resp) => {
|
||||
// Clear the loading state for a model.
|
||||
if ('loading' in resp.config) {
|
||||
store.commit('setLoading', { model: resp.config.loading, status: false });
|
||||
}
|
||||
|
||||
// Store the API response for a model.
|
||||
if ('store' in resp.config) {
|
||||
store.commit('setModelResponse', { model: resp.config.store, data: resp.data });
|
||||
}
|
||||
return resp;
|
||||
}, (err) => {
|
||||
// Clear the loading state for a model.
|
||||
if ('loading' in err.config) {
|
||||
store.commit('setLoading', { model: err.config.loading, status: false });
|
||||
}
|
||||
|
||||
let msg = '';
|
||||
if (err.response.data && err.response.data.message) {
|
||||
msg = err.response.data.message;
|
||||
} else {
|
||||
msg = err.toString();
|
||||
}
|
||||
|
||||
Toast.open({
|
||||
message: msg,
|
||||
type: 'is-danger',
|
||||
queue: false,
|
||||
});
|
||||
|
||||
return Promise.reject(err);
|
||||
});
|
||||
|
||||
// API calls accept the following config keys.
|
||||
// loading: modelName (set's the loading status in the global store: eg: store.loading.lists = true)
|
||||
// store: modelName (set's the API response in the global store. eg: store.lists: { ... } )
|
||||
|
||||
// Dashboard
|
||||
export const getDashboardCounts = () => http.get('/api/dashboard/counts',
|
||||
{ loading: models.dashboard });
|
||||
|
||||
export const getDashboardCharts = () => http.get('/api/dashboard/charts',
|
||||
{ loading: models.dashboard });
|
||||
|
||||
// Lists.
|
||||
export const getLists = () => http.get('/api/lists',
|
||||
{ loading: models.lists, store: models.lists });
|
||||
|
||||
export const createList = (data) => http.post('/api/lists', data,
|
||||
{ loading: models.lists });
|
||||
|
||||
export const updateList = (data) => http.put(`/api/lists/${data.id}`, data,
|
||||
{ loading: models.lists });
|
||||
|
||||
export const deleteList = (id) => http.delete(`/api/lists/${id}`,
|
||||
{ loading: models.lists });
|
||||
|
||||
// Subscribers.
|
||||
export const getSubscribers = async (params) => http.get('/api/subscribers',
|
||||
{ params, loading: models.subscribers, store: models.subscribers });
|
||||
|
||||
export const createSubscriber = (data) => http.post('/api/subscribers', data,
|
||||
{ loading: models.subscribers });
|
||||
|
||||
export const updateSubscriber = (data) => http.put(`/api/subscribers/${data.id}`, data,
|
||||
{ loading: models.subscribers });
|
||||
|
||||
export const deleteSubscriber = (id) => http.delete(`/api/subscribers/${id}`,
|
||||
{ loading: models.subscribers });
|
||||
|
||||
export const addSubscribersToLists = (data) => http.put('/api/subscribers/lists', data,
|
||||
{ loading: models.subscribers });
|
||||
|
||||
export const addSubscribersToListsByQuery = (data) => http.put('/api/subscribers/query/lists',
|
||||
data, { loading: models.subscribers });
|
||||
|
||||
export const blacklistSubscribers = (data) => http.put('/api/subscribers/blacklist', data,
|
||||
{ loading: models.subscribers });
|
||||
|
||||
export const blacklistSubscribersByQuery = (data) => http.put('/api/subscribers/query/blacklist', data,
|
||||
{ loading: models.subscribers });
|
||||
|
||||
export const deleteSubscribers = (params) => http.delete('/api/subscribers',
|
||||
{ params, loading: models.subscribers });
|
||||
|
||||
export const deleteSubscribersByQuery = (data) => http.post('/api/subscribers/query/delete', data,
|
||||
{ loading: models.subscribers });
|
||||
|
||||
// Subscriber import.
|
||||
export const importSubscribers = (data) => http.post('/api/import/subscribers', data);
|
||||
|
||||
export const getImportStatus = () => http.get('/api/import/subscribers');
|
||||
|
||||
export const getImportLogs = () => http.get('/api/import/subscribers/logs');
|
||||
|
||||
export const stopImport = () => http.delete('/api/import/subscribers');
|
||||
|
||||
// Campaigns.
|
||||
export const getCampaigns = async (params) => http.get('/api/campaigns',
|
||||
{ params, loading: models.campaigns, store: models.campaigns });
|
||||
|
||||
export const getCampaign = async (id) => http.get(`/api/campaigns/${id}`,
|
||||
{ loading: models.campaigns });
|
||||
|
||||
export const getCampaignStats = async () => http.get('/api/campaigns/running/stats', {});
|
||||
|
||||
export const createCampaign = async (data) => http.post('/api/campaigns', data,
|
||||
{ loading: models.campaigns });
|
||||
|
||||
export const testCampaign = async (data) => http.post(`/api/campaigns/${data.id}/test`, data,
|
||||
{ loading: models.campaigns });
|
||||
|
||||
export const updateCampaign = async (id, data) => http.put(`/api/campaigns/${id}`, data,
|
||||
{ loading: models.campaigns });
|
||||
|
||||
export const changeCampaignStatus = async (id, status) => http.put(`/api/campaigns/${id}/status`,
|
||||
{ status }, { loading: models.campaigns });
|
||||
|
||||
export const deleteCampaign = async (id) => http.delete(`/api/campaigns/${id}`,
|
||||
{ loading: models.campaigns });
|
||||
|
||||
// Media.
|
||||
export const getMedia = async () => http.get('/api/media',
|
||||
{ loading: models.media, store: models.media });
|
||||
|
||||
export const uploadMedia = (data) => http.post('/api/media', data,
|
||||
{ loading: models.media });
|
||||
|
||||
export const deleteMedia = (id) => http.delete(`/api/media/${id}`,
|
||||
{ loading: models.media });
|
||||
|
||||
// Templates.
|
||||
export const createTemplate = async (data) => http.post('/api/templates', data,
|
||||
{ loading: models.templates });
|
||||
|
||||
export const getTemplates = async () => http.get('/api/templates',
|
||||
{ loading: models.templates, store: models.templates });
|
||||
|
||||
export const updateTemplate = async (data) => http.put(`/api/templates/${data.id}`, data,
|
||||
{ loading: models.templates });
|
||||
|
||||
export const makeTemplateDefault = async (id) => http.put(`/api/templates/${id}/default`, {},
|
||||
{ loading: models.templates });
|
||||
|
||||
export const deleteTemplate = async (id) => http.delete(`/api/templates/${id}`,
|
||||
{ loading: models.templates });
|
|
@ -0,0 +1,43 @@
|
|||
@import "~bulma/sass/base/_all";
|
||||
@import "~bulma/sass/elements/_all";
|
||||
@import "~bulma/sass/components/card";
|
||||
@import "~bulma/sass/components/dropdown";
|
||||
@import "~bulma/sass/components/level";
|
||||
@import "~bulma/sass/components/menu";
|
||||
@import "~bulma/sass/components/message";
|
||||
@import "~bulma/sass/components/modal";
|
||||
@import "~bulma/sass/components/pagination";
|
||||
@import "~bulma/sass/components/tabs";
|
||||
@import "~bulma/sass/form/_all";
|
||||
@import "~bulma/sass/grid/columns";
|
||||
@import "~bulma/sass/grid/tiles";
|
||||
@import "~bulma/sass/layout/section";
|
||||
@import "~bulma/sass/layout/footer";
|
||||
|
||||
@import "~buefy/src/scss/utils/_all";
|
||||
@import "~buefy/src/scss/components/_autocomplete";
|
||||
@import "~buefy/src/scss/components/_carousel";
|
||||
@import "~buefy/src/scss/components/_checkbox";
|
||||
@import "~buefy/src/scss/components/_datepicker";
|
||||
@import "~buefy/src/scss/components/_dialog";
|
||||
@import "~buefy/src/scss/components/_dropdown";
|
||||
@import "~buefy/src/scss/components/_form";
|
||||
@import "~buefy/src/scss/components/_icon";
|
||||
@import "~buefy/src/scss/components/_loading";
|
||||
@import "~buefy/src/scss/components/_menu";
|
||||
@import "~buefy/src/scss/components/_message";
|
||||
@import "~buefy/src/scss/components/_modal";
|
||||
@import "~buefy/src/scss/components/_pagination";
|
||||
@import "~buefy/src/scss/components/_notices";
|
||||
@import "~buefy/src/scss/components/_progress";
|
||||
@import "~buefy/src/scss/components/_radio";
|
||||
@import "~buefy/src/scss/components/_select";
|
||||
@import "~buefy/src/scss/components/_sidebar";
|
||||
@import "~buefy/src/scss/components/_switch";
|
||||
@import "~buefy/src/scss/components/_table";
|
||||
@import "~buefy/src/scss/components/_tabs";
|
||||
@import "~buefy/src/scss/components/_tag";
|
||||
@import "~buefy/src/scss/components/_taginput";
|
||||
@import "~buefy/src/scss/components/_timepicker";
|
||||
@import "~buefy/src/scss/components/_tooltip";
|
||||
@import "~buefy/src/scss/components/_upload";
|
Binary file not shown.
After Width: | Height: | Size: 1.2 KiB |
|
@ -0,0 +1,73 @@
|
|||
@font-face {
|
||||
font-family: 'fontello';
|
||||
src: url('fontello.woff2') format('woff2');
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
[class^="mdi-"]:before, [class*=" mdi-"]:before {
|
||||
font-family: "fontello";
|
||||
font-style: normal;
|
||||
font-weight: normal;
|
||||
speak: never;
|
||||
|
||||
display: inline-block;
|
||||
text-decoration: inherit;
|
||||
width: 1em;
|
||||
margin-right: .2em;
|
||||
text-align: center;
|
||||
/* opacity: .8; */
|
||||
|
||||
/* For safety - reset parent styles, that can break glyph codes*/
|
||||
font-variant: normal;
|
||||
text-transform: none;
|
||||
|
||||
/* fix buttons height, for twitter bootstrap */
|
||||
line-height: 1em;
|
||||
|
||||
/* Animation center compensation - margins should be symmetric */
|
||||
/* remove if not needed */
|
||||
margin-left: .2em;
|
||||
|
||||
/* you can be more comfortable with increased icons size */
|
||||
/* font-size: 120%; */
|
||||
|
||||
/* Font smoothing. That was taken from TWBS */
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
|
||||
/* Uncomment for 3D effect */
|
||||
/* text-shadow: 1px 1px 1px rgba(127, 127, 127, 0.3); */
|
||||
}
|
||||
|
||||
.mdi-view-dashboard-variant-outline:before { content: '\e800'; } /* '' */
|
||||
.mdi-format-list-bulleted-square:before { content: '\e801'; } /* '' */
|
||||
.mdi-newspaper-variant-outline:before { content: '\e802'; } /* '' */
|
||||
.mdi-account-multiple:before { content: '\e803'; } /* '' */
|
||||
.mdi-file-upload-outline:before { content: '\e804'; } /* '' */
|
||||
.mdi-rocket-launch-outline:before { content: '\e805'; } /* '' */
|
||||
.mdi-plus:before { content: '\e806'; } /* '' */
|
||||
.mdi-image-outline:before { content: '\e807'; } /* '' */
|
||||
.mdi-file-image-outline:before { content: '\e808'; } /* '' */
|
||||
.mdi-cog-outline:before { content: '\e809'; } /* '' */
|
||||
.mdi-tag-outline:before { content: '\e80a'; } /* '' */
|
||||
.mdi-calendar-clock:before { content: '\e80b'; } /* '' */
|
||||
.mdi-email-outline:before { content: '\e80c'; } /* '' */
|
||||
.mdi-text:before { content: '\e80d'; } /* '' */
|
||||
.mdi-alarm:before { content: '\e80e'; } /* '' */
|
||||
.mdi-pause-circle-outline:before { content: '\e80f'; } /* '' */
|
||||
.mdi-file-find-outline:before { content: '\e810'; } /* '' */
|
||||
.mdi-clock-start:before { content: '\e811'; } /* '' */
|
||||
.mdi-file-multiple-outline:before { content: '\e812'; } /* '' */
|
||||
.mdi-trash-can-outline:before { content: '\e813'; } /* '' */
|
||||
.mdi-pencil-outline:before { content: '\e814'; } /* '' */
|
||||
.mdi-arrow-top-right:before { content: '\e815'; } /* '' */
|
||||
.mdi-link-variant:before { content: '\e816'; } /* '' */
|
||||
.mdi-cloud-download-outline:before { content: '\e817'; } /* '' */
|
||||
.mdi-account-search-outline:before { content: '\e818'; } /* '' */
|
||||
.mdi-check-circle-outline:before { content: '\e819'; } /* '' */
|
||||
.mdi-account-check-outline:before { content: '\e81a'; } /* '' */
|
||||
.mdi-account-off-outline:before { content: '\e81b'; } /* '' */
|
||||
.mdi-chevron-right:before { content: '\e81c'; } /* '' */
|
||||
.mdi-chevron-left:before { content: '\e81d'; } /* '' */
|
||||
.mdi-content-save-outline:before { content: '\e81e'; } /* '' */
|
Binary file not shown.
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 10 KiB |
|
@ -0,0 +1,533 @@
|
|||
/* Import Bulma to set variables */
|
||||
@import "~bulma/sass/utilities/_all";
|
||||
|
||||
$body-family: "IBM Plex Sans", "Helvetica Neue", sans-serif;
|
||||
$body-size: 15px;
|
||||
$primary: #7f2aff;
|
||||
$green: #4caf50;
|
||||
$turquoise: $green;
|
||||
$red: #ff5722;
|
||||
$link: $primary;
|
||||
$input-placeholder-color: $black-ter;
|
||||
|
||||
$colors: map-merge($colors, (
|
||||
"turquoise": ($green, $green-invert),
|
||||
"green": ($green, $green-invert),
|
||||
"success": ($green, $green-invert),
|
||||
"danger": ($red, $green-invert),
|
||||
));
|
||||
|
||||
$sidebar-box-shadow: none;
|
||||
$sidebar-width: 240px;
|
||||
$menu-item-active-background-color: $white-bis;
|
||||
$menu-item-active-color: $primary;
|
||||
|
||||
/* Buefy */
|
||||
$modal-background-background-color: rgba(0, 0, 0, .30);
|
||||
$speed-slow: 25ms !default;
|
||||
$speed-slower: 50ms !default;
|
||||
|
||||
/* Import full Bulma and Buefy to override styles. */
|
||||
// @import "~bulma";
|
||||
@import "./buefy";
|
||||
|
||||
html, body {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
code {
|
||||
color: $grey;
|
||||
}
|
||||
|
||||
ul.no {
|
||||
list-style-type: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.relative {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
section {
|
||||
&.wrap {
|
||||
max-width: 1100px;
|
||||
}
|
||||
&.wrap-small {
|
||||
max-width: 900px;
|
||||
}
|
||||
}
|
||||
.spinner.is-tiny {
|
||||
display: inline-block;
|
||||
height: 10px;
|
||||
width: 10px;
|
||||
position: relative;
|
||||
|
||||
.loading-overlay {
|
||||
.loading-background {
|
||||
background: none;
|
||||
}
|
||||
.loading-icon::after {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
top: 0;
|
||||
left: 0;
|
||||
position: static;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Two column sidebar+body layout */
|
||||
#app {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
min-height: 100%;
|
||||
|
||||
> .sidebar {
|
||||
flex-shrink: 1;
|
||||
box-shadow: 0 0 5px #eee;
|
||||
border-right: 1px solid #eee;
|
||||
|
||||
.b-sidebar {
|
||||
position: sticky;
|
||||
top: 0px;
|
||||
}
|
||||
}
|
||||
> .main {
|
||||
margin: 30px 30px 30px 45px;
|
||||
flex-grow: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.b-sidebar {
|
||||
.logo {
|
||||
padding: 15px;
|
||||
}
|
||||
.sidebar-content {
|
||||
border-right: 1px solid #eee;
|
||||
}
|
||||
.menu-list {
|
||||
.router-link-exact-active {
|
||||
border-right: 5px solid $primary;
|
||||
outline: 0 none;
|
||||
}
|
||||
li ul {
|
||||
margin-right: 0;
|
||||
}
|
||||
> li {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
}
|
||||
.logo {
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
.favicon {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Table colors and padding */
|
||||
.main table {
|
||||
thead th {
|
||||
background: $white-bis;
|
||||
border-bottom: 1px solid $grey-lighter;
|
||||
}
|
||||
thead th, tbody td {
|
||||
padding: 15px 10px;
|
||||
border-color: #eaeaea;
|
||||
}
|
||||
.actions a {
|
||||
margin: 0 10px;
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
|
||||
/* Modal */
|
||||
.modal {
|
||||
z-index: 100;
|
||||
}
|
||||
.modal-card-head {
|
||||
display: block;
|
||||
}
|
||||
.modal .modal-card-foot {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.modal .modal-close.is-large {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Fix for button primary colour. */
|
||||
.button.is-primary {
|
||||
background: $primary;
|
||||
&:hover {
|
||||
background: darken($primary, 15%);
|
||||
}
|
||||
&:disabled {
|
||||
background: $grey-light;
|
||||
}
|
||||
}
|
||||
|
||||
.autocomplete .dropdown-content {
|
||||
background-color: $white-bis;
|
||||
}
|
||||
|
||||
.help {
|
||||
color: $grey;
|
||||
}
|
||||
|
||||
/* Tags */
|
||||
.tag {
|
||||
min-width: 75px;
|
||||
|
||||
&:not(body) {
|
||||
$color: $grey-lighter;
|
||||
border: 1px solid $color;
|
||||
box-shadow: 1px 1px 0 $color;
|
||||
color: $grey;
|
||||
}
|
||||
|
||||
&.private, &.scheduled, &.paused {
|
||||
$color: #ed7b00;
|
||||
color: $color;
|
||||
background: #fff7e6;
|
||||
border: 1px solid lighten($color, 37%);
|
||||
box-shadow: 1px 1px 0 lighten($color, 37%);
|
||||
}
|
||||
&.public, &.running {
|
||||
$color: #1890ff;
|
||||
color: $color;
|
||||
background: #e6f7ff;
|
||||
border: 1px solid lighten($color, 37%);
|
||||
box-shadow: 1px 1px 0 lighten($color, 25%);
|
||||
}
|
||||
&.finished, &.enabled {
|
||||
$color: #50ab24;
|
||||
color: $color;
|
||||
background: #f6ffed;
|
||||
border: 1px solid lighten($color, 45%);
|
||||
box-shadow: 1px 1px 0 lighten($color, 45%);
|
||||
}
|
||||
&.blacklisted {
|
||||
$color: #f5222d;
|
||||
color: $color;
|
||||
background: #fff1f0;
|
||||
border: 1px solid lighten($color, 45%);
|
||||
box-shadow: 1px 1px 0 lighten($color, 45%);
|
||||
}
|
||||
|
||||
sup {
|
||||
font-weight: $weight-semibold;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
&.unsubscribed sup {
|
||||
color: #fa8c16;
|
||||
}
|
||||
&.confirmed sup {
|
||||
color: #52c41a;
|
||||
}
|
||||
|
||||
&:not(body) .icon:first-child:last-child {
|
||||
margin-right: 1px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Dashboard */
|
||||
section.dashboard {
|
||||
.title {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.level-item {
|
||||
background-color: $white-bis;
|
||||
padding: 30px;
|
||||
margin: 10px;
|
||||
|
||||
&:first-child, &:last-child {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
label {
|
||||
font-weight: bold;
|
||||
display: inline-block;
|
||||
min-width: 50px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.charts {
|
||||
min-height: 200px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Lists page */
|
||||
section.lists {
|
||||
td .tag {
|
||||
min-width: 65px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Subscribers page */
|
||||
.subscribers-controls {
|
||||
padding-bottom: 15px;
|
||||
}
|
||||
.subscribers-bulk {
|
||||
.actions a {
|
||||
display: inline-block;
|
||||
margin-right: 30px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Import page */
|
||||
section.import {
|
||||
.delimiter input {
|
||||
max-width: 100px;
|
||||
}
|
||||
.status {
|
||||
padding: 60px;
|
||||
}
|
||||
.logs {
|
||||
max-height: 240px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Campaigns page */
|
||||
section.campaigns {
|
||||
table tbody {
|
||||
tr.running {
|
||||
background: lighten(#1890ff, 43%);
|
||||
td {
|
||||
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 {
|
||||
&.status .spinner {
|
||||
margin-left: 10px;
|
||||
}
|
||||
.tags {
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
&.lists ul {
|
||||
font-size: $size-7;
|
||||
list-style-type: circle;
|
||||
|
||||
a {
|
||||
color: $grey-dark;
|
||||
&:hover {
|
||||
color: $primary;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.fields {
|
||||
font-size: $size-7;
|
||||
label {
|
||||
font-weight: bold;
|
||||
text-align: right;
|
||||
min-width: 50px;
|
||||
margin-right: 10px;
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
|
||||
&.draft {
|
||||
color: $grey-lighter;
|
||||
}
|
||||
|
||||
.progress-wrapper {
|
||||
.progress.is-small {
|
||||
height: 0.4em;
|
||||
}
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Campaign / template preview popup */
|
||||
.preview {
|
||||
padding: 0;
|
||||
|
||||
/* Contain the spinner background in the content area. */
|
||||
position: relative;
|
||||
|
||||
#iframe {
|
||||
border: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 500px;
|
||||
padding: 0;
|
||||
margin: 0 0 -5px 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Campaign */
|
||||
section.campaign {
|
||||
header .buttons {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
|
||||
/* Media gallery */
|
||||
.media-files {
|
||||
.thumbs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
flex-direction: column;
|
||||
flex-flow: row wrap;
|
||||
|
||||
.thumb {
|
||||
margin: 10px;
|
||||
max-height: 90px;
|
||||
overflow: hidden;
|
||||
|
||||
position: relative;
|
||||
|
||||
.caption {
|
||||
background-color: rgba($white, .70);
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
padding: 2px 5px;
|
||||
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.actions {
|
||||
background-color: rgba($white, .70);
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
padding: 2px 5px;
|
||||
display: none;
|
||||
|
||||
a {
|
||||
margin-left: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover .actions {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.box {
|
||||
padding: 10px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Template form */
|
||||
.template-modal {
|
||||
.template-modal-content {
|
||||
height: 95vh;
|
||||
max-height: none;
|
||||
}
|
||||
.textarea {
|
||||
max-height: none;
|
||||
height: 55vh;
|
||||
}
|
||||
}
|
||||
|
||||
.c3 {
|
||||
.c3-chart-lines .c3-line {
|
||||
stroke-width: 2px;
|
||||
}
|
||||
.c3-axis-x .tick line,
|
||||
.c3-axis-y .tick line {
|
||||
display: none;
|
||||
}
|
||||
text {
|
||||
fill: $grey;
|
||||
font-family: $body-family;
|
||||
font-size: 11px;
|
||||
}
|
||||
.c3-axis path, .c3-axis line {
|
||||
stroke: #eee;
|
||||
}
|
||||
|
||||
.c3-tooltip {
|
||||
border: 0;
|
||||
background-color: #fff;
|
||||
empty-cells: show;
|
||||
box-shadow: none;
|
||||
opacity: 0.9;
|
||||
|
||||
tr {
|
||||
border: 0;
|
||||
}
|
||||
th {
|
||||
background: $white;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 1450px) and (min-width: 769px) {
|
||||
section.campaigns {
|
||||
/* Fold the stats labels until the card view */
|
||||
table tbody td {
|
||||
.fields {
|
||||
label {
|
||||
margin: 0;
|
||||
display: block;
|
||||
text-align: left;
|
||||
}
|
||||
p {
|
||||
margin-bottom: 10px !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 1023px) {
|
||||
/* Hide sidebar menu captions on mobile */
|
||||
.b-sidebar .sidebar-content.is-mini-mobile {
|
||||
.menu-list li {
|
||||
margin-bottom: 30px;
|
||||
|
||||
span:nth-child(2) {
|
||||
display: none;
|
||||
}
|
||||
.icon.is-small {
|
||||
scale: 1.4;
|
||||
}
|
||||
}
|
||||
.logo {
|
||||
text-align: center;
|
||||
.full {
|
||||
display: none;
|
||||
}
|
||||
.favicon {
|
||||
display: block;
|
||||
}
|
||||
.version {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#app > .content {
|
||||
margin: 15px;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 840px) {
|
||||
section.dashboard label {
|
||||
min-width: auto;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,95 @@
|
|||
<template>
|
||||
<div>
|
||||
<b-modal scroll="keep" @close="close"
|
||||
:aria-modal="true" :active="isVisible">
|
||||
<div>
|
||||
<div class="modal-card" style="width: auto">
|
||||
<header class="modal-card-head">
|
||||
<h4>{{ title }}</h4>
|
||||
</header>
|
||||
</div>
|
||||
<section expanded class="modal-card-body preview">
|
||||
<b-loading :active="isLoading" :is-full-page="false"></b-loading>
|
||||
<form v-if="body" method="post" :action="previewURL" target="iframe" ref="form">
|
||||
<input type="hidden" name="body" :value="body" />
|
||||
</form>
|
||||
|
||||
<iframe id="iframe" name="iframe" ref="iframe"
|
||||
:title="title"
|
||||
:src="body ? 'about:blank' : previewURL"
|
||||
@load="onLoaded"
|
||||
></iframe>
|
||||
</section>
|
||||
<footer class="modal-card-foot has-text-right">
|
||||
<b-button @click="close">Close</b-button>
|
||||
</footer>
|
||||
</div>
|
||||
</b-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { uris } from '../constants';
|
||||
|
||||
export default {
|
||||
name: 'CampaignPreview',
|
||||
|
||||
props: {
|
||||
// Template or campaign ID.
|
||||
id: Number,
|
||||
title: String,
|
||||
|
||||
// campaign | template.
|
||||
type: String,
|
||||
body: String,
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
isVisible: true,
|
||||
isLoading: true,
|
||||
};
|
||||
},
|
||||
|
||||
methods: {
|
||||
close() {
|
||||
this.$emit('close');
|
||||
this.isVisible = false;
|
||||
},
|
||||
|
||||
// On iframe load, kill the spinner.
|
||||
onLoaded(l) {
|
||||
if (l.srcElement.contentWindow.location.href === 'about:blank') {
|
||||
return;
|
||||
}
|
||||
this.isLoading = false;
|
||||
},
|
||||
},
|
||||
|
||||
computed: {
|
||||
previewURL() {
|
||||
let uri = 'about:blank';
|
||||
|
||||
if (this.type === 'campaign') {
|
||||
uri = uris.previewCampaign;
|
||||
} else if (this.type === 'template') {
|
||||
if (this.id) {
|
||||
uri = uris.previewTemplate;
|
||||
} else {
|
||||
uri = uris.previewRawTemplate;
|
||||
}
|
||||
}
|
||||
|
||||
return uri.replace(':id', this.id);
|
||||
},
|
||||
},
|
||||
|
||||
mounted() {
|
||||
setTimeout(() => {
|
||||
if (this.$refs.form) {
|
||||
this.$refs.form.submit();
|
||||
}
|
||||
}, 100);
|
||||
},
|
||||
};
|
||||
</script>
|
|
@ -0,0 +1,187 @@
|
|||
<template>
|
||||
<!-- Two-way Data-Binding -->
|
||||
<section class="editor">
|
||||
<div class="columns">
|
||||
<div class="column is-6">
|
||||
<b-field label="Format">
|
||||
<div>
|
||||
<b-radio v-model="form.radioFormat"
|
||||
@input="onChangeFormat" :disabled="disabled" name="format"
|
||||
native-value="richtext">Rich text</b-radio>
|
||||
<b-radio v-model="form.radioFormat"
|
||||
@input="onChangeFormat" :disabled="disabled" name="format"
|
||||
native-value="html">Raw HTML</b-radio>
|
||||
</div>
|
||||
</b-field>
|
||||
</div>
|
||||
<div class="column is-6 has-text-right">
|
||||
<b-button @click="togglePreview" type="is-primary"
|
||||
icon-left="file-find-outline">Preview</b-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- wsywig //-->
|
||||
<quill-editor
|
||||
v-if="form.format === 'richtext'"
|
||||
v-model="form.body"
|
||||
ref="quill"
|
||||
:options="options"
|
||||
:disabled="disabled"
|
||||
placeholder="Content here"
|
||||
@change="onEditorChange($event)"
|
||||
@ready="onEditorReady($event)"
|
||||
/>
|
||||
|
||||
<!-- raw html editor //-->
|
||||
<b-input v-if="form.format === 'html'"
|
||||
@input="onEditorChange"
|
||||
v-model="form.body" type="textarea" />
|
||||
|
||||
|
||||
<!-- campaign preview //-->
|
||||
<campaign-preview v-if="isPreviewing"
|
||||
@close="togglePreview"
|
||||
type='campaign'
|
||||
:id='id'
|
||||
:title='title'
|
||||
:body="form.body"></campaign-preview>
|
||||
|
||||
<!-- image picker -->
|
||||
<b-modal scroll="keep" :aria-modal="true" :active.sync="isMediaVisible" :width="900">
|
||||
<div class="modal-card content" style="width: auto">
|
||||
<section expanded class="modal-card-body">
|
||||
<media isModal @selected="onMediaSelect" />
|
||||
</section>
|
||||
</div>
|
||||
</b-modal>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import 'quill/dist/quill.snow.css';
|
||||
import 'quill/dist/quill.core.css';
|
||||
import { quillEditor } from 'vue-quill-editor';
|
||||
import CampaignPreview from './CampaignPreview.vue';
|
||||
import Media from '../views/Media.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
Media,
|
||||
CampaignPreview,
|
||||
quillEditor,
|
||||
},
|
||||
|
||||
props: {
|
||||
id: Number,
|
||||
title: String,
|
||||
body: String,
|
||||
contentType: String,
|
||||
disabled: Boolean,
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
isPreviewing: false,
|
||||
isMediaVisible: false,
|
||||
form: {
|
||||
body: '',
|
||||
format: this.contentType,
|
||||
|
||||
// Model bound to the checkboxes. This changes on click of the radio,
|
||||
// but is reverted by the change handler if the user cancels the
|
||||
// conversion warning. This is used to set the value of form.format
|
||||
// that the editor uses to render content.
|
||||
radioFormat: this.contentType,
|
||||
},
|
||||
|
||||
// Quill editor options.
|
||||
options: {
|
||||
placeholder: 'Content here',
|
||||
modules: {
|
||||
toolbar: {
|
||||
container: [
|
||||
[{ header: [1, 2, 3, false] }],
|
||||
['bold', 'italic', 'underline', 'strike', 'blockquote', 'code'],
|
||||
[{ color: [] }, { background: [] }, { size: [] }],
|
||||
[
|
||||
{ list: 'ordered' },
|
||||
{ list: 'bullet' },
|
||||
{ indent: '-1' },
|
||||
{ indent: '+1' },
|
||||
],
|
||||
[
|
||||
{ align: '' },
|
||||
{ align: 'center' },
|
||||
{ align: 'right' },
|
||||
{ align: 'justify' },
|
||||
],
|
||||
['link', 'image'],
|
||||
['clean', 'font'],
|
||||
],
|
||||
|
||||
handlers: {
|
||||
image: this.toggleMedia,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
methods: {
|
||||
onChangeFormat(format) {
|
||||
this.$utils.confirm(
|
||||
'The content may lose some formatting. Are you sure?',
|
||||
() => {
|
||||
this.form.format = format;
|
||||
this.onEditorChange();
|
||||
},
|
||||
() => {
|
||||
// On cancel, undo the radio selection.
|
||||
this.form.radioFormat = format === 'richtext' ? 'html' : 'richtext';
|
||||
},
|
||||
);
|
||||
},
|
||||
|
||||
onEditorReady() {
|
||||
// Hack to focus the editor on page load.
|
||||
this.$nextTick(() => {
|
||||
window.setTimeout(() => this.$refs.quill.quill.focus(), 100);
|
||||
});
|
||||
},
|
||||
|
||||
onEditorChange() {
|
||||
// The parent's v-model gets { contentType, body }.
|
||||
this.$emit('input', { contentType: this.form.format, body: this.form.body });
|
||||
},
|
||||
|
||||
togglePreview() {
|
||||
this.isPreviewing = !this.isPreviewing;
|
||||
},
|
||||
|
||||
toggleMedia() {
|
||||
this.isMediaVisible = !this.isMediaVisible;
|
||||
},
|
||||
|
||||
onMediaSelect(m) {
|
||||
this.$refs.quill.quill.insertEmbed(10, 'image', m.uri);
|
||||
},
|
||||
},
|
||||
|
||||
watch: {
|
||||
// Capture contentType and body passed from the parent as props.
|
||||
contentType(f) {
|
||||
this.form.format = f;
|
||||
this.form.radioFormat = f;
|
||||
|
||||
// Trigger the change event so that the body and content type
|
||||
// are propagated to the parent on first load.
|
||||
this.onEditorChange();
|
||||
},
|
||||
|
||||
body(b) {
|
||||
this.form.body = b;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
|
@ -0,0 +1,22 @@
|
|||
<template>
|
||||
<section class="section">
|
||||
<div class="content has-text-grey has-text-centered">
|
||||
<p>
|
||||
<b-icon :icon="!icon ? 'plus' : icon" size="is-large" />
|
||||
</p>
|
||||
<p>{{ !label ? 'Nothing here yet' : label }}</p>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'EmptyPlaceholder',
|
||||
|
||||
props: {
|
||||
icon: String,
|
||||
label: String,
|
||||
},
|
||||
};
|
||||
</script>
|
|
@ -0,0 +1,111 @@
|
|||
<template>
|
||||
<div class="field">
|
||||
<b-field :label="label + (selectedItems ? ` (${selectedItems.length})` : '')">
|
||||
<div :class="classes">
|
||||
<b-taglist>
|
||||
<b-tag v-for="l in selectedItems"
|
||||
:key="l.id"
|
||||
:class="l.subscriptionStatus"
|
||||
:closable="!disabled && l.subscriptionStatus !== 'unsubscribed'"
|
||||
:data-id="l.id"
|
||||
@close="removeList(l.id)">
|
||||
{{ l.name }} <sup>{{ l.subscriptionStatus }}</sup>
|
||||
</b-tag>
|
||||
</b-taglist>
|
||||
</div>
|
||||
</b-field>
|
||||
|
||||
<b-field :message="message">
|
||||
<b-autocomplete
|
||||
:placeholder="placeholder"
|
||||
clearable
|
||||
dropdown-position="top"
|
||||
:disabled="disabled || filteredLists.length === 0"
|
||||
:keep-first="true"
|
||||
:clear-on-select="true"
|
||||
:open-on-focus="true"
|
||||
:data="filteredLists"
|
||||
@select="selectList"
|
||||
field="name">
|
||||
</b-autocomplete>
|
||||
</b-field>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Vue from 'vue';
|
||||
|
||||
export default {
|
||||
name: 'ListSelector',
|
||||
|
||||
props: {
|
||||
label: String,
|
||||
placeholder: String,
|
||||
message: String,
|
||||
required: Boolean,
|
||||
disabled: Boolean,
|
||||
classes: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
selected: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
all: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
selectedItems: [],
|
||||
};
|
||||
},
|
||||
|
||||
methods: {
|
||||
selectList(l) {
|
||||
if (!l) {
|
||||
return;
|
||||
}
|
||||
this.selectedItems.push(l);
|
||||
|
||||
// Propagate the items to the parent's v-model binding.
|
||||
Vue.nextTick(() => {
|
||||
this.$emit('input', this.selectedItems);
|
||||
});
|
||||
},
|
||||
|
||||
removeList(id) {
|
||||
this.selectedItems = this.selectedItems.filter((l) => l.id !== id);
|
||||
|
||||
// Propagate the items to the parent's v-model binding.
|
||||
Vue.nextTick(() => {
|
||||
this.$emit('input', this.selectedItems);
|
||||
});
|
||||
},
|
||||
},
|
||||
|
||||
computed: {
|
||||
// Returns the list of lists to which the subscriber isn't subscribed.
|
||||
filteredLists() {
|
||||
// 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 }), {});
|
||||
|
||||
// Filter lists from the global lists whose IDs are not in the user's
|
||||
// subscribed ist.
|
||||
return this.$props.all.filter((l) => !(l.id in subIDs));
|
||||
},
|
||||
},
|
||||
|
||||
watch: {
|
||||
// This is required to update the array of lists to propagate from parent
|
||||
// components and "react" on the selector.
|
||||
selected() {
|
||||
// Deep-copy.
|
||||
this.selectedItems = JSON.parse(JSON.stringify(this.selected));
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
|
@ -1,126 +1,27 @@
|
|||
export const DateFormat = "ddd D MMM YYYY, hh:mm A"
|
||||
export const models = Object.freeze({
|
||||
dashboard: 'dashboard',
|
||||
lists: 'lists',
|
||||
subscribers: 'subscribers',
|
||||
campaigns: 'campaigns',
|
||||
templates: 'templates',
|
||||
media: 'media',
|
||||
});
|
||||
|
||||
// Data types.
|
||||
export const ModelUsers = "users"
|
||||
export const ModelSubscribers = "subscribers"
|
||||
export const ModelSubscribersByList = "subscribersByList"
|
||||
export const ModelLists = "lists"
|
||||
export const ModelMedia = "media"
|
||||
export const ModelCampaigns = "campaigns"
|
||||
export const ModelTemplates = "templates"
|
||||
// Ad-hoc URIs that are used outside of vuex requests.
|
||||
export const uris = Object.freeze({
|
||||
previewCampaign: '/api/campaigns/:id/preview',
|
||||
previewTemplate: '/api/templates/:id/preview',
|
||||
previewRawTemplate: '/api/templates/preview',
|
||||
});
|
||||
|
||||
// HTTP methods.
|
||||
export const MethodGet = "get"
|
||||
export const MethodPost = "post"
|
||||
export const MethodPut = "put"
|
||||
export const MethodDelete = "delete"
|
||||
// Keys used in Vuex store.
|
||||
export const storeKeys = Object.freeze({
|
||||
models: 'models',
|
||||
isLoading: 'isLoading',
|
||||
});
|
||||
|
||||
// Data loading states.
|
||||
export const StatePending = "pending"
|
||||
export const StateDone = "done"
|
||||
export const timestamp = 'ddd D MMM YYYY, hh:mm A';
|
||||
|
||||
// Form types.
|
||||
export const FormCreate = "create"
|
||||
export const FormEdit = "edit"
|
||||
|
||||
// Message types.
|
||||
export const MsgSuccess = "success"
|
||||
export const MsgWarning = "warning"
|
||||
export const MsgError = "error"
|
||||
export const MsgPosition = "bottomRight"
|
||||
|
||||
// Model specific.
|
||||
export const CampaignStatusColors = {
|
||||
draft: "",
|
||||
scheduled: "purple",
|
||||
running: "blue",
|
||||
paused: "orange",
|
||||
finished: "green",
|
||||
cancelled: "red"
|
||||
}
|
||||
|
||||
export const CampaignStatusDraft = "draft"
|
||||
export const CampaignStatusScheduled = "scheduled"
|
||||
export const CampaignStatusRunning = "running"
|
||||
export const CampaignStatusPaused = "paused"
|
||||
export const CampaignStatusFinished = "finished"
|
||||
export const CampaignStatusCancelled = "cancelled"
|
||||
export const CampaignStatusRegular = "regular"
|
||||
export const CampaignStatusOptin = "optin"
|
||||
|
||||
export const CampaignTypeRegular = "regular"
|
||||
export const CampaignTypeOptin = "optin"
|
||||
|
||||
export const CampaignContentTypeRichtext = "richtext"
|
||||
export const CampaignContentTypeHTML = "html"
|
||||
export const CampaignContentTypePlain = "plain"
|
||||
|
||||
export const SubscriptionStatusConfirmed = "confirmed"
|
||||
export const SubscriptionStatusUnConfirmed = "unconfirmed"
|
||||
export const SubscriptionStatusUnsubscribed = "unsubscribed"
|
||||
|
||||
export const ListOptinSingle = "single"
|
||||
export const ListOptinDouble = "double"
|
||||
|
||||
// API routes.
|
||||
export const Routes = {
|
||||
GetDashboarcStats: "/api/dashboard/stats",
|
||||
GetUsers: "/api/users",
|
||||
|
||||
// Lists.
|
||||
GetLists: "/api/lists",
|
||||
CreateList: "/api/lists",
|
||||
UpdateList: "/api/lists/:id",
|
||||
DeleteList: "/api/lists/:id",
|
||||
|
||||
// Subscribers.
|
||||
ViewSubscribers: "/subscribers",
|
||||
GetSubscribers: "/api/subscribers",
|
||||
GetSubscriber: "/api/subscribers/:id",
|
||||
GetSubscribersByList: "/api/subscribers/lists/:listID",
|
||||
PreviewCampaign: "/api/campaigns/:id/preview",
|
||||
CreateSubscriber: "/api/subscribers",
|
||||
UpdateSubscriber: "/api/subscribers/:id",
|
||||
DeleteSubscriber: "/api/subscribers/:id",
|
||||
DeleteSubscribers: "/api/subscribers",
|
||||
SendSubscriberOptinMail: "/api/subscribers/:id/optin",
|
||||
BlacklistSubscriber: "/api/subscribers/:id/blacklist",
|
||||
BlacklistSubscribers: "/api/subscribers/blacklist",
|
||||
AddSubscriberToLists: "/api/subscribers/lists/:id",
|
||||
AddSubscribersToLists: "/api/subscribers/lists",
|
||||
DeleteSubscribersByQuery: "/api/subscribers/query/delete",
|
||||
BlacklistSubscribersByQuery: "/api/subscribers/query/blacklist",
|
||||
AddSubscribersToListsByQuery: "/api/subscribers/query/lists",
|
||||
|
||||
// Campaigns.
|
||||
ViewCampaigns: "/campaigns",
|
||||
ViewCampaign: "/campaigns/:id",
|
||||
GetCampaignMessengers: "/api/campaigns/messengers",
|
||||
GetCampaigns: "/api/campaigns",
|
||||
GetCampaign: "/api/campaigns/:id",
|
||||
GetRunningCampaignStats: "/api/campaigns/running/stats",
|
||||
CreateCampaign: "/api/campaigns",
|
||||
TestCampaign: "/api/campaigns/:id/test",
|
||||
UpdateCampaign: "/api/campaigns/:id",
|
||||
UpdateCampaignStatus: "/api/campaigns/:id/status",
|
||||
DeleteCampaign: "/api/campaigns/:id",
|
||||
|
||||
// Media.
|
||||
GetMedia: "/api/media",
|
||||
AddMedia: "/api/media",
|
||||
DeleteMedia: "/api/media/:id",
|
||||
|
||||
// Templates.
|
||||
GetTemplates: "/api/templates",
|
||||
PreviewTemplate: "/api/templates/:id/preview",
|
||||
PreviewNewTemplate: "/api/templates/preview",
|
||||
CreateTemplate: "/api/templates",
|
||||
UpdateTemplate: "/api/templates/:id",
|
||||
SetDefaultTemplate: "/api/templates/:id/default",
|
||||
DeleteTemplate: "/api/templates/:id",
|
||||
|
||||
// Import.
|
||||
UploadRouteImport: "/api/import/subscribers",
|
||||
GetRouteImportStats: "/api/import/subscribers",
|
||||
GetRouteImportLogs: "/api/import/subscribers/logs"
|
||||
}
|
||||
export const colors = Object.freeze({
|
||||
primary: '#7f2aff',
|
||||
});
|
||||
|
|
|
@ -1,391 +0,0 @@
|
|||
/* Disable all the ridiculous, unnecessary animations except for the spinner */
|
||||
*:not(.ant-spin-dot-spin) {
|
||||
animation-duration: 0s;
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
body {
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
header.header {
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
hr {
|
||||
border-width: 1px 0 0 0;
|
||||
border-style: solid;
|
||||
border-color: #eee;
|
||||
margin: 30px 0;
|
||||
}
|
||||
|
||||
/* Helpers */
|
||||
.center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.right {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.text-tiny {
|
||||
font-size: 0.65em;
|
||||
}
|
||||
|
||||
.text-small {
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.text-grey {
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.empty-spinner {
|
||||
padding: 30px !important;
|
||||
}
|
||||
|
||||
ul.no {
|
||||
list-style-type: none;
|
||||
}
|
||||
ul.no li {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
/* Layout */
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: sans-serif;
|
||||
}
|
||||
|
||||
.content-body {
|
||||
min-height: 90vh;
|
||||
}
|
||||
|
||||
section.content {
|
||||
padding: 24px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.logo {
|
||||
padding: 30px;
|
||||
}
|
||||
.logo a {
|
||||
overflow: hidden;
|
||||
display: inline-block;
|
||||
}
|
||||
.logo img {
|
||||
width: auto;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
.ant-layout-sider.ant-layout-sider-collapsed .logo a {
|
||||
width: 20px;
|
||||
}
|
||||
|
||||
.ant-card-head-title {
|
||||
font-size: .85em !important;
|
||||
color: #999 !important;
|
||||
}
|
||||
|
||||
.broken {
|
||||
margin: 100px;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Form */
|
||||
.list-form .html {
|
||||
background: #fafafa;
|
||||
padding: 30px;
|
||||
max-width: 100%;
|
||||
overflow-y: auto;
|
||||
max-height: 600px;
|
||||
}
|
||||
.list-form .lists label {
|
||||
display: block;
|
||||
}
|
||||
|
||||
|
||||
/* Table actions */
|
||||
td .actions a {
|
||||
display: inline-block;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
td.actions {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
td .ant-tag {
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
/* External options */
|
||||
.table-options {
|
||||
}
|
||||
.table-options a {
|
||||
margin-right: 30px;
|
||||
}
|
||||
|
||||
/* Dashboard */
|
||||
.dashboard {
|
||||
margin: 24px;
|
||||
}
|
||||
.dashboard .campaign-counts .name {
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
/* Templates */
|
||||
.wysiwyg {
|
||||
padding: 30px;
|
||||
}
|
||||
|
||||
/* Subscribers */
|
||||
.subscribers table .name {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.subscriber-query {
|
||||
margin: 0 0 15px 0;
|
||||
padding: 30px;
|
||||
box-shadow: 0 1px 6px #ddd;
|
||||
min-height: 140px;
|
||||
}
|
||||
.subscriber-query .advanced-query {
|
||||
margin-top: 15px;
|
||||
}
|
||||
.subscriber-query textarea {
|
||||
font-family: monospace;
|
||||
}
|
||||
.subscriber-query .actions {
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.subscription-status {
|
||||
color: #999;
|
||||
}
|
||||
.subscription-status.confirmed {
|
||||
color: #52c41a;
|
||||
}
|
||||
.subscription-status.unsubscribed {
|
||||
color: #ff7875;
|
||||
}
|
||||
|
||||
/* Import */
|
||||
.import .import-container {
|
||||
margin-top: 100px;
|
||||
}
|
||||
.import .logs,
|
||||
.import .help {
|
||||
max-width: 950px;
|
||||
margin-top: 30px;
|
||||
}
|
||||
.import .stats .ant-progress {
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
.import .csv-example {
|
||||
background: #efefef;
|
||||
padding: 5px 10px;
|
||||
display: inline-block;
|
||||
}
|
||||
.import .csv-example code {
|
||||
display: block;
|
||||
}
|
||||
.import .csv-example .csv-headers span {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* Campaigns */
|
||||
.campaigns table tbody td {
|
||||
vertical-align: top;
|
||||
border-bottom-width: 3px;
|
||||
border-bottom-color: #efefef;
|
||||
}
|
||||
.campaigns td.status .date {
|
||||
display: block;
|
||||
margin-top: 5px;
|
||||
}
|
||||
.campaigns td.lists .name {
|
||||
margin-right: 15px;
|
||||
}
|
||||
.campaigns td hr {
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.campaigns td.stats .ant-row {
|
||||
border-bottom: 1px solid #eee;
|
||||
padding: 5px 0;
|
||||
}
|
||||
.campaigns td.stats .ant-row:last-child {
|
||||
border: 0;
|
||||
}
|
||||
.campaigns td.stats .label {
|
||||
font-weight: 600;
|
||||
color: #aaa;
|
||||
}
|
||||
.campaigns .duration {
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.campaign .messengers {
|
||||
text-transform: capitalize;
|
||||
}
|
||||
.campaign .content-type .actions {
|
||||
display: inline-block;
|
||||
margin-left: 15px;
|
||||
}
|
||||
.campaign .content-actions {
|
||||
margin-top: 30px;
|
||||
}
|
||||
|
||||
/* gallery */
|
||||
.gallery {
|
||||
display:flex;
|
||||
flex-direction: row;
|
||||
flex-flow: wrap;
|
||||
}
|
||||
.gallery .image {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
min-height: 90px;
|
||||
|
||||
padding: 10px;
|
||||
border: 1px solid #eee;
|
||||
overflow: hidden;
|
||||
margin: 10px;
|
||||
text-align: center;
|
||||
position: relative;
|
||||
}
|
||||
.gallery .name {
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
padding: 3px 5px;
|
||||
width: 100%;
|
||||
font-size: .75em;
|
||||
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.gallery .actions {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 15px;
|
||||
display: none;
|
||||
text-align: center;
|
||||
}
|
||||
.gallery .actions a {
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
padding: 0 3px 3px 3px;
|
||||
border-radius: 0 0 3px 3px;
|
||||
display: inline-block;
|
||||
margin-left: 5px;
|
||||
}
|
||||
.gallery .image:hover .actions {
|
||||
display: block;
|
||||
}
|
||||
.gallery .image img {
|
||||
max-width: 90px;
|
||||
max-height: 90px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* gallery icon in the wsiwyg */
|
||||
.ql-gallery {
|
||||
background: url('/gallery.svg');
|
||||
}
|
||||
|
||||
/* templates */
|
||||
.templates .template-body {
|
||||
margin-top: 30px;
|
||||
}
|
||||
.preview-iframe-container {
|
||||
min-height: 500px;
|
||||
}
|
||||
.preview-iframe-spinner {
|
||||
position: absolute !important;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
left: calc(50% - 40px);
|
||||
top: calc(30%);
|
||||
/* top: 15px; */
|
||||
}
|
||||
.preview-iframe {
|
||||
border: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 500px;
|
||||
}
|
||||
.preview-modal .ant-modal-footer button:first-child {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 1200px) {
|
||||
.dashboard .ant-card {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 1023px) {
|
||||
.ant-table-content {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.ant-table-thead > tr > th,
|
||||
.ant-table-tbody > tr > td {
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 768px) {
|
||||
.ant-modal {
|
||||
top: 0 !important;
|
||||
}
|
||||
|
||||
hr {
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.subscriber-query {
|
||||
padding: 20px
|
||||
}
|
||||
|
||||
.header-action-break {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.subscribers.content .slc-subs-section .table-options {
|
||||
margin-top: 20px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid #f4f4f4;
|
||||
}
|
||||
|
||||
.subscribers.content .slc-subs-actions a {
|
||||
display: block;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.ant-modal.subscriber-modal .subscriber-export {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.ant-modal.subscriber-modal .subscriber-name {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.dashboard {
|
||||
margin: 24px 12px;
|
||||
}
|
||||
}
|
|
@ -1,7 +0,0 @@
|
|||
import React from "react"
|
||||
import ReactDOM from "react-dom"
|
||||
|
||||
import "./index.css"
|
||||
import App from "./App.js"
|
||||
|
||||
ReactDOM.render(<App />, document.getElementById("root"))
|
|
@ -1,7 +0,0 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 841.9 595.3">
|
||||
<g fill="#61DAFB">
|
||||
<path d="M666.3 296.5c0-32.5-40.7-63.3-103.1-82.4 14.4-63.6 8-114.2-20.2-130.4-6.5-3.8-14.1-5.6-22.4-5.6v22.3c4.6 0 8.3.9 11.4 2.6 13.6 7.8 19.5 37.5 14.9 75.7-1.1 9.4-2.9 19.3-5.1 29.4-19.6-4.8-41-8.5-63.5-10.9-13.5-18.5-27.5-35.3-41.6-50 32.6-30.3 63.2-46.9 84-46.9V78c-27.5 0-63.5 19.6-99.9 53.6-36.4-33.8-72.4-53.2-99.9-53.2v22.3c20.7 0 51.4 16.5 84 46.6-14 14.7-28 31.4-41.3 49.9-22.6 2.4-44 6.1-63.6 11-2.3-10-4-19.7-5.2-29-4.7-38.2 1.1-67.9 14.6-75.8 3-1.8 6.9-2.6 11.5-2.6V78.5c-8.4 0-16 1.8-22.6 5.6-28.1 16.2-34.4 66.7-19.9 130.1-62.2 19.2-102.7 49.9-102.7 82.3 0 32.5 40.7 63.3 103.1 82.4-14.4 63.6-8 114.2 20.2 130.4 6.5 3.8 14.1 5.6 22.5 5.6 27.5 0 63.5-19.6 99.9-53.6 36.4 33.8 72.4 53.2 99.9 53.2 8.4 0 16-1.8 22.6-5.6 28.1-16.2 34.4-66.7 19.9-130.1 62-19.1 102.5-49.9 102.5-82.3zm-130.2-66.7c-3.7 12.9-8.3 26.2-13.5 39.5-4.1-8-8.4-16-13.1-24-4.6-8-9.5-15.8-14.4-23.4 14.2 2.1 27.9 4.7 41 7.9zm-45.8 106.5c-7.8 13.5-15.8 26.3-24.1 38.2-14.9 1.3-30 2-45.2 2-15.1 0-30.2-.7-45-1.9-8.3-11.9-16.4-24.6-24.2-38-7.6-13.1-14.5-26.4-20.8-39.8 6.2-13.4 13.2-26.8 20.7-39.9 7.8-13.5 15.8-26.3 24.1-38.2 14.9-1.3 30-2 45.2-2 15.1 0 30.2.7 45 1.9 8.3 11.9 16.4 24.6 24.2 38 7.6 13.1 14.5 26.4 20.8 39.8-6.3 13.4-13.2 26.8-20.7 39.9zm32.3-13c5.4 13.4 10 26.8 13.8 39.8-13.1 3.2-26.9 5.9-41.2 8 4.9-7.7 9.8-15.6 14.4-23.7 4.6-8 8.9-16.1 13-24.1zM421.2 430c-9.3-9.6-18.6-20.3-27.8-32 9 .4 18.2.7 27.5.7 9.4 0 18.7-.2 27.8-.7-9 11.7-18.3 22.4-27.5 32zm-74.4-58.9c-14.2-2.1-27.9-4.7-41-7.9 3.7-12.9 8.3-26.2 13.5-39.5 4.1 8 8.4 16 13.1 24 4.7 8 9.5 15.8 14.4 23.4zM420.7 163c9.3 9.6 18.6 20.3 27.8 32-9-.4-18.2-.7-27.5-.7-9.4 0-18.7.2-27.8.7 9-11.7 18.3-22.4 27.5-32zm-74 58.9c-4.9 7.7-9.8 15.6-14.4 23.7-4.6 8-8.9 16-13 24-5.4-13.4-10-26.8-13.8-39.8 13.1-3.1 26.9-5.8 41.2-7.9zm-90.5 125.2c-35.4-15.1-58.3-34.9-58.3-50.6 0-15.7 22.9-35.6 58.3-50.6 8.6-3.7 18-7 27.7-10.1 5.7 19.6 13.2 40 22.5 60.9-9.2 20.8-16.6 41.1-22.2 60.6-9.9-3.1-19.3-6.5-28-10.2zM310 490c-13.6-7.8-19.5-37.5-14.9-75.7 1.1-9.4 2.9-19.3 5.1-29.4 19.6 4.8 41 8.5 63.5 10.9 13.5 18.5 27.5 35.3 41.6 50-32.6 30.3-63.2 46.9-84 46.9-4.5-.1-8.3-1-11.3-2.7zm237.2-76.2c4.7 38.2-1.1 67.9-14.6 75.8-3 1.8-6.9 2.6-11.5 2.6-20.7 0-51.4-16.5-84-46.6 14-14.7 28-31.4 41.3-49.9 22.6-2.4 44-6.1 63.6-11 2.3 10.1 4.1 19.8 5.2 29.1zm38.5-66.7c-8.6 3.7-18 7-27.7 10.1-5.7-19.6-13.2-40-22.5-60.9 9.2-20.8 16.6-41.1 22.2-60.6 9.9 3.1 19.3 6.5 28.1 10.2 35.4 15.1 58.3 34.9 58.3 50.6-.1 15.7-23 35.6-58.4 50.6zM320.8 78.4z"/>
|
||||
<circle cx="420.9" cy="296.5" r="45.7"/>
|
||||
<path d="M520.5 78.1z"/>
|
||||
</g>
|
||||
</svg>
|
Before Width: | Height: | Size: 2.6 KiB |
|
@ -0,0 +1,21 @@
|
|||
import Vue from 'vue';
|
||||
import Buefy from 'buefy';
|
||||
|
||||
import App from './App.vue';
|
||||
import router from './router';
|
||||
import store from './store';
|
||||
import * as api from './api';
|
||||
import utils from './utils';
|
||||
|
||||
Vue.use(Buefy, {});
|
||||
Vue.config.productionTip = false;
|
||||
|
||||
// Custom global elements.
|
||||
Vue.prototype.$api = api;
|
||||
Vue.prototype.$utils = utils;
|
||||
|
||||
new Vue({
|
||||
router,
|
||||
store,
|
||||
render: (h) => h(App),
|
||||
}).$mount('#app');
|
|
@ -1,117 +0,0 @@
|
|||
// In production, we register a service worker to serve assets from local cache.
|
||||
|
||||
// This lets the app load faster on subsequent visits in production, and gives
|
||||
// it offline capabilities. However, it also means that developers (and users)
|
||||
// will only see deployed updates on the "N+1" visit to a page, since previously
|
||||
// cached resources are updated in the background.
|
||||
|
||||
// To learn more about the benefits of this model, read https://goo.gl/KwvDNy.
|
||||
// This link also includes instructions on opting out of this behavior.
|
||||
|
||||
const isLocalhost = Boolean(
|
||||
window.location.hostname === 'localhost' ||
|
||||
// [::1] is the IPv6 localhost address.
|
||||
window.location.hostname === '[::1]' ||
|
||||
// 127.0.0.1/8 is considered localhost for IPv4.
|
||||
window.location.hostname.match(
|
||||
/^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/
|
||||
)
|
||||
);
|
||||
|
||||
export default function register() {
|
||||
if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) {
|
||||
// The URL constructor is available in all browsers that support SW.
|
||||
const publicUrl = new URL(process.env.PUBLIC_URL, window.location);
|
||||
if (publicUrl.origin !== window.location.origin) {
|
||||
// Our service worker won't work if PUBLIC_URL is on a different origin
|
||||
// from what our page is served on. This might happen if a CDN is used to
|
||||
// serve assets; see https://github.com/facebookincubator/create-react-app/issues/2374
|
||||
return;
|
||||
}
|
||||
|
||||
window.addEventListener('load', () => {
|
||||
const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`;
|
||||
|
||||
if (isLocalhost) {
|
||||
// This is running on localhost. Lets check if a service worker still exists or not.
|
||||
checkValidServiceWorker(swUrl);
|
||||
|
||||
// Add some additional logging to localhost, pointing developers to the
|
||||
// service worker/PWA documentation.
|
||||
navigator.serviceWorker.ready.then(() => {
|
||||
console.log(
|
||||
'This web app is being served cache-first by a service ' +
|
||||
'worker. To learn more, visit https://goo.gl/SC7cgQ'
|
||||
);
|
||||
});
|
||||
} else {
|
||||
// Is not local host. Just register service worker
|
||||
registerValidSW(swUrl);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function registerValidSW(swUrl) {
|
||||
navigator.serviceWorker
|
||||
.register(swUrl)
|
||||
.then(registration => {
|
||||
registration.onupdatefound = () => {
|
||||
const installingWorker = registration.installing;
|
||||
installingWorker.onstatechange = () => {
|
||||
if (installingWorker.state === 'installed') {
|
||||
if (navigator.serviceWorker.controller) {
|
||||
// At this point, the old content will have been purged and
|
||||
// the fresh content will have been added to the cache.
|
||||
// It's the perfect time to display a "New content is
|
||||
// available; please refresh." message in your web app.
|
||||
console.log('New content is available; please refresh.');
|
||||
} else {
|
||||
// At this point, everything has been precached.
|
||||
// It's the perfect time to display a
|
||||
// "Content is cached for offline use." message.
|
||||
console.log('Content is cached for offline use.');
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error during service worker registration:', error);
|
||||
});
|
||||
}
|
||||
|
||||
function checkValidServiceWorker(swUrl) {
|
||||
// Check if the service worker can be found. If it can't reload the page.
|
||||
fetch(swUrl)
|
||||
.then(response => {
|
||||
// Ensure service worker exists, and that we really are getting a JS file.
|
||||
if (
|
||||
response.status === 404 ||
|
||||
response.headers.get('content-type').indexOf('javascript') === -1
|
||||
) {
|
||||
// No service worker found. Probably a different app. Reload the page.
|
||||
navigator.serviceWorker.ready.then(registration => {
|
||||
registration.unregister().then(() => {
|
||||
window.location.reload();
|
||||
});
|
||||
});
|
||||
} else {
|
||||
// Service worker found. Proceed as normal.
|
||||
registerValidSW(swUrl);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
console.log(
|
||||
'No internet connection found. App is running in offline mode.'
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export function unregister() {
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.ready.then(registration => {
|
||||
registration.unregister();
|
||||
});
|
||||
}
|
||||
}
|
|
@ -0,0 +1,95 @@
|
|||
import Vue from 'vue';
|
||||
import VueRouter from 'vue-router';
|
||||
|
||||
Vue.use(VueRouter);
|
||||
|
||||
// The meta.group param is used in App.vue to expand menu group by name.
|
||||
const routes = [
|
||||
{
|
||||
path: '/',
|
||||
name: 'dashboard',
|
||||
meta: { title: 'Dashboard' },
|
||||
component: () => import(/* webpackChunkName: "main" */ '../views/Dashboard.vue'),
|
||||
},
|
||||
{
|
||||
path: '/lists',
|
||||
name: 'lists',
|
||||
meta: { title: 'Lists', group: 'lists' },
|
||||
component: () => import(/* webpackChunkName: "main" */ '../views/Lists.vue'),
|
||||
},
|
||||
{
|
||||
path: '/lists/forms',
|
||||
name: 'forms',
|
||||
meta: { title: 'Forms', group: 'lists' },
|
||||
component: () => import(/* webpackChunkName: "main" */ '../views/Forms.vue'),
|
||||
},
|
||||
{
|
||||
path: '/subscribers',
|
||||
name: 'subscribers',
|
||||
meta: { title: 'Subscribers', group: 'subscribers' },
|
||||
component: () => import(/* webpackChunkName: "main" */ '../views/Subscribers.vue'),
|
||||
},
|
||||
{
|
||||
path: '/subscribers/import',
|
||||
name: 'import',
|
||||
meta: { title: 'Import subscribers', group: 'subscribers' },
|
||||
component: () => import(/* webpackChunkName: "main" */ '../views/Import.vue'),
|
||||
},
|
||||
{
|
||||
path: '/subscribers/lists/:listID',
|
||||
name: 'subscribers_list',
|
||||
meta: { title: 'Subscribers', group: 'subscribers' },
|
||||
component: () => import(/* webpackChunkName: "main" */ '../views/Subscribers.vue'),
|
||||
},
|
||||
{
|
||||
path: '/subscribers/:id',
|
||||
name: 'subscriber',
|
||||
meta: { title: 'Subscribers', group: 'subscribers' },
|
||||
component: () => import(/* webpackChunkName: "main" */ '../views/Subscribers.vue'),
|
||||
},
|
||||
{
|
||||
path: '/campaigns',
|
||||
name: 'campaigns',
|
||||
meta: { title: 'Campaigns', group: 'campaigns' },
|
||||
component: () => import(/* webpackChunkName: "main" */ '../views/Campaigns.vue'),
|
||||
},
|
||||
{
|
||||
path: '/campaigns/media',
|
||||
name: 'media',
|
||||
meta: { title: 'Media', group: 'campaigns' },
|
||||
component: () => import(/* webpackChunkName: "main" */ '../views/Media.vue'),
|
||||
},
|
||||
{
|
||||
path: '/campaigns/templates',
|
||||
name: 'templates',
|
||||
meta: { title: 'Templates', group: 'campaigns' },
|
||||
component: () => import(/* webpackChunkName: "main" */ '../views/Templates.vue'),
|
||||
},
|
||||
{
|
||||
path: '/campaigns/:id',
|
||||
name: 'campaign',
|
||||
meta: { title: 'Campaign', group: 'campaigns' },
|
||||
component: () => import(/* webpackChunkName: "main" */ '../views/Campaign.vue'),
|
||||
},
|
||||
];
|
||||
|
||||
const router = new VueRouter({
|
||||
mode: 'history',
|
||||
base: process.env.BASE_URL,
|
||||
routes,
|
||||
|
||||
scrollBehavior(to) {
|
||||
if (to.hash) {
|
||||
return { selector: to.hash };
|
||||
}
|
||||
return { x: 0, y: 0 };
|
||||
},
|
||||
});
|
||||
|
||||
router.afterEach((to) => {
|
||||
Vue.nextTick(() => {
|
||||
document.title = to.meta.title;
|
||||
});
|
||||
});
|
||||
|
||||
export default router;
|
|
@ -1 +0,0 @@
|
|||
<svg viewbox="0 0 18 18"><rect class="ql-stroke" height="10" width="12" x="3" y="4"></rect><circle class="ql-fill" cx="6" cy="7" r="1"></circle><polyline class="ql-even ql-fill" points="5 12 5 11 7 9 8 10 11 7 13 9 13 12 5 12"></polyline></svg>
|
Before Width: | Height: | Size: 244 B |
|
@ -0,0 +1,48 @@
|
|||
import Vue from 'vue';
|
||||
import Vuex from 'vuex';
|
||||
import { models } from '../constants';
|
||||
|
||||
Vue.use(Vuex);
|
||||
|
||||
export default new Vuex.Store({
|
||||
state: {
|
||||
// Data from API responses for different models, eg: lists, campaigns.
|
||||
// The API responses are stored in this map as-is. This is invoked by
|
||||
// API requests in `http`. This initialises lists: {}, campaigns: {}
|
||||
// etc. on state.
|
||||
...Object.keys(models).reduce((obj, cur) => ({ ...obj, [cur]: [] }), {}),
|
||||
|
||||
// Map of loading status (true, false) indicators for different model keys
|
||||
// like lists, campaigns etc. loading: {lists: true, campaigns: true ...}.
|
||||
// The Axios API global request interceptor marks a model as loading=true
|
||||
// and the response interceptor marks it as false. The model keys are being
|
||||
// pre-initialised here to fix "reactivity" issues on first loads.
|
||||
loading: Object.keys(models).reduce((obj, cur) => ({ ...obj, [cur]: false }), {}),
|
||||
},
|
||||
|
||||
mutations: {
|
||||
// Set data from API responses. `model` is 'lists', 'campaigns' etc.
|
||||
setModelResponse(state, { model, data }) {
|
||||
state[model] = data;
|
||||
},
|
||||
|
||||
// Set the loading status for a model globally. When a request starts,
|
||||
// status is set to true which is used by the UI to show loaders and block
|
||||
// forms. When a response is received, the status is set to false. This is
|
||||
// invoked by API requests in `http`.
|
||||
setLoading(state, { model, status }) {
|
||||
state.loading[model] = status;
|
||||
},
|
||||
},
|
||||
|
||||
getters: {
|
||||
[models.lists]: (state) => state[models.lists],
|
||||
[models.subscribers]: (state) => state[models.subscribers],
|
||||
[models.campaigns]: (state) => state[models.campaigns],
|
||||
[models.media]: (state) => state[models.media],
|
||||
[models.templates]: (state) => state[models.templates],
|
||||
},
|
||||
|
||||
modules: {
|
||||
},
|
||||
});
|
|
@ -1,82 +1,96 @@
|
|||
import React from "react"
|
||||
import ReactDOM from "react-dom"
|
||||
import {
|
||||
ToastProgrammatic as Toast,
|
||||
DialogProgrammatic as Dialog,
|
||||
} from 'buefy';
|
||||
|
||||
import { Alert } from "antd"
|
||||
const reEmail = /(.+?)@(.+?)/ig;
|
||||
|
||||
class Utils {
|
||||
static months = [
|
||||
"Jan",
|
||||
"Feb",
|
||||
"Mar",
|
||||
"Apr",
|
||||
"May",
|
||||
"Jun",
|
||||
"Jul",
|
||||
"Aug",
|
||||
"Sep",
|
||||
"Oct",
|
||||
"Nov",
|
||||
"Dec"
|
||||
]
|
||||
static days = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]
|
||||
export default class utils {
|
||||
static months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug',
|
||||
'Sep', 'Oct', 'Nov', 'Dec'];
|
||||
|
||||
// Converts the ISO date format to a simpler form.
|
||||
static DateString = (stamp, showTime) => {
|
||||
static days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||
|
||||
// Parses an ISO timestamp to a simpler form.
|
||||
static niceDate = (stamp, showTime) => {
|
||||
if (!stamp) {
|
||||
return ""
|
||||
return '';
|
||||
}
|
||||
|
||||
let d = new Date(stamp)
|
||||
let out =
|
||||
Utils.days[d.getDay()] +
|
||||
", " +
|
||||
d.getDate() +
|
||||
" " +
|
||||
Utils.months[d.getMonth()] +
|
||||
" " +
|
||||
d.getFullYear()
|
||||
|
||||
const d = new Date(stamp);
|
||||
let out = `${utils.days[d.getDay()]}, ${d.getDate()}`;
|
||||
out += ` ${utils.months[d.getMonth()]} ${d.getFullYear()}`;
|
||||
if (showTime) {
|
||||
out += " " + d.getHours() + ":" + d.getMinutes()
|
||||
out += ` ${d.getHours()}:${d.getMinutes()}`;
|
||||
}
|
||||
|
||||
return out
|
||||
return out;
|
||||
};
|
||||
|
||||
// Simple, naive, e-mail address check.
|
||||
static validateEmail = (e) => e.match(reEmail);
|
||||
|
||||
static niceNumber = (n) => {
|
||||
if (n === null || n === undefined) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// HttpError takes an axios error and returns an error dict after some sanity checks.
|
||||
static HttpError = err => {
|
||||
if (!err.response) {
|
||||
return err
|
||||
let pfx = '';
|
||||
let div = 1;
|
||||
|
||||
if (n >= 1.0e+9) {
|
||||
pfx = 'b';
|
||||
div = 1.0e+9;
|
||||
} else if (n >= 1.0e+6) {
|
||||
pfx = 'm';
|
||||
div = 1.0e+6;
|
||||
} else if (n >= 1.0e+4) {
|
||||
pfx = 'k';
|
||||
div = 1.0e+3;
|
||||
} else {
|
||||
return n;
|
||||
}
|
||||
|
||||
if (!err.response.data || !err.response.data.message) {
|
||||
return {
|
||||
message: err.message + " - " + err.response.request.responseURL,
|
||||
data: {}
|
||||
}
|
||||
// Whole number without decimals.
|
||||
const out = (n / div);
|
||||
if (Math.floor(out) === n) {
|
||||
return out + pfx;
|
||||
}
|
||||
|
||||
return {
|
||||
message: err.response.data.message,
|
||||
data: err.response.data.data
|
||||
}
|
||||
return out.toFixed(2) + pfx;
|
||||
}
|
||||
|
||||
// Shows a flash message.
|
||||
static Alert = (msg, msgType) => {
|
||||
document.getElementById("alert-container").classList.add("visible")
|
||||
ReactDOM.render(
|
||||
<Alert message={msg} type={msgType} showIcon />,
|
||||
document.getElementById("alert-container")
|
||||
)
|
||||
}
|
||||
static ModalAlert = (msg, msgType) => {
|
||||
document.getElementById("modal-alert-container").classList.add("visible")
|
||||
ReactDOM.render(
|
||||
<Alert message={msg} type={msgType} showIcon />,
|
||||
document.getElementById("modal-alert-container")
|
||||
)
|
||||
}
|
||||
}
|
||||
// UI shortcuts.
|
||||
static confirm = (msg, onConfirm, onCancel) => {
|
||||
Dialog.confirm({
|
||||
scroll: 'keep',
|
||||
message: !msg ? 'Are you sure?' : msg,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
});
|
||||
};
|
||||
|
||||
export default Utils
|
||||
static prompt = (msg, inputAttrs, onConfirm, onCancel) => {
|
||||
Dialog.prompt({
|
||||
scroll: 'keep',
|
||||
message: msg,
|
||||
confirmText: 'OK',
|
||||
inputAttrs: {
|
||||
type: 'string',
|
||||
maxlength: 200,
|
||||
...inputAttrs,
|
||||
},
|
||||
trapFocus: true,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
});
|
||||
};
|
||||
|
||||
static toast = (msg, typ) => {
|
||||
Toast.open({
|
||||
message: msg,
|
||||
type: !typ ? 'is-success' : typ,
|
||||
queue: false,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
|
|
@ -0,0 +1,5 @@
|
|||
<template>
|
||||
<div class="about">
|
||||
<h1>This is an about page</h1>
|
||||
</div>
|
||||
</template>
|
|
@ -0,0 +1,369 @@
|
|||
<template>
|
||||
<section class="campaign">
|
||||
<header class="columns">
|
||||
<div class="column is-8">
|
||||
<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>
|
||||
<span v-if="isEditing" class="has-text-grey-light is-size-7">
|
||||
ID: {{ data.id }} / UUID: {{ data.uuid }}
|
||||
</span>
|
||||
</p>
|
||||
<h4 v-if="isEditing" class="title is-4">{{ data.name }}</h4>
|
||||
<h4 v-else class="title is-4">New campaign</h4>
|
||||
</div>
|
||||
|
||||
<div class="column">
|
||||
<div class="buttons" v-if="isEditing && canEdit">
|
||||
<b-button @click="onSubmit" :loading="loading.campaigns"
|
||||
type="is-primary" icon-left="content-save-outline">Save changes</b-button>
|
||||
|
||||
<b-button v-if="canStart" @click="startCampaign" :loading="loading.campaigns"
|
||||
type="is-primary" icon-left="rocket-launch-outline">
|
||||
Start campaign
|
||||
</b-button>
|
||||
<b-button v-if="canSchedule" @click="startCampaign" :loading="loading.campaigns"
|
||||
type="is-primary" icon-left="clock-start">
|
||||
Schedule campaign
|
||||
</b-button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<b-loading :active="loading.campaigns"></b-loading>
|
||||
|
||||
<b-tabs type="is-boxed" :animated="false" v-model="activeTab">
|
||||
<b-tab-item label="Campaign" icon="rocket-launch-outline">
|
||||
<section class="wrap">
|
||||
<div class="columns">
|
||||
<div class="column is-7">
|
||||
<form @submit.prevent="onSubmit">
|
||||
<b-field label="Name">
|
||||
<b-input :maxlength="200" :ref="'focus'" v-model="form.name" :disabled="!canEdit"
|
||||
placeholder="Name" required></b-input>
|
||||
</b-field>
|
||||
|
||||
<b-field label="Subject">
|
||||
<b-input :maxlength="200" v-model="form.subject" :disabled="!canEdit"
|
||||
placeholder="Subject" required></b-input>
|
||||
</b-field>
|
||||
|
||||
<b-field label="From address">
|
||||
<b-input :maxlength="200" v-model="form.fromEmail" :disabled="!canEdit"
|
||||
placeholder="Your Name <noreply@yoursite.com>" required></b-input>
|
||||
</b-field>
|
||||
|
||||
<list-selector
|
||||
v-model="form.lists"
|
||||
:selected="form.lists"
|
||||
:all="lists.results"
|
||||
:disabled="!canEdit"
|
||||
label="Lists"
|
||||
placeholder="Lists to send to"
|
||||
></list-selector>
|
||||
|
||||
<b-field label="Template">
|
||||
<b-select placeholder="Template" v-model="form.templateId"
|
||||
:disabled="!canEdit" required>
|
||||
<option v-for="t in templates" :value="t.id" :key="t.id">{{ t.name }}</option>
|
||||
</b-select>
|
||||
</b-field>
|
||||
|
||||
<b-field label="Tags">
|
||||
<b-taginput v-model="form.tags" :disabled="!canEdit"
|
||||
ellipsis icon="tag-outline" placeholder="Tags"></b-taginput>
|
||||
</b-field>
|
||||
<hr />
|
||||
|
||||
<b-field label="Send later?">
|
||||
<b-switch v-model="form.sendLater" :disabled="!canEdit"></b-switch>
|
||||
</b-field>
|
||||
|
||||
<b-field v-if="form.sendLater" label="Send at">
|
||||
<b-datetimepicker
|
||||
v-model="form.sendAtDate"
|
||||
:disabled="!canEdit"
|
||||
placeholder="Date and time"
|
||||
icon="calendar-clock"
|
||||
:timepicker="{ hourFormat: '24' }"
|
||||
:datetime-formatter="formatDateTime"
|
||||
horizontal-time-picker>
|
||||
</b-datetimepicker>
|
||||
</b-field>
|
||||
<hr />
|
||||
|
||||
<b-field v-if="isNew">
|
||||
<b-button native-type="submit" type="is-primary"
|
||||
:loading="loading.campaigns">Continue</b-button>
|
||||
</b-field>
|
||||
</form>
|
||||
</div>
|
||||
<div class="column is-4 is-offset-1">
|
||||
<br />
|
||||
<div class="box">
|
||||
<h3 class="title is-size-6">Send test message</h3>
|
||||
<b-field message="Hit Enter after typing an address to add multiple recipients.
|
||||
The addresses must belong to existing subscribers.">
|
||||
<b-taginput v-model="form.testEmails"
|
||||
:before-adding="$utils.validateEmail" :disabled="this.isNew"
|
||||
ellipsis icon="email-outline" placeholder="E-mails"></b-taginput>
|
||||
</b-field>
|
||||
<b-field>
|
||||
<b-button @click="sendTest" :loading="loading.campaigns" :disabled="this.isNew"
|
||||
type="is-primary" icon-left="email-outline">Send</b-button>
|
||||
</b-field>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</b-tab-item><!-- campaign -->
|
||||
|
||||
<b-tab-item label="Content" icon="text" :disabled="isNew">
|
||||
<section class="wrap">
|
||||
<editor
|
||||
v-model="form.content"
|
||||
:id="data.id"
|
||||
:title="data.name"
|
||||
:contentType="data.contentType"
|
||||
:body="data.body"
|
||||
:disabled="!canEdit"
|
||||
/>
|
||||
</section>
|
||||
</b-tab-item><!-- content -->
|
||||
</b-tabs>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Vue from 'vue';
|
||||
import { mapState } from 'vuex';
|
||||
import dayjs from 'dayjs';
|
||||
import ListSelector from '../components/ListSelector.vue';
|
||||
import Editor from '../components/Editor.vue';
|
||||
|
||||
export default Vue.extend({
|
||||
components: {
|
||||
ListSelector,
|
||||
Editor,
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
isNew: false,
|
||||
isEditing: false,
|
||||
activeTab: 0,
|
||||
|
||||
data: {},
|
||||
|
||||
// Binds form input values.
|
||||
form: {
|
||||
name: '',
|
||||
subject: '',
|
||||
fromEmail: window.CONFIG.fromEmail,
|
||||
templateId: 0,
|
||||
lists: [],
|
||||
tags: [],
|
||||
sendAt: null,
|
||||
content: { contentType: 'richtext', body: '' },
|
||||
|
||||
// Parsed Date() version of send_at from the API.
|
||||
sendAtDate: null,
|
||||
sendLater: false,
|
||||
|
||||
testEmails: [],
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
methods: {
|
||||
formatDateTime(s) {
|
||||
return dayjs(s).format('YYYY-MM-DD HH:mm');
|
||||
},
|
||||
|
||||
getCampaign(id) {
|
||||
return this.$api.getCampaign(id).then((r) => {
|
||||
this.data = r.data;
|
||||
this.form = { ...this.form, ...r.data };
|
||||
|
||||
if (r.data.sendAt !== null) {
|
||||
this.form.sendLater = true;
|
||||
this.form.sendAtDate = dayjs(r.data.sendAt).toDate();
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
sendTest() {
|
||||
const data = {
|
||||
id: this.data.id,
|
||||
name: this.form.name,
|
||||
subject: this.form.subject,
|
||||
lists: this.form.lists.map((l) => l.id),
|
||||
from_email: this.form.fromEmail,
|
||||
content_type: 'richtext',
|
||||
messenger: 'email',
|
||||
type: 'regular',
|
||||
tags: this.form.tags,
|
||||
template_id: this.form.templateId,
|
||||
body: this.form.body,
|
||||
subscribers: this.form.testEmails,
|
||||
};
|
||||
|
||||
this.$api.testCampaign(data).then(() => {
|
||||
this.$utils.toast('Test message sent');
|
||||
});
|
||||
return false;
|
||||
},
|
||||
|
||||
onSubmit() {
|
||||
if (this.isNew) {
|
||||
this.createCampaign();
|
||||
} else {
|
||||
this.updateCampaign();
|
||||
}
|
||||
},
|
||||
|
||||
createCampaign() {
|
||||
const data = {
|
||||
name: this.form.name,
|
||||
subject: this.form.subject,
|
||||
lists: this.form.lists.map((l) => l.id),
|
||||
from_email: this.form.fromEmail,
|
||||
content_type: 'richtext',
|
||||
messenger: 'email',
|
||||
type: 'regular',
|
||||
tags: this.form.tags,
|
||||
template_id: this.form.templateId,
|
||||
// body: this.form.body,
|
||||
};
|
||||
|
||||
this.$api.createCampaign(data).then((r) => {
|
||||
this.$router.push({ name: 'campaign', hash: '#content', params: { id: r.data.id } });
|
||||
|
||||
// this.data = r.data;
|
||||
// this.isEditing = true;
|
||||
// this.isNew = false;
|
||||
// this.activeTab = 1;
|
||||
});
|
||||
return false;
|
||||
},
|
||||
|
||||
async updateCampaign(typ) {
|
||||
const data = {
|
||||
name: this.form.name,
|
||||
subject: this.form.subject,
|
||||
lists: this.form.lists.map((l) => l.id),
|
||||
from_email: this.form.fromEmail,
|
||||
messenger: 'email',
|
||||
type: 'regular',
|
||||
tags: this.form.tags,
|
||||
send_later: this.form.sendLater,
|
||||
send_at: this.form.sendLater ? this.form.sendAtDate : null,
|
||||
template_id: this.form.templateId,
|
||||
content_type: this.form.content.contentType,
|
||||
body: this.form.content.body,
|
||||
};
|
||||
|
||||
let typMsg = 'updated';
|
||||
if (typ === 'start') {
|
||||
typMsg = 'started';
|
||||
}
|
||||
|
||||
// This promise is used by startCampaign to first save before starting.
|
||||
return new Promise((resolve) => {
|
||||
this.$api.updateCampaign(this.data.id, data).then((resp) => {
|
||||
this.data = resp.data;
|
||||
this.$buefy.toast.open({
|
||||
message: `'${resp.data.name}' ${typMsg}`,
|
||||
type: 'is-success',
|
||||
queue: false,
|
||||
});
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
// Starts or schedule a campaign.
|
||||
startCampaign() {
|
||||
let status = '';
|
||||
if (this.canStart) {
|
||||
status = 'running';
|
||||
} else if (this.canSchedule) {
|
||||
status = 'scheduled';
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
this.$utils.confirm(null,
|
||||
() => {
|
||||
// First save the campaign.
|
||||
this.updateCampaign().then(() => {
|
||||
// Then start/schedule it.
|
||||
this.$api.changeCampaignStatus(this.data.id, status).then(() => {
|
||||
this.$router.push({ name: 'campaigns' });
|
||||
});
|
||||
});
|
||||
});
|
||||
},
|
||||
},
|
||||
|
||||
computed: {
|
||||
...mapState(['lists', 'templates', 'loading']),
|
||||
|
||||
canEdit() {
|
||||
return this.isNew
|
||||
|| this.data.status === 'draft' || this.data.status === 'scheduled';
|
||||
},
|
||||
|
||||
canSchedule() {
|
||||
return this.data.status === 'draft' && this.data.sendAt;
|
||||
},
|
||||
|
||||
canStart() {
|
||||
return this.data.status === 'draft' && !this.data.sendAt;
|
||||
},
|
||||
},
|
||||
|
||||
mounted() {
|
||||
const { id } = this.$route.params;
|
||||
|
||||
// New campaign.
|
||||
if (id === 'new') {
|
||||
this.isNew = true;
|
||||
} else {
|
||||
const intID = parseInt(id, 10);
|
||||
if (intID <= 0 || Number.isNaN(intID)) {
|
||||
this.$buefy.toast.open({
|
||||
message: 'Invalid campaign',
|
||||
type: 'is-danger',
|
||||
queue: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this.isEditing = true;
|
||||
}
|
||||
|
||||
// Get templates list.
|
||||
this.$api.getTemplates().then((r) => {
|
||||
if (r.data.length > 0) {
|
||||
if (!this.form.templateId) {
|
||||
this.form.templateId = r.data.find((i) => i.isDefault === true).id;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Fetch campaign.
|
||||
if (this.isEditing) {
|
||||
this.getCampaign(id).then(() => {
|
||||
if (this.$route.hash === '#content') {
|
||||
this.activeTab = 1;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
this.$nextTick(() => {
|
||||
this.$refs.focus.focus();
|
||||
});
|
||||
},
|
||||
});
|
||||
</script>
|
|
@ -0,0 +1,365 @@
|
|||
<template>
|
||||
<section class="campaigns">
|
||||
<header class="columns">
|
||||
<div class="column is-two-thirds">
|
||||
<h1 class="title is-4">Campaigns
|
||||
<span v-if="campaigns.total > 0">({{ campaigns.total }})</span>
|
||||
</h1>
|
||||
</div>
|
||||
<div class="column has-text-right">
|
||||
<b-button :to="{name: 'campaign', params:{id: 'new'}}" tag="router-link"
|
||||
type="is-primary" icon-left="plus">New</b-button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<b-table
|
||||
:data="campaigns.results"
|
||||
:loading="loading.campaigns"
|
||||
:row-class="highlightedRow"
|
||||
paginated backend-pagination pagination-position="both" @page-change="onPageChange"
|
||||
:current-page="queryParams.page" :per-page="campaigns.perPage" :total="campaigns.total"
|
||||
hoverable>
|
||||
<template slot-scope="props">
|
||||
<b-table-column class="status" field="status" label="Status"
|
||||
width="10%" :id="props.row.id">
|
||||
<div>
|
||||
<p>
|
||||
<router-link :to="{ name: 'campaign', params: { 'id': props.row.id }}">
|
||||
<b-tag :class="props.row.status">{{ props.row.status }}</b-tag>
|
||||
<span class="spinner is-tiny" v-if="isRunning(props.row.id)">
|
||||
<b-loading :is-full-page="false" active />
|
||||
</span>
|
||||
</router-link>
|
||||
</p>
|
||||
<p v-if="isSheduled(props.row)">
|
||||
<b-tooltip label="Scheduled" type="is-dark">
|
||||
<span class="is-size-7 has-text-grey scheduled">
|
||||
<b-icon icon="alarm" size="is-small" />
|
||||
{{ $utils.niceDate(props.row.sendAt, true) }}
|
||||
</span>
|
||||
</b-tooltip>
|
||||
</p>
|
||||
</div>
|
||||
</b-table-column>
|
||||
<b-table-column field="name" label="Name" sortable width="25%">
|
||||
<div>
|
||||
<p>
|
||||
<router-link :to="{ name: 'campaign', params: { 'id': props.row.id }}">
|
||||
{{ props.row.name }}</router-link>
|
||||
</p>
|
||||
<p class="is-size-7 has-text-grey">{{ props.row.subject }}</p>
|
||||
<b-taglist>
|
||||
<b-tag v-for="t in props.row.tags" :key="t">{{ t }}</b-tag>
|
||||
</b-taglist>
|
||||
</div>
|
||||
</b-table-column>
|
||||
<b-table-column class="lists" field="lists" label="Lists" width="15%">
|
||||
<ul class="no">
|
||||
<li v-for="l in props.row.lists" :key="l.id">
|
||||
<router-link :to="{name: 'subscribers_list', params: { listID: l.id }}">
|
||||
{{ l.name }}
|
||||
</router-link>
|
||||
</li>
|
||||
</ul>
|
||||
</b-table-column>
|
||||
<b-table-column field="updatedAt" label="Timestamps" width="19%" sortable>
|
||||
<div class="fields timestamps" :set="stats = getCampaignStats(props.row)">
|
||||
<p>
|
||||
<label>Created</label>
|
||||
{{ $utils.niceDate(props.row.createdAt, true) }}
|
||||
</p>
|
||||
<p v-if="stats.startedAt">
|
||||
<label>Started</label>
|
||||
{{ $utils.niceDate(stats.startedAt, true) }}
|
||||
</p>
|
||||
<p v-if="isDone(props.row)">
|
||||
<label>Ended</label>
|
||||
{{ $utils.niceDate(stats.updatedAt, true) }}
|
||||
</p>
|
||||
<p v-if="stats.startedAt && stats.updatedAt"
|
||||
class="is-capitalized" title="Duration">
|
||||
<label><b-icon icon="alarm" size="is-small" /></label>
|
||||
{{ duration(stats.startedAt, stats.updatedAt) }}
|
||||
</p>
|
||||
</div>
|
||||
</b-table-column>
|
||||
|
||||
<b-table-column :class="props.row.status" label="Stats" width="18%">
|
||||
<div class="fields stats" :set="stats = getCampaignStats(props.row)">
|
||||
<p>
|
||||
<label>Views</label>
|
||||
{{ props.row.views }}
|
||||
</p>
|
||||
<p>
|
||||
<label>Clicks</label>
|
||||
{{ props.row.clicks }}
|
||||
</p>
|
||||
<p>
|
||||
<label>Sent</label>
|
||||
{{ stats.sent }} / {{ stats.toSend }}
|
||||
</p>
|
||||
<p title="Speed" v-if="stats.rate">
|
||||
<label><b-icon icon="speedometer" size="is-small"></b-icon></label>
|
||||
<span class="send-rate">
|
||||
{{ stats.rate }} / min
|
||||
</span>
|
||||
</p>
|
||||
<p v-if="isRunning(props.row.id)">
|
||||
<label>Progress
|
||||
<span class="spinner is-tiny">
|
||||
<b-loading :is-full-page="false" active />
|
||||
</span>
|
||||
</label>
|
||||
<b-progress :value="stats.sent / stats.toSend * 100" size="is-small" />
|
||||
</p>
|
||||
</div>
|
||||
</b-table-column>
|
||||
|
||||
<b-table-column class="actions" width="13%" align="right">
|
||||
<a href="" v-if="canStart(props.row)"
|
||||
@click.prevent="$utils.confirm(null,
|
||||
() => changeCampaignStatus(props.row, 'running'))">
|
||||
<b-tooltip label="Start" type="is-dark">
|
||||
<b-icon icon="rocket-launch-outline" size="is-small" />
|
||||
</b-tooltip>
|
||||
</a>
|
||||
<a href="" v-if="canPause(props.row)"
|
||||
@click.prevent="$utils.confirm(null,
|
||||
() => changeCampaignStatus(props.row, 'paused'))">
|
||||
<b-tooltip label="Pause" type="is-dark">
|
||||
<b-icon icon="pause-circle-outline" size="is-small" />
|
||||
</b-tooltip>
|
||||
</a>
|
||||
<a href="" v-if="canResume(props.row)"
|
||||
@click.prevent="$utils.confirm(null,
|
||||
() => changeCampaignStatus(props.row, 'running'))">
|
||||
<b-tooltip label="Send" type="is-dark">
|
||||
<b-icon icon="rocket-launch-outline" size="is-small" />
|
||||
</b-tooltip>
|
||||
</a>
|
||||
<a href="" v-if="canSchedule(props.row)"
|
||||
@click.prevent="$utils.confirm(`This campaign will start automatically at the
|
||||
scheduled date and time. Schedule now?`,
|
||||
() => changeCampaignStatus(props.row, 'scheduled'))">
|
||||
<b-tooltip label="Schedule" type="is-dark">
|
||||
<b-icon icon="clock-start" size="is-small" />
|
||||
</b-tooltip>
|
||||
</a>
|
||||
<a href="" @click.prevent="previewCampaign(props.row)">
|
||||
<b-tooltip label="Preview" type="is-dark">
|
||||
<b-icon icon="file-find-outline" size="is-small" />
|
||||
</b-tooltip>
|
||||
</a>
|
||||
<a href="" @click.prevent="$utils.prompt(`Clone campaign`,
|
||||
{ placeholder: 'Campaign name', value: `Copy of ${props.row.name}`},
|
||||
(name) => cloneCampaign(name, props.row))">
|
||||
<b-tooltip label="Clone" type="is-dark">
|
||||
<b-icon icon="file-multiple-outline" size="is-small" />
|
||||
</b-tooltip>
|
||||
</a>
|
||||
<a href="" v-if="canCancel(props.row)"
|
||||
@click.prevent="$utils.confirm(null,
|
||||
() => changeCampaignStatus(props.row, 'cancelled'))">
|
||||
<b-tooltip label="Cancel" type="is-dark">
|
||||
<b-icon icon="trash-can-outline" size="is-small" />
|
||||
</b-tooltip>
|
||||
</a>
|
||||
<a href="" v-if="canDelete(props.row)"
|
||||
@click.prevent="$utils.confirm(`Delete '${props.row.name}'?`,
|
||||
() => deleteCampaign(props.row))">
|
||||
<b-icon icon="trash-can-outline" size="is-small" />
|
||||
</a>
|
||||
</b-table-column>
|
||||
</template>
|
||||
<template slot="empty" v-if="!loading.campaigns">
|
||||
<empty-placeholder />
|
||||
</template>
|
||||
</b-table>
|
||||
|
||||
<campaign-preview v-if="previewItem"
|
||||
type='campaign'
|
||||
:id="previewItem.id"
|
||||
:title="previewItem.name"
|
||||
@close="closePreview"></campaign-preview>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Vue from 'vue';
|
||||
import { mapState } from 'vuex';
|
||||
import dayjs from 'dayjs';
|
||||
import relativeTime from 'dayjs/plugin/relativeTime';
|
||||
import CampaignPreview from '../components/CampaignPreview.vue';
|
||||
import EmptyPlaceholder from '../components/EmptyPlaceholder.vue';
|
||||
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
export default Vue.extend({
|
||||
components: {
|
||||
CampaignPreview,
|
||||
EmptyPlaceholder,
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
previewItem: null,
|
||||
queryParams: {
|
||||
page: 1,
|
||||
},
|
||||
pollID: null,
|
||||
campaignStatsData: {},
|
||||
};
|
||||
},
|
||||
|
||||
methods: {
|
||||
// Campaign statuses.
|
||||
canStart(c) {
|
||||
return c.status === 'draft' && !c.sendAt;
|
||||
},
|
||||
canSchedule(c) {
|
||||
return c.status === 'draft' && c.sendAt;
|
||||
},
|
||||
canPause(c) {
|
||||
return c.status === 'running';
|
||||
},
|
||||
canCancel(c) {
|
||||
return c.status === 'running' || c.status === 'paused';
|
||||
},
|
||||
canResume(c) {
|
||||
return c.status === 'paused';
|
||||
},
|
||||
canDelete(c) {
|
||||
return c.status === 'draft' || c.status === 'scheduled';
|
||||
},
|
||||
isSheduled(c) {
|
||||
return c.status === 'scheduled' || c.sendAt !== null;
|
||||
},
|
||||
isDone(c) {
|
||||
return c.status === 'finished' || c.status === 'cancelled';
|
||||
},
|
||||
|
||||
isRunning(id) {
|
||||
if (id in this.campaignStatsData) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
|
||||
highlightedRow(data) {
|
||||
if (data.status === 'running') {
|
||||
return ['running'];
|
||||
}
|
||||
return '';
|
||||
},
|
||||
|
||||
duration(start, end) {
|
||||
return dayjs(end).from(dayjs(start), true);
|
||||
},
|
||||
|
||||
onPageChange(p) {
|
||||
this.queryParams.page = p;
|
||||
this.getCampaigns();
|
||||
},
|
||||
|
||||
// Campaign actions.
|
||||
previewCampaign(c) {
|
||||
this.previewItem = c;
|
||||
},
|
||||
|
||||
closePreview() {
|
||||
this.previewItem = null;
|
||||
},
|
||||
|
||||
getCampaigns() {
|
||||
this.$api.getCampaigns({
|
||||
page: this.queryParams.page,
|
||||
});
|
||||
},
|
||||
|
||||
// Stats returns the campaign object with stats (sent, toSend etc.)
|
||||
// if there's live stats availabe for running campaigns. Otherwise,
|
||||
// it returns the incoming campaign object that has the static stats
|
||||
// values.
|
||||
getCampaignStats(c) {
|
||||
if (c.id in this.campaignStatsData) {
|
||||
return this.campaignStatsData[c.id];
|
||||
}
|
||||
return c;
|
||||
},
|
||||
|
||||
pollStats() {
|
||||
// Clear any running status polls.
|
||||
clearInterval(this.pollID);
|
||||
|
||||
// Poll for the status as long as the import is running.
|
||||
this.pollID = setInterval(() => {
|
||||
this.$api.getCampaignStats().then((r) => {
|
||||
// Stop polling. No running campaigns.
|
||||
if (r.data.length === 0) {
|
||||
clearInterval(this.pollID);
|
||||
|
||||
// There were running campaigns and stats earlier. Clear them
|
||||
// and refetch the campaigns list with up-to-date fields.
|
||||
if (Object.keys(this.campaignStatsData).length > 0) {
|
||||
this.getCampaigns();
|
||||
this.campaignStatsData = {};
|
||||
}
|
||||
} else {
|
||||
// Turn the list of campaigns [{id: 1, ...}, {id: 2, ...}] into
|
||||
// a map indexed by the id: {1: {}, 2: {}}.
|
||||
this.campaignStatsData = r.data.reduce((obj, cur) => ({ ...obj, [cur.id]: cur }), {});
|
||||
}
|
||||
}, () => {
|
||||
clearInterval(this.pollID);
|
||||
});
|
||||
}, 1000);
|
||||
},
|
||||
|
||||
changeCampaignStatus(c, status) {
|
||||
this.$api.changeCampaignStatus(c.id, status).then(() => {
|
||||
this.$utils.toast(`'${c.name}' is ${status}`);
|
||||
this.getCampaigns();
|
||||
this.pollStats();
|
||||
});
|
||||
},
|
||||
|
||||
cloneCampaign(name, c) {
|
||||
const data = {
|
||||
name,
|
||||
subject: c.subject,
|
||||
lists: c.lists.map((l) => l.id),
|
||||
type: c.type,
|
||||
from_email: c.fromEmail,
|
||||
content_type: c.contentType,
|
||||
messenger: c.messenger,
|
||||
tags: c.tags,
|
||||
template_id: c.templateId,
|
||||
body: c.body,
|
||||
};
|
||||
this.$api.createCampaign(data).then((r) => {
|
||||
this.$router.push({ name: 'campaign', params: { id: r.data.id } });
|
||||
});
|
||||
},
|
||||
|
||||
deleteCampaign(c) {
|
||||
this.$api.deleteCampaign(c.id).then(() => {
|
||||
this.getCampaigns();
|
||||
this.$utils.toast(`'${c.name}' deleted`);
|
||||
});
|
||||
},
|
||||
},
|
||||
|
||||
computed: {
|
||||
...mapState(['campaigns', 'loading']),
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.getCampaigns();
|
||||
this.pollStats();
|
||||
},
|
||||
|
||||
destroyed() {
|
||||
clearInterval(this.pollID);
|
||||
},
|
||||
});
|
||||
</script>
|
|
@ -0,0 +1,218 @@
|
|||
<template>
|
||||
<section class="dashboard content">
|
||||
<header class="columns">
|
||||
<div class="column is-two-thirds">
|
||||
<h1 class="title is-5">{{ dayjs().format("ddd, DD MMM") }}</h1>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="counts wrap-small">
|
||||
<div class="tile is-ancestor">
|
||||
<div class="tile is-vertical is-12">
|
||||
<div class="tile">
|
||||
<div class="tile is-parent is-vertical relative">
|
||||
<b-loading v-if="isCountsLoading" active :is-full-page="false" />
|
||||
<article class="tile is-child notification">
|
||||
<div class="columns is-mobile">
|
||||
<div class="column is-6">
|
||||
<p class="title">{{ $utils.niceNumber(counts.lists.total) }}</p>
|
||||
<p class="is-size-6 has-text-grey">Lists</p>
|
||||
</div>
|
||||
<div class="column is-6">
|
||||
<ul class="no is-size-7 has-text-grey">
|
||||
<li>
|
||||
<label>{{ $utils.niceNumber(counts.lists.public) }}</label> public
|
||||
</li>
|
||||
<li>
|
||||
<label>{{ $utils.niceNumber(counts.lists.private) }}</label> private
|
||||
</li>
|
||||
<li>
|
||||
<label>{{ $utils.niceNumber(counts.lists.optinSingle) }}</label>
|
||||
single opt-in
|
||||
</li>
|
||||
<li>
|
||||
<label>{{ $utils.niceNumber(counts.lists.optinDouble) }}</label>
|
||||
double opt-in</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</article><!-- lists -->
|
||||
|
||||
<article class="tile is-child notification">
|
||||
<div class="columns is-mobile">
|
||||
<div class="column is-6">
|
||||
<p class="title">{{ $utils.niceNumber(counts.campaigns.total) }}</p>
|
||||
<p class="is-size-6 has-text-grey">Campaigns</p>
|
||||
</div>
|
||||
<div class="column is-6">
|
||||
<ul class="no is-size-7 has-text-grey">
|
||||
<li v-for="(num, status) in counts.campaigns.byStatus" :key="status">
|
||||
<label>{{ num }}</label> {{ status }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</article><!-- campaigns -->
|
||||
</div><!-- block -->
|
||||
|
||||
<div class="tile is-parent relative">
|
||||
<b-loading v-if="isCountsLoading" active :is-full-page="false" />
|
||||
<article class="tile is-child notification">
|
||||
<div class="columns is-mobile">
|
||||
<div class="column is-6">
|
||||
<p class="title">{{ $utils.niceNumber(counts.subscribers.total) }}</p>
|
||||
<p class="is-size-6 has-text-grey">Subscribers</p>
|
||||
</div>
|
||||
|
||||
<div class="column is-6">
|
||||
<ul class="no is-size-7 has-text-grey">
|
||||
<li>
|
||||
<label>{{ $utils.niceNumber(counts.subscribers.blacklisted) }}</label>
|
||||
blacklisted
|
||||
</li>
|
||||
<li>
|
||||
<label>{{ $utils.niceNumber(counts.subscribers.orphans) }}</label>
|
||||
orphans
|
||||
</li>
|
||||
</ul>
|
||||
</div><!-- subscriber breakdown -->
|
||||
</div><!-- subscriber columns -->
|
||||
<hr />
|
||||
<div class="columns">
|
||||
<div class="column is-6">
|
||||
<p class="title">{{ $utils.niceNumber(counts.messages) }}</p>
|
||||
<p class="is-size-6 has-text-grey">Messages sent</p>
|
||||
</div>
|
||||
</div>
|
||||
</article><!-- subscribers -->
|
||||
</div>
|
||||
</div>
|
||||
<div class="tile is-parent relative">
|
||||
<b-loading v-if="isChartsLoading" active :is-full-page="false" />
|
||||
<article class="tile is-child notification charts">
|
||||
<div class="columns">
|
||||
<div class="column is-6">
|
||||
<h3 class="title is-size-6 has-text-right">Campaign views</h3>
|
||||
<vue-c3 v-if="chartViewsInst" :handler="chartViewsInst"></vue-c3>
|
||||
<empty-placeholder v-else-if="!isChartsLoading" />
|
||||
</div>
|
||||
<div class="column is-6">
|
||||
<h3 class="title is-size-6 has-text-right">Link clicks</h3>
|
||||
<vue-c3 v-if="chartClicksInst" :handler="chartClicksInst"></vue-c3>
|
||||
<empty-placeholder v-else-if="!isChartsLoading" />
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</div><!-- tile block -->
|
||||
</section>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
|
||||
<style lang="css">
|
||||
@import "~c3/c3.css";
|
||||
</style>
|
||||
|
||||
<script>
|
||||
import Vue from 'vue';
|
||||
import VueC3 from 'vue-c3';
|
||||
import dayjs from 'dayjs';
|
||||
import { colors } from '../constants';
|
||||
import EmptyPlaceholder from '../components/EmptyPlaceholder.vue';
|
||||
|
||||
export default Vue.extend({
|
||||
components: {
|
||||
EmptyPlaceholder,
|
||||
VueC3,
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
// Unique Vue() instances for each chart.
|
||||
chartViewsInst: null,
|
||||
chartClicksInst: null,
|
||||
|
||||
isChartsLoading: true,
|
||||
isCountsLoading: true,
|
||||
|
||||
counts: {
|
||||
lists: {},
|
||||
subscribers: {},
|
||||
campaigns: {},
|
||||
messages: 0,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
methods: {
|
||||
makeChart(label, data) {
|
||||
const conf = {
|
||||
data: {
|
||||
columns: [
|
||||
[label, ...data.map((d) => d.count).reverse()],
|
||||
],
|
||||
type: 'spline',
|
||||
color() {
|
||||
return colors.primary;
|
||||
},
|
||||
},
|
||||
axis: {
|
||||
x: {
|
||||
type: 'category',
|
||||
categories: data.map((d) => dayjs(d.date).format('DD MMM')).reverse(),
|
||||
tick: {
|
||||
rotate: -45,
|
||||
multiline: false,
|
||||
culling: { max: 10 },
|
||||
},
|
||||
},
|
||||
},
|
||||
legend: {
|
||||
show: false,
|
||||
},
|
||||
};
|
||||
return conf;
|
||||
},
|
||||
},
|
||||
|
||||
computed: {
|
||||
dayjs() {
|
||||
return dayjs;
|
||||
},
|
||||
},
|
||||
|
||||
mounted() {
|
||||
// Pull the counts.
|
||||
this.$api.getDashboardCounts().then((r) => {
|
||||
this.counts = r.data;
|
||||
this.isCountsLoading = false;
|
||||
});
|
||||
|
||||
// Pull the charts.
|
||||
this.$api.getDashboardCharts().then((r) => {
|
||||
this.isChartsLoading = false;
|
||||
|
||||
// vue-c3 lib requires unique instances of Vue() to communicate.
|
||||
if (r.data.campaignViews.length > 0) {
|
||||
this.chartViewsInst = this;
|
||||
|
||||
this.$nextTick(() => {
|
||||
this.chartViewsInst.$emit('init',
|
||||
this.makeChart('Campaign views', r.data.campaignViews));
|
||||
});
|
||||
}
|
||||
|
||||
if (r.data.linkClicks.length > 0) {
|
||||
this.chartClicksInst = new Vue();
|
||||
|
||||
this.$nextTick(() => {
|
||||
this.chartClicksInst.$emit('init',
|
||||
this.makeChart('Link clicks', r.data.linkClicks));
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
</script>
|
|
@ -0,0 +1,88 @@
|
|||
<template>
|
||||
<section class="forms content relative">
|
||||
<h1 class="title is-4">Forms</h1>
|
||||
<hr />
|
||||
<b-loading v-if="loading.lists" :active="loading.lists" :is-full-page="false" />
|
||||
<div class="columns" v-else-if="publicLists.length > 0">
|
||||
<div class="column is-4">
|
||||
<h4>Public lists</h4>
|
||||
<p>Select lists to add to the form.</p>
|
||||
|
||||
<b-loading :active="loading.lists" :is-full-page="false" />
|
||||
<ul class="no">
|
||||
<li v-for="l in publicLists" :key="l.id">
|
||||
<b-checkbox v-model="checked"
|
||||
:native-value="l.uuid">{{ l.name }}</b-checkbox>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="column">
|
||||
<h4>Form HTML</h4>
|
||||
<p>
|
||||
Use the following HTML to show a subscription form on an external webpage.
|
||||
</p>
|
||||
<p>
|
||||
The form should have the <code>email</code> field and one or more <code>l</code>
|
||||
(list UUID) fields. The <code>name</code> field is optional.
|
||||
</p>
|
||||
|
||||
<pre><!-- eslint-disable max-len --><form method="post" action="http://localhost:9000/subscription/form" class="listmonk-form">
|
||||
<div>
|
||||
<h3>Subscribe</h3>
|
||||
<p><input type="text" name="email" placeholder="E-mail" /></p>
|
||||
<p><input type="text" name="name" placeholder="Name (optional)" /></p>
|
||||
<template v-for="l in publicLists"><span v-if="l.uuid in selected" :key="l.id" :set="id = l.uuid.substr(0, 5)">
|
||||
<p>
|
||||
<input id="{{ id }}" type="checkbox" name="l" value="{{ uuid }}" />
|
||||
<label for="{{ id }}">{{ l.name }}</label>
|
||||
</p></span></template>
|
||||
<p><input type="submit" value="Subscribe" /></p>
|
||||
</div>
|
||||
</form></pre>
|
||||
</div>
|
||||
</div><!-- columns -->
|
||||
|
||||
<p v-else>There are no public lists to create forms.</p>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Vue from 'vue';
|
||||
import { mapState } from 'vuex';
|
||||
|
||||
export default Vue.extend({
|
||||
name: 'ListForm',
|
||||
|
||||
data() {
|
||||
return {
|
||||
checked: [],
|
||||
};
|
||||
},
|
||||
|
||||
methods: {
|
||||
getPublicLists(lists) {
|
||||
console.log(lists.filter((l) => l.type === 'public'));
|
||||
return lists.filter((l) => l.type === 'public');
|
||||
},
|
||||
},
|
||||
|
||||
computed: {
|
||||
...mapState(['lists', 'loading']),
|
||||
|
||||
publicLists() {
|
||||
if (!this.lists.results) {
|
||||
return [];
|
||||
}
|
||||
return this.lists.results.filter((l) => l.type === 'public');
|
||||
},
|
||||
|
||||
selected() {
|
||||
const sel = [];
|
||||
this.checked.forEach((uuid) => {
|
||||
sel[uuid] = true;
|
||||
});
|
||||
return sel;
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
|
@ -0,0 +1,298 @@
|
|||
<template>
|
||||
<section class="import">
|
||||
<h1 class="title is-4">Import subscribers</h1>
|
||||
|
||||
<b-loading :active="isLoading"></b-loading>
|
||||
|
||||
<section v-if="isFree()" class="wrap-small">
|
||||
<form @submit.prevent="onSubmit" class="box">
|
||||
<div>
|
||||
<b-field label="Mode">
|
||||
<div>
|
||||
<b-radio v-model="form.mode" name="mode"
|
||||
native-value="subscribe">Subscribe</b-radio>
|
||||
<b-radio v-model="form.mode" name="mode"
|
||||
native-value="blacklist">Blacklist</b-radio>
|
||||
</div>
|
||||
</b-field>
|
||||
|
||||
<list-selector
|
||||
label="Lists"
|
||||
placeholder="Lists to subscribe to"
|
||||
message="Lists to subscribe to."
|
||||
v-model="form.lists"
|
||||
:selected="form.lists"
|
||||
:all="lists.results"
|
||||
></list-selector>
|
||||
<hr />
|
||||
<b-field label="CSV delimiter" message="Default delimiter is comma."
|
||||
class="delimiter">
|
||||
<b-input v-model="form.delim" name="delim"
|
||||
placeholder="," maxlength="1" required />
|
||||
</b-field>
|
||||
|
||||
<b-field label="CSV or ZIP file"
|
||||
message="For existing subscribers, the names and attributes
|
||||
will be overwritten with the values in the CSV.">
|
||||
<b-upload v-model="form.file" drag-drop expanded required>
|
||||
<div class="has-text-centered section">
|
||||
<p>
|
||||
<b-icon icon="file-upload-outline" size="is-large"></b-icon>
|
||||
</p>
|
||||
<p>Click or drag a CSV or ZIP file here</p>
|
||||
</div>
|
||||
</b-upload>
|
||||
</b-field>
|
||||
<div class="tags" v-if="form.file">
|
||||
<b-tag size="is-medium" closable @close="clearFile">
|
||||
{{ form.file.name }}
|
||||
</b-tag>
|
||||
</div>
|
||||
<div class="buttons">
|
||||
<b-button native-type="submit" type="is-primary"
|
||||
:disabled="form.lists.length === 0"
|
||||
:loading="isProcessing">Upload</b-button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<hr />
|
||||
|
||||
<div class="import-help">
|
||||
<h5 class="title is-size-6">Instructions</h5>
|
||||
<p>
|
||||
Upload a CSV file or a ZIP file with a single CSV file in it to bulk
|
||||
import subscribers. The CSV file should have the following headers
|
||||
with the exact column names. <code>attributes</code> (optional)
|
||||
should be a valid JSON string with double escaped quotes.
|
||||
</p>
|
||||
<br />
|
||||
<blockquote className="csv-example">
|
||||
<code className="csv-headers">
|
||||
<span>email,</span>
|
||||
<span>name,</span>
|
||||
<span>attributes</span>
|
||||
</code>
|
||||
</blockquote>
|
||||
|
||||
<hr />
|
||||
|
||||
<h5 class="title is-size-6">Example raw CSV</h5>
|
||||
<blockquote className="csv-example">
|
||||
<code className="csv-headers">
|
||||
<span>email,</span>
|
||||
<span>name,</span>
|
||||
<span>attributes</span>
|
||||
</code><br />
|
||||
<code className="csv-row">
|
||||
<span>user1@mail.com,</span>
|
||||
<span>"User One",</span>
|
||||
<span>{'"{""age"": 42, ""planet"": ""Mars""}"'}</span>
|
||||
</code><br />
|
||||
<code className="csv-row">
|
||||
<span>user2@mail.com,</span>
|
||||
<span>"User Two",</span>
|
||||
<span>
|
||||
{'"{""age"": 24, ""job"": ""Time Traveller""}"'}
|
||||
</span>
|
||||
</code>
|
||||
</blockquote>
|
||||
</div>
|
||||
</section><!-- upload //-->
|
||||
|
||||
<section v-if="isRunning() || isDone()" class="wrap status box has-text-centered">
|
||||
<b-progress :value="progress" show-value type="is-success"></b-progress>
|
||||
<br />
|
||||
<p :class="['is-size-5', 'is-capitalized',
|
||||
{'has-text-success': status.status === 'finished'},
|
||||
{'has-text-danger': (status.status === 'failed' || status.status === 'stopped')}]">
|
||||
{{ status.status }}</p>
|
||||
|
||||
<p>{{ status.imported }} / {{ status.total }} records</p>
|
||||
<br />
|
||||
|
||||
<p>
|
||||
<b-button @click="stopImport" :loading="isProcessing" icon-left="file-upload-outline"
|
||||
type="is-primary">{{ isDone() ? 'Done' : 'Stop import' }}</b-button>
|
||||
</p>
|
||||
<br />
|
||||
|
||||
<p>
|
||||
<b-input v-model="logs" id="import-log" class="logs"
|
||||
type="textarea" readonly placeholder="Import log" />
|
||||
</p>
|
||||
</section>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Vue from 'vue';
|
||||
import { mapState } from 'vuex';
|
||||
import ListSelector from '../components/ListSelector.vue';
|
||||
|
||||
export default Vue.extend({
|
||||
components: {
|
||||
ListSelector,
|
||||
},
|
||||
|
||||
props: {
|
||||
data: {},
|
||||
isEditing: null,
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
form: {
|
||||
mode: 'subscribe',
|
||||
delim: ',',
|
||||
lists: [],
|
||||
file: null,
|
||||
},
|
||||
|
||||
// Initial page load still has to wait for the status API to return
|
||||
// to either show the form or the status box.
|
||||
isLoading: true,
|
||||
|
||||
isProcessing: false,
|
||||
status: { status: '' },
|
||||
logs: '',
|
||||
pollID: null,
|
||||
};
|
||||
},
|
||||
|
||||
methods: {
|
||||
clearFile() {
|
||||
this.form.file = null;
|
||||
},
|
||||
|
||||
// Returns true if we're free to do an upload.
|
||||
isFree() {
|
||||
if (this.status.status === 'none') {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
|
||||
// Returns true if an import is running.
|
||||
isRunning() {
|
||||
if (this.status.status === 'importing'
|
||||
|| this.status.status === 'stopping') {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
|
||||
isSuccessful() {
|
||||
return this.status.status === 'finished';
|
||||
},
|
||||
|
||||
isFailed() {
|
||||
return (
|
||||
this.status.status === 'stopped'
|
||||
|| this.status.status === 'failed'
|
||||
);
|
||||
},
|
||||
|
||||
// Returns true if an import has finished (failed or sucessful).
|
||||
isDone() {
|
||||
if (this.status.status === 'finished'
|
||||
|| this.status.status === 'stopped'
|
||||
|| this.status.status === 'failed'
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
|
||||
pollStatus() {
|
||||
// Clear any running status polls.
|
||||
clearInterval(this.pollID);
|
||||
|
||||
// Poll for the status as long as the import is running.
|
||||
this.pollID = setInterval(() => {
|
||||
this.$api.getImportStatus().then((r) => {
|
||||
this.isProcessing = false;
|
||||
this.isLoading = false;
|
||||
this.status = r.data;
|
||||
this.getLogs();
|
||||
|
||||
if (!this.isRunning()) {
|
||||
clearInterval(this.pollID);
|
||||
}
|
||||
}, () => {
|
||||
this.isProcessing = false;
|
||||
this.isLoading = false;
|
||||
this.status = { status: 'none' };
|
||||
clearInterval(this.pollID);
|
||||
});
|
||||
return true;
|
||||
}, 250);
|
||||
},
|
||||
|
||||
getLogs() {
|
||||
this.$api.getImportLogs().then((r) => {
|
||||
this.logs = r.data;
|
||||
|
||||
Vue.nextTick(() => {
|
||||
// vue.$refs doesn't work as the logs textarea is rendered dynamiaclly.
|
||||
const ref = document.getElementById('import-log');
|
||||
if (ref) {
|
||||
ref.scrollTop = ref.scrollHeight;
|
||||
}
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
// Cancel a running import or clears a finished import.
|
||||
stopImport() {
|
||||
this.isProcessing = true;
|
||||
this.$api.stopImport().then(() => {
|
||||
this.pollStatus();
|
||||
});
|
||||
},
|
||||
|
||||
onSubmit() {
|
||||
this.isProcessing = true;
|
||||
|
||||
// Prepare the upload payload.
|
||||
const params = new FormData();
|
||||
params.set('params', JSON.stringify({
|
||||
mode: this.form.mode,
|
||||
delim: this.form.delim,
|
||||
lists: this.form.lists.map((l) => l.id),
|
||||
}));
|
||||
params.set('file', this.form.file);
|
||||
|
||||
// Make the API request.
|
||||
this.$api.importSubscribers(params).then(() => {
|
||||
// On file upload, show a confirmation.
|
||||
this.$buefy.toast.open({
|
||||
message: 'Import started',
|
||||
type: 'is-success',
|
||||
queue: false,
|
||||
});
|
||||
|
||||
// Start polling status.
|
||||
this.pollStatus();
|
||||
}, () => {
|
||||
this.isProcessing = false;
|
||||
});
|
||||
},
|
||||
},
|
||||
|
||||
computed: {
|
||||
...mapState(['lists']),
|
||||
|
||||
// Import progress bar value.
|
||||
progress() {
|
||||
if (!this.status) {
|
||||
return 0;
|
||||
}
|
||||
return Math.ceil((this.status.imported / this.status.total) * 100);
|
||||
},
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.pollStatus();
|
||||
},
|
||||
});
|
||||
</script>
|
|
@ -0,0 +1,118 @@
|
|||
<template>
|
||||
<form @submit.prevent="onSubmit">
|
||||
<div class="modal-card content" style="width: auto">
|
||||
<header class="modal-card-head">
|
||||
<b-tag v-if="isEditing" :class="[data.type, 'is-pulled-right']">{{ data.type }}</b-tag>
|
||||
<h4 v-if="isEditing">{{ data.name }}</h4>
|
||||
<h4 v-else>New list</h4>
|
||||
|
||||
<p v-if="isEditing" class="has-text-grey is-size-7">
|
||||
ID: {{ data.id }} / UUID: {{ data.uuid }}
|
||||
</p>
|
||||
</header>
|
||||
<section expanded class="modal-card-body">
|
||||
<b-field label="Name">
|
||||
<b-input :maxlength="200" :ref="'focus'" v-model="form.name"
|
||||
placeholder="Name" required></b-input>
|
||||
</b-field>
|
||||
|
||||
<b-field label="Type"
|
||||
message="Public lists are open to the world to subscribe
|
||||
and their names may appear on public pages such as the subscription
|
||||
management page.">
|
||||
<b-select v-model="form.type" placeholder="Type" required>
|
||||
<option value="private">Private</option>
|
||||
<option value="public">Public</option>
|
||||
</b-select>
|
||||
</b-field>
|
||||
|
||||
<b-field label="Opt-in"
|
||||
message="Double opt-in sends an e-mail to the subscriber asking for
|
||||
confirmation. On Double opt-in lists, campaigns are only sent to
|
||||
confirmed subscribers.">
|
||||
<b-select v-model="form.optin" placeholder="Opt-in type" required>
|
||||
<option value="single">Single</option>
|
||||
<option value="double">Double</option>
|
||||
</b-select>
|
||||
</b-field>
|
||||
</section>
|
||||
<footer class="modal-card-foot has-text-right">
|
||||
<b-button @click="$parent.close()">Close</b-button>
|
||||
<b-button native-type="submit" type="is-primary"
|
||||
:loading="loading.lists">Save</b-button>
|
||||
</footer>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Vue from 'vue';
|
||||
import { mapState } from 'vuex';
|
||||
|
||||
export default Vue.extend({
|
||||
name: 'ListForm',
|
||||
|
||||
props: {
|
||||
data: {},
|
||||
isEditing: null,
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
// Binds form input values.
|
||||
form: {
|
||||
name: '',
|
||||
type: '',
|
||||
optin: '',
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
methods: {
|
||||
onSubmit() {
|
||||
if (this.isEditing) {
|
||||
this.updateList();
|
||||
return;
|
||||
}
|
||||
|
||||
this.createList();
|
||||
},
|
||||
|
||||
createList() {
|
||||
this.$api.createList(this.form).then((resp) => {
|
||||
this.$emit('finished');
|
||||
this.$parent.close();
|
||||
this.$buefy.toast.open({
|
||||
message: `'${resp.data.name}' created`,
|
||||
type: 'is-success',
|
||||
queue: false,
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
updateList() {
|
||||
this.$api.updateList({ id: this.data.id, ...this.form }).then((resp) => {
|
||||
this.$emit('finished');
|
||||
this.$parent.close();
|
||||
this.$buefy.toast.open({
|
||||
message: `'${resp.data.name}' updated`,
|
||||
type: 'is-success',
|
||||
queue: false,
|
||||
});
|
||||
});
|
||||
},
|
||||
},
|
||||
|
||||
computed: {
|
||||
...mapState(['loading']),
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.form = { ...this.$props.data };
|
||||
|
||||
this.$nextTick(() => {
|
||||
this.$refs.focus.focus();
|
||||
});
|
||||
},
|
||||
});
|
||||
</script>
|
|
@ -0,0 +1,153 @@
|
|||
<template>
|
||||
<section class="lists">
|
||||
<header class="columns">
|
||||
<div class="column is-two-thirds">
|
||||
<h1 class="title is-4">Lists <span v-if="lists.total > 0">({{ lists.total }})</span></h1>
|
||||
</div>
|
||||
<div class="column has-text-right">
|
||||
<b-button type="is-primary" icon-left="plus" @click="showNewForm">New</b-button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<b-table
|
||||
:data="lists.results"
|
||||
:loading="loading.lists"
|
||||
hoverable
|
||||
default-sort="createdAt">
|
||||
<template slot-scope="props">
|
||||
<b-table-column field="name" label="Name" sortable>
|
||||
<router-link :to="{name: 'subscribers_list', params: { listID: props.row.id }}">
|
||||
{{ props.row.name }}
|
||||
</router-link>
|
||||
</b-table-column>
|
||||
|
||||
<b-table-column field="type" label="Type" sortable>
|
||||
<b-tag :class="props.row.type">{{ props.row.type }}</b-tag>
|
||||
{{ ' ' }}
|
||||
<b-tag>
|
||||
<b-icon :icon="props.row.optin === 'double' ?
|
||||
'account-check-outline' : 'account-off-outline'" size="is-small" />
|
||||
{{ ' ' }}
|
||||
{{ props.row.optin }}
|
||||
</b-tag>{{ ' ' }}
|
||||
<router-link :to="{name: 'campaign', params: {id: 'new'},
|
||||
query: {type: 'optin', 'list_id': props.row.id}}"
|
||||
v-if="props.row.optin === 'double'" class="is-size-7 send-optin">
|
||||
<b-tooltip label="Send opt-in campaign" type="is-dark">
|
||||
<b-icon icon="rocket-launch-outline" size="is-small" />
|
||||
Send opt-in campaign
|
||||
</b-tooltip>
|
||||
</router-link>
|
||||
</b-table-column>
|
||||
|
||||
<b-table-column field="subscriberCount" label="Subscribers" numeric sortable centered>
|
||||
<router-link :to="`/subscribers/lists/${props.row.id}`">
|
||||
{{ props.row.subscriberCount }}
|
||||
</router-link>
|
||||
</b-table-column>
|
||||
|
||||
<b-table-column field="createdAt" label="Created" sortable>
|
||||
{{ $utils.niceDate(props.row.createdAt) }}
|
||||
</b-table-column>
|
||||
<b-table-column field="updatedAt" label="Updated" sortable>
|
||||
{{ $utils.niceDate(props.row.updatedAt) }}
|
||||
</b-table-column>
|
||||
|
||||
<b-table-column class="actions" align="right">
|
||||
<router-link :to="`/campaign/new?list_id=${props.row.id}`">
|
||||
<b-tooltip label="Send campaign" type="is-dark">
|
||||
<b-icon icon="rocket-launch-outline" size="is-small" />
|
||||
</b-tooltip>
|
||||
</router-link>
|
||||
<a href="" @click.prevent="showEditForm(props.row)">
|
||||
<b-tooltip label="Edit" type="is-dark">
|
||||
<b-icon icon="pencil-outline" size="is-small" />
|
||||
</b-tooltip>
|
||||
</a>
|
||||
<a href="" @click.prevent="deleteList(props.row)">
|
||||
<b-tooltip label="Delete" type="is-dark">
|
||||
<b-icon icon="trash-can-outline" size="is-small" />
|
||||
</b-tooltip>
|
||||
</a>
|
||||
</b-table-column>
|
||||
</template>
|
||||
|
||||
<template slot="empty" v-if="!loading.lists">
|
||||
<empty-placeholder />
|
||||
</template>
|
||||
</b-table>
|
||||
|
||||
<!-- Add / edit form modal -->
|
||||
<b-modal scroll="keep" :aria-modal="true" :active.sync="isFormVisible" :width="450">
|
||||
<list-form :data="curItem" :isEditing="isEditing" @finished="formFinished"></list-form>
|
||||
</b-modal>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Vue from 'vue';
|
||||
import { mapState } from 'vuex';
|
||||
import ListForm from './ListForm.vue';
|
||||
import EmptyPlaceholder from '../components/EmptyPlaceholder.vue';
|
||||
|
||||
export default Vue.extend({
|
||||
components: {
|
||||
ListForm,
|
||||
EmptyPlaceholder,
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
// Current list item being edited.
|
||||
curItem: null,
|
||||
isEditing: false,
|
||||
isFormVisible: false,
|
||||
};
|
||||
},
|
||||
|
||||
methods: {
|
||||
// Show the edit list form.
|
||||
showEditForm(list) {
|
||||
this.curItem = list;
|
||||
this.isFormVisible = true;
|
||||
this.isEditing = true;
|
||||
},
|
||||
|
||||
// Show the new list form.
|
||||
showNewForm() {
|
||||
this.curItem = {};
|
||||
this.isFormVisible = true;
|
||||
this.isEditing = false;
|
||||
},
|
||||
|
||||
formFinished() {
|
||||
this.$api.getLists();
|
||||
},
|
||||
|
||||
deleteList(list) {
|
||||
this.$utils.confirm(
|
||||
'Are you sure? This does not delete subscribers.',
|
||||
() => {
|
||||
this.$api.deleteList(list.id).then(() => {
|
||||
this.$api.getLists();
|
||||
|
||||
this.$buefy.toast.open({
|
||||
message: `'${list.name}' deleted`,
|
||||
type: 'is-success',
|
||||
queue: false,
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
},
|
||||
},
|
||||
|
||||
computed: {
|
||||
...mapState(['lists', 'loading']),
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.$api.getLists();
|
||||
},
|
||||
});
|
||||
</script>
|
|
@ -0,0 +1,179 @@
|
|||
<template>
|
||||
<section class="media-files">
|
||||
<h1 class="title is-4">Media
|
||||
<span v-if="media.length > 0">({{ media.length }})</span>
|
||||
</h1>
|
||||
|
||||
<b-loading :active="isProcessing || loading.media"></b-loading>
|
||||
|
||||
<section class="wrap-small">
|
||||
<form @submit.prevent="onSubmit" class="box">
|
||||
<div>
|
||||
<b-field label="Upload image">
|
||||
<b-upload
|
||||
v-model="form.files"
|
||||
drag-drop
|
||||
multiple
|
||||
accept=".png,.jpg,.jpeg,.gif"
|
||||
expanded required>
|
||||
<div class="has-text-centered section">
|
||||
<p>
|
||||
<b-icon icon="file-upload-outline" size="is-large"></b-icon>
|
||||
</p>
|
||||
<p>Click or drag one or more images here</p>
|
||||
</div>
|
||||
</b-upload>
|
||||
</b-field>
|
||||
<div class="tags" v-if="form.files.length > 0">
|
||||
<b-tag v-for="(f, i) in form.files" :key="i" size="is-medium"
|
||||
closable @close="removeUploadFile(i)">
|
||||
{{ f.name }}
|
||||
</b-tag>
|
||||
</div>
|
||||
<div class="buttons">
|
||||
<b-button native-type="submit" type="is-primary" icon-left="file-upload-outline"
|
||||
:disabled="form.files.length === 0"
|
||||
:loading="isProcessing">Upload</b-button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="section gallery">
|
||||
<div v-for="group in items" :key="group.title">
|
||||
<h3 class="title is-5">{{ group.title }}</h3>
|
||||
|
||||
<div class="thumbs">
|
||||
<div v-for="m in group.items" :key="m.id" class="box thumb">
|
||||
<a @click="(e) => onMediaSelect(m, e)" :href="m.uri" target="_blank">
|
||||
<img :src="m.thumbUri" :title="m.filename" />
|
||||
</a>
|
||||
<span class="caption is-size-7" :title="m.filename">{{ m.filename }}</span>
|
||||
|
||||
<div class="actions has-text-right">
|
||||
<a :href="m.uri" target="_blank">
|
||||
<b-icon icon="arrow-top-right" size="is-small" />
|
||||
</a>
|
||||
<a href="#" @click.prevent="deleteMedia(m.id)">
|
||||
<b-icon icon="trash-can-outline" size="is-small" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<hr />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Vue from 'vue';
|
||||
import { mapState } from 'vuex';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
export default Vue.extend({
|
||||
name: 'Media',
|
||||
|
||||
props: {
|
||||
isModal: Boolean,
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
form: {
|
||||
files: [],
|
||||
},
|
||||
toUpload: 0,
|
||||
uploaded: 0,
|
||||
};
|
||||
},
|
||||
|
||||
methods: {
|
||||
removeUploadFile(i) {
|
||||
this.form.files.splice(i, 1);
|
||||
},
|
||||
|
||||
onMediaSelect(m, e) {
|
||||
// If the component is open in the modal mode, close the modal and
|
||||
// fire the selection event.
|
||||
// Otherwise, do nothing and let the image open like a normal link.
|
||||
if (this.isModal) {
|
||||
e.preventDefault();
|
||||
this.$emit('selected', m);
|
||||
this.$parent.close();
|
||||
}
|
||||
},
|
||||
|
||||
onSubmit() {
|
||||
this.toUpload = this.form.files.length;
|
||||
|
||||
// Upload N files with N requests.
|
||||
for (let i = 0; i < this.toUpload; i += 1) {
|
||||
const params = new FormData();
|
||||
params.set('file', this.form.files[i]);
|
||||
this.$api.uploadMedia(params).then(() => {
|
||||
this.onUploaded();
|
||||
}, () => {
|
||||
this.onUploaded();
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
deleteMedia(id) {
|
||||
this.$api.deleteMedia(id).then(() => {
|
||||
this.$api.getMedia();
|
||||
});
|
||||
},
|
||||
|
||||
onUploaded() {
|
||||
this.uploaded += 1;
|
||||
if (this.uploaded >= this.toUpload) {
|
||||
this.toUpload = 0;
|
||||
this.uploaded = 0;
|
||||
this.form.files = [];
|
||||
|
||||
this.$api.getMedia();
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
computed: {
|
||||
...mapState(['media', 'loading']),
|
||||
|
||||
isProcessing() {
|
||||
if (this.toUpload > 0 && this.uploaded < this.toUpload) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
|
||||
// Filters the list of media items by months into:
|
||||
// [{"title": "Jan 2020", items: [...]}, ...]
|
||||
items() {
|
||||
const out = [];
|
||||
if (!this.media || !(this.media instanceof Array)) {
|
||||
return out;
|
||||
}
|
||||
|
||||
let lastStamp = '';
|
||||
let lastIndex = 0;
|
||||
this.media.forEach((m) => {
|
||||
const stamp = dayjs(m.createdAt).format('MMM YYYY');
|
||||
if (stamp !== lastStamp) {
|
||||
out.push({ title: stamp, items: [] });
|
||||
lastStamp = stamp;
|
||||
lastIndex = out.length;
|
||||
}
|
||||
|
||||
out[lastIndex - 1].items.push(m);
|
||||
});
|
||||
return out;
|
||||
},
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.$api.getMedia();
|
||||
},
|
||||
});
|
||||
</script>
|
|
@ -0,0 +1,75 @@
|
|||
<template>
|
||||
<form @submit.prevent="onSubmit">
|
||||
<div class="modal-card" style="width: auto">
|
||||
<header class="modal-card-head">
|
||||
<h4>Manage lists</h4>
|
||||
<p>{{ numSubscribers }} subscriber(s) selected</p>
|
||||
</header>
|
||||
|
||||
<section expanded class="modal-card-body">
|
||||
<b-field label="Action">
|
||||
<div>
|
||||
<b-radio v-model="form.action" name="action" native-value="add">Add</b-radio>
|
||||
<b-radio v-model="form.action" name="action" native-value="remove">Remove</b-radio>
|
||||
<b-radio
|
||||
v-model="form.action"
|
||||
name="action"
|
||||
native-value="unsubscribe"
|
||||
>Mark as unsubscribed</b-radio>
|
||||
</div>
|
||||
</b-field>
|
||||
|
||||
<list-selector
|
||||
label="Target lists"
|
||||
placeholder="Lists to apply to"
|
||||
v-model="form.lists"
|
||||
:selected="form.lists"
|
||||
:all="lists.results"
|
||||
></list-selector>
|
||||
</section>
|
||||
|
||||
<footer class="modal-card-foot has-text-right">
|
||||
<b-button @click="$parent.close()">Close</b-button>
|
||||
<b-button native-type="submit" type="is-primary"
|
||||
:disabled="form.lists.length === 0">Save</b-button>
|
||||
</footer>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Vue from 'vue';
|
||||
import { mapState } from 'vuex';
|
||||
import ListSelector from '../components/ListSelector.vue';
|
||||
|
||||
export default Vue.extend({
|
||||
components: {
|
||||
ListSelector,
|
||||
},
|
||||
|
||||
props: {
|
||||
numSubscribers: Number,
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
// Binds form input values.
|
||||
form: {
|
||||
action: 'add',
|
||||
lists: [],
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
methods: {
|
||||
onSubmit() {
|
||||
this.$emit('finished', this.form.action, this.form.lists);
|
||||
this.$parent.close();
|
||||
},
|
||||
},
|
||||
|
||||
computed: {
|
||||
...mapState(['lists', 'loading']),
|
||||
},
|
||||
});
|
||||
</script>
|
|
@ -0,0 +1,197 @@
|
|||
<template>
|
||||
<form @submit.prevent="onSubmit">
|
||||
<div class="modal-card content" style="width: auto">
|
||||
<header class="modal-card-head">
|
||||
|
||||
<b-tag v-if="isEditing" :class="[data.status, 'is-pulled-right']">{{ data.status }}</b-tag>
|
||||
<h4 v-if="isEditing">{{ data.name }}</h4>
|
||||
<h4 v-else>New subscriber</h4>
|
||||
|
||||
<p v-if="isEditing" class="has-text-grey is-size-7">
|
||||
ID: {{ data.id }} / UUID: {{ data.uuid }}
|
||||
</p>
|
||||
</header>
|
||||
<section expanded class="modal-card-body">
|
||||
<b-field label="E-mail">
|
||||
<b-input :maxlength="200" v-model="form.email" :ref="'focus'"
|
||||
placeholder="E-mail" required></b-input>
|
||||
</b-field>
|
||||
|
||||
<b-field label="Name">
|
||||
<b-input :maxlength="200" v-model="form.name" placeholder="Name"></b-input>
|
||||
</b-field>
|
||||
|
||||
<b-field label="Status" message="Blacklisted subscribers will never receive any e-mails.">
|
||||
<b-select v-model="form.status" placeholder="Status" required>
|
||||
<option value="enabled">Enabled</option>
|
||||
<option value="blacklisted">Blacklisted</option>
|
||||
</b-select>
|
||||
</b-field>
|
||||
|
||||
<list-selector
|
||||
label="Lists"
|
||||
placeholder="Lists to subscribe to"
|
||||
message="Lists from which subscribers have unsubscribed themselves cannot be removed."
|
||||
v-model="form.lists"
|
||||
:selected="form.lists"
|
||||
:all="lists.results"
|
||||
></list-selector>
|
||||
|
||||
<b-field label="Attributes"
|
||||
message='Attributes are defined as a JSON map, for example:
|
||||
{"job": "developer", "location": "Mars", "has_rocket": true}.'>
|
||||
<b-input v-model="form.strAttribs" type="textarea" />
|
||||
</b-field>
|
||||
<a href="https://listmonk.app/docs/concepts"
|
||||
target="_blank" rel="noopener noreferrer" class="is-size-7">
|
||||
Learn more <b-icon icon="link" size="is-small" />.
|
||||
</a>
|
||||
</section>
|
||||
<footer class="modal-card-foot has-text-right">
|
||||
<b-button @click="$parent.close()">Close</b-button>
|
||||
<b-button native-type="submit" type="is-primary"
|
||||
:loading="loading.subscribers">Save</b-button>
|
||||
</footer>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Vue from 'vue';
|
||||
import { mapState } from 'vuex';
|
||||
import ListSelector from '../components/ListSelector.vue';
|
||||
|
||||
export default Vue.extend({
|
||||
components: {
|
||||
ListSelector,
|
||||
},
|
||||
|
||||
props: {
|
||||
data: {
|
||||
type: Object,
|
||||
default: () => {},
|
||||
},
|
||||
isEditing: Boolean,
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
// Binds form input values. This is populated by subscriber props passed
|
||||
// from the parent component in mounted().
|
||||
form: { lists: [], strAttribs: '{}' },
|
||||
};
|
||||
},
|
||||
|
||||
methods: {
|
||||
onSubmit() {
|
||||
if (this.isEditing) {
|
||||
this.updateSubscriber();
|
||||
return;
|
||||
}
|
||||
|
||||
this.createSubscriber();
|
||||
},
|
||||
|
||||
createSubscriber() {
|
||||
const attribs = this.validateAttribs(this.form.strAttribs);
|
||||
if (!attribs) {
|
||||
return;
|
||||
}
|
||||
|
||||
const data = {
|
||||
email: this.form.email,
|
||||
name: this.form.name,
|
||||
status: this.form.status,
|
||||
attribs,
|
||||
|
||||
// List IDs.
|
||||
lists: this.form.lists.map((l) => l.id),
|
||||
};
|
||||
|
||||
this.$api.createSubscriber(data).then((resp) => {
|
||||
this.$emit('finished');
|
||||
this.$parent.close();
|
||||
this.$buefy.toast.open({
|
||||
message: `'${resp.data.name}' created`,
|
||||
type: 'is-success',
|
||||
queue: false,
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
updateSubscriber() {
|
||||
const attribs = this.validateAttribs(this.form.strAttribs);
|
||||
if (!attribs) {
|
||||
return;
|
||||
}
|
||||
|
||||
const data = {
|
||||
id: this.form.id,
|
||||
email: this.form.email,
|
||||
name: this.form.name,
|
||||
status: this.form.status,
|
||||
attribs,
|
||||
|
||||
// List IDs.
|
||||
lists: this.form.lists.map((l) => l.id),
|
||||
};
|
||||
|
||||
this.$api.updateSubscriber(data).then((resp) => {
|
||||
this.$emit('finished');
|
||||
this.$parent.close();
|
||||
this.$buefy.toast.open({
|
||||
message: `'${resp.data.name}' updated`,
|
||||
type: 'is-success',
|
||||
queue: false,
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
validateAttribs(str) {
|
||||
// Parse and validate attributes JSON.
|
||||
let attribs = {};
|
||||
try {
|
||||
attribs = JSON.parse(str);
|
||||
} catch (e) {
|
||||
this.$buefy.toast.open({
|
||||
message: `Invalid JSON in attributes: ${e.toString()}`,
|
||||
type: 'is-danger',
|
||||
duration: 3000,
|
||||
queue: false,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
if (attribs instanceof Array) {
|
||||
this.$buefy.toast.open({
|
||||
message: 'Attributes should be a map {} and not an array []',
|
||||
type: 'is-danger',
|
||||
duration: 3000,
|
||||
queue: false,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
return attribs;
|
||||
},
|
||||
},
|
||||
|
||||
computed: {
|
||||
...mapState(['lists', 'loading']),
|
||||
},
|
||||
|
||||
mounted() {
|
||||
if (this.$props.isEditing) {
|
||||
this.form = {
|
||||
...this.$props.data,
|
||||
|
||||
// Deep-copy the lists array on to the form.
|
||||
strAttribs: JSON.stringify(this.$props.data.attribs, null, 4),
|
||||
};
|
||||
}
|
||||
|
||||
this.$nextTick(() => {
|
||||
this.$refs.focus.focus();
|
||||
});
|
||||
},
|
||||
});
|
||||
</script>
|
|
@ -0,0 +1,450 @@
|
|||
<template>
|
||||
<section class="subscribers">
|
||||
<header class="columns">
|
||||
<div class="column is-half">
|
||||
<h1 class="title is-4">Subscribers
|
||||
<span v-if="subscribers.total > 0">({{ subscribers.total }})</span>
|
||||
|
||||
<span v-if="currentList">
|
||||
» {{ currentList.name }}
|
||||
</span>
|
||||
</h1>
|
||||
</div>
|
||||
<div class="column has-text-right">
|
||||
<b-button type="is-primary" icon-left="plus" @click="showNewForm">New</b-button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="subscribers-controls columns">
|
||||
<div class="column is-4">
|
||||
<form @submit.prevent="querySubscribers">
|
||||
<div>
|
||||
<b-field grouped>
|
||||
<b-input v-model="queryParams.query"
|
||||
placeholder="E-mail or name" icon="account-search-outline" ref="query"
|
||||
:disabled="isSearchAdvanced"></b-input>
|
||||
<b-button native-type="submit" type="is-primary" icon-left="account-search-outline"
|
||||
:disabled="isSearchAdvanced"></b-button>
|
||||
</b-field>
|
||||
|
||||
<p>
|
||||
<a href="#" @click.prevent="toggleAdvancedSearch">
|
||||
<b-icon icon="cog-outline" size="is-small" /> Advanced</a>
|
||||
</p>
|
||||
|
||||
<div v-if="isSearchAdvanced">
|
||||
<b-field>
|
||||
<b-input v-model="queryParams.fullQuery"
|
||||
type="textarea" ref="fullQuery"
|
||||
placeholder="subscribers.name LIKE '%user%' or subscribers.status='blacklisted'">
|
||||
</b-input>
|
||||
</b-field>
|
||||
<b-field>
|
||||
<span class="is-size-6 has-text-grey">
|
||||
Partial SQL expression to query subscriber attributes.{{ ' ' }}
|
||||
<a href="https://listmonk.app/docs/querying-and-segmentation"
|
||||
target="_blank" rel="noopener noreferrer">
|
||||
Learn more <b-icon icon="link" size="is-small" />.
|
||||
</a>
|
||||
</span>
|
||||
</b-field>
|
||||
|
||||
<div class="buttons">
|
||||
<b-button native-type="submit" type="is-primary"
|
||||
icon-left="account-search-outline">Query</b-button>
|
||||
<b-button @click.prevent="toggleAdvancedSearch" icon-left="close">Reset</b-button>
|
||||
</div>
|
||||
</div><!-- advanced query -->
|
||||
</div>
|
||||
</form>
|
||||
</div><!-- search -->
|
||||
|
||||
<div class="column is-4 subscribers-bulk" v-if="bulk.checked.length > 0">
|
||||
<div>
|
||||
<p>
|
||||
<span class="is-size-5 has-text-weight-semibold">
|
||||
{{ numSelectedSubscribers }} subscriber(s) selected
|
||||
</span>
|
||||
<span v-if="!bulk.all && subscribers.total > subscribers.perPage">
|
||||
— <a href="" @click.prevent="selectAllSubscribers">
|
||||
Select all {{ subscribers.total }}</a>
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<p class="actions">
|
||||
<a href='' @click.prevent="showBulkListForm">
|
||||
<b-icon icon="format-list-bulleted-square" size="is-small" /> Manage lists
|
||||
</a>
|
||||
|
||||
<a href='' @click.prevent="deleteSubscribers">
|
||||
<b-icon icon="trash-can-outline" size="is-small" /> Delete
|
||||
</a>
|
||||
|
||||
<a href='' @click.prevent="blacklistSubscribers">
|
||||
<b-icon icon="account-off-outline" size="is-small" /> Blacklist
|
||||
</a>
|
||||
</p><!-- selection actions //-->
|
||||
</div>
|
||||
</div>
|
||||
</section><!-- control -->
|
||||
|
||||
<b-table
|
||||
:data="subscribers.results"
|
||||
:loading="loading.subscribers"
|
||||
@check-all="onTableCheck" @check="onTableCheck"
|
||||
:checked-rows.sync="bulk.checked"
|
||||
paginated backend-pagination pagination-position="both" @page-change="onPageChange"
|
||||
:current-page="queryParams.page" :per-page="subscribers.perPage" :total="subscribers.total"
|
||||
hoverable
|
||||
checkable>
|
||||
<template slot-scope="props">
|
||||
<b-table-column field="status" label="Status">
|
||||
<a :href="`/subscribers/${props.row.id}`"
|
||||
@click.prevent="showEditForm(props.row)">
|
||||
<b-tag :class="props.row.status">{{ props.row.status }}</b-tag>
|
||||
</a>
|
||||
</b-table-column>
|
||||
|
||||
<b-table-column field="email" label="E-mail">
|
||||
<a :href="`/subscribers/${props.row.id}`"
|
||||
@click.prevent="showEditForm(props.row)">
|
||||
{{ props.row.email }}
|
||||
</a>
|
||||
<b-taglist>
|
||||
<router-link :to="`/subscribers/lists/${props.row.id}`">
|
||||
<b-tag :class="l.subscriptionStatus" v-for="l in props.row.lists"
|
||||
size="is-small" :key="l.id">
|
||||
{{ l.name }} <sup>{{ l.subscriptionStatus }}</sup>
|
||||
</b-tag>
|
||||
</router-link>
|
||||
</b-taglist>
|
||||
</b-table-column>
|
||||
|
||||
<b-table-column field="name" label="Name">
|
||||
<a :href="`/subscribers/${props.row.id}`"
|
||||
@click.prevent="showEditForm(props.row)">
|
||||
{{ props.row.name }}
|
||||
</a>
|
||||
</b-table-column>
|
||||
|
||||
<b-table-column field="lists" label="Lists" numeric centered>
|
||||
{{ listCount(props.row.lists) }}
|
||||
</b-table-column>
|
||||
|
||||
<b-table-column field="createdAt" label="Created">
|
||||
{{ $utils.niceDate(props.row.createdAt) }}
|
||||
</b-table-column>
|
||||
|
||||
<b-table-column field="updatedAt" label="Updated">
|
||||
{{ $utils.niceDate(props.row.updatedAt) }}
|
||||
</b-table-column>
|
||||
|
||||
<b-table-column class="actions" align="right">
|
||||
<a :href="`/api/subscribers/${props.row.id}/export`">
|
||||
<b-tooltip label="Download data" type="is-dark">
|
||||
<b-icon icon="cloud-download-outline" size="is-small" />
|
||||
</b-tooltip>
|
||||
</a>
|
||||
<a :href="`/subscribers/${props.row.id}`"
|
||||
@click.prevent="showEditForm(props.row)">
|
||||
<b-tooltip label="Edit" type="is-dark">
|
||||
<b-icon icon="pencil-outline" size="is-small" />
|
||||
</b-tooltip>
|
||||
</a>
|
||||
<a href='' @click.prevent="deleteSubscriber(props.row)">
|
||||
<b-tooltip label="Delete" type="is-dark">
|
||||
<b-icon icon="trash-can-outline" size="is-small" />
|
||||
</b-tooltip>
|
||||
</a>
|
||||
</b-table-column>
|
||||
</template>
|
||||
<template slot="empty" v-if="!loading.subscribers">
|
||||
<empty-placeholder />
|
||||
</template>
|
||||
</b-table>
|
||||
|
||||
<!-- Manage list modal -->
|
||||
<b-modal scroll="keep" :aria-modal="true" :active.sync="isBulkListFormVisible" :width="450">
|
||||
<subscriber-bulk-list :numSubscribers="this.numSelectedSubscribers"
|
||||
@finished="bulkChangeLists" />
|
||||
</b-modal>
|
||||
|
||||
<!-- Add / edit form modal -->
|
||||
<b-modal scroll="keep" :aria-modal="true" :active.sync="isFormVisible" :width="750">
|
||||
<subscriber-form :data="curItem" :isEditing="isEditing"
|
||||
@finished="querySubscribers"></subscriber-form>
|
||||
</b-modal>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Vue from 'vue';
|
||||
import { mapState } from 'vuex';
|
||||
import SubscriberForm from './SubscriberForm.vue';
|
||||
import SubscriberBulkList from './SubscriberBulkList.vue';
|
||||
import EmptyPlaceholder from '../components/EmptyPlaceholder.vue';
|
||||
|
||||
export default Vue.extend({
|
||||
components: {
|
||||
SubscriberForm,
|
||||
SubscriberBulkList,
|
||||
EmptyPlaceholder,
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
// Current subscriber item being edited.
|
||||
curItem: null,
|
||||
isSearchAdvanced: false,
|
||||
isEditing: false,
|
||||
isFormVisible: false,
|
||||
isBulkListFormVisible: false,
|
||||
|
||||
// Table bulk row selection states.
|
||||
bulk: {
|
||||
checked: [],
|
||||
all: false,
|
||||
},
|
||||
|
||||
// Query params to filter the getSubscribers() API call.
|
||||
queryParams: {
|
||||
// Simple query field.
|
||||
query: '',
|
||||
|
||||
// Advanced query filled. This value should be accessed via fullQueryExp().
|
||||
fullQuery: '',
|
||||
|
||||
// ID of the list the current subscriber view is filtered by.
|
||||
listID: null,
|
||||
page: 1,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
methods: {
|
||||
// Count the lists from which a subscriber has not unsubscribed.
|
||||
listCount(lists) {
|
||||
return lists.reduce((defVal, item) => (defVal + item.status !== 'unsubscribed' ? 1 : 0), 0);
|
||||
},
|
||||
|
||||
toggleAdvancedSearch() {
|
||||
this.isSearchAdvanced = !this.isSearchAdvanced;
|
||||
|
||||
// Toggling to simple search.
|
||||
if (!this.isSearchAdvanced) {
|
||||
this.$nextTick(() => {
|
||||
this.$refs.query.focus();
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Toggling to advanced search.
|
||||
this.$nextTick(() => {
|
||||
// Turn the string in the simple query input into an SQL exprssion and
|
||||
// show in the full query input.
|
||||
if (this.queryParams.query !== '') {
|
||||
this.queryParams.fullQuery = this.fullQueryExp;
|
||||
}
|
||||
this.$refs.fullQuery.focus();
|
||||
});
|
||||
},
|
||||
|
||||
// Mark all subscribers in the query as selected.
|
||||
selectAllSubscribers() {
|
||||
this.bulk.all = true;
|
||||
},
|
||||
|
||||
onTableCheck() {
|
||||
// Disable bulk.all selection if there are no rows checked in the table.
|
||||
if (this.bulk.checked.length !== this.subscribers.total) {
|
||||
this.bulk.all = false;
|
||||
}
|
||||
},
|
||||
|
||||
// Show the edit list form.
|
||||
showEditForm(sub) {
|
||||
this.curItem = sub;
|
||||
this.isFormVisible = true;
|
||||
this.isEditing = true;
|
||||
},
|
||||
|
||||
// Show the new list form.
|
||||
showNewForm() {
|
||||
this.curItem = {};
|
||||
this.isFormVisible = true;
|
||||
this.isEditing = false;
|
||||
},
|
||||
|
||||
showBulkListForm() {
|
||||
this.isBulkListFormVisible = true;
|
||||
},
|
||||
|
||||
sortSubscribers(field, order, event) {
|
||||
console.log(field, order, event);
|
||||
},
|
||||
|
||||
onPageChange(p) {
|
||||
this.queryParams.page = p;
|
||||
this.querySubscribers();
|
||||
},
|
||||
|
||||
// Search / query subscribers.
|
||||
querySubscribers() {
|
||||
this.$api.getSubscribers({
|
||||
list_id: this.queryParams.listID,
|
||||
query: this.fullQueryExp,
|
||||
page: this.queryParams.page,
|
||||
}).then(() => {
|
||||
this.bulk.checked = [];
|
||||
});
|
||||
},
|
||||
|
||||
deleteSubscriber(sub) {
|
||||
this.$utils.confirm(
|
||||
'Are you sure?',
|
||||
() => {
|
||||
this.$api.deleteSubscriber(sub.id).then(() => {
|
||||
this.querySubscribers();
|
||||
|
||||
this.$buefy.toast.open({
|
||||
message: `'${sub.name}' deleted.`,
|
||||
type: 'is-success',
|
||||
queue: false,
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
},
|
||||
|
||||
blacklistSubscribers() {
|
||||
let fn = null;
|
||||
if (!this.bulk.all && this.bulk.checked.length > 0) {
|
||||
// If 'all' is not selected, blacklist subscribers by IDs.
|
||||
fn = () => {
|
||||
const ids = this.bulk.checked.map((s) => s.id);
|
||||
this.$api.blacklistSubscribers({ ids })
|
||||
.then(() => this.querySubscribers());
|
||||
};
|
||||
} else {
|
||||
// 'All' is selected, blacklist by query.
|
||||
fn = () => {
|
||||
this.$api.blacklistSubscribersByQuery({
|
||||
query: this.fullQueryExp,
|
||||
list_ids: [],
|
||||
}).then(() => this.querySubscribers());
|
||||
};
|
||||
}
|
||||
|
||||
this.$utils.confirm(
|
||||
`Blacklist ${this.numSelectedSubscribers} subscriber(s)?`,
|
||||
fn,
|
||||
);
|
||||
},
|
||||
|
||||
deleteSubscribers() {
|
||||
let fn = null;
|
||||
if (!this.bulk.all && this.bulk.checked.length > 0) {
|
||||
// If 'all' is not selected, delete subscribers by IDs.
|
||||
fn = () => {
|
||||
const ids = this.bulk.checked.map((s) => s.id);
|
||||
this.$api.deleteSubscribers({ id: ids })
|
||||
.then(() => {
|
||||
this.querySubscribers();
|
||||
|
||||
this.$buefy.toast.open({
|
||||
message: `${this.numSelectedSubscribers} subscriber(s) deleted`,
|
||||
type: 'is-success',
|
||||
queue: false,
|
||||
});
|
||||
});
|
||||
};
|
||||
} else {
|
||||
// 'All' is selected, delete by query.
|
||||
fn = () => {
|
||||
this.$api.deleteSubscribersByQuery({
|
||||
query: this.fullQueryExp,
|
||||
list_ids: [],
|
||||
}).then(() => {
|
||||
this.querySubscribers();
|
||||
|
||||
this.$buefy.toast.open({
|
||||
message: `${this.numSelectedSubscribers} subscriber(s) deleted`,
|
||||
type: 'is-success',
|
||||
queue: false,
|
||||
});
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
this.$utils.confirm(
|
||||
`Delete ${this.numSelectedSubscribers} subscriber(s)?`,
|
||||
fn,
|
||||
);
|
||||
},
|
||||
|
||||
bulkChangeLists(action, lists) {
|
||||
const data = {
|
||||
action,
|
||||
target_list_ids: lists.map((l) => l.id),
|
||||
};
|
||||
|
||||
let fn = null;
|
||||
if (!this.bulk.all && this.bulk.checked.length > 0) {
|
||||
// If 'all' is not selected, perform by IDs.
|
||||
fn = this.$api.addSubscribersToLists;
|
||||
data.ids = this.bulk.checked.map((s) => s.id);
|
||||
} else {
|
||||
// 'All' is selected, perform by query.
|
||||
fn = this.$api.addSubscribersToListsByQuery;
|
||||
}
|
||||
|
||||
fn(data).then(() => {
|
||||
this.querySubscribers();
|
||||
this.$buefy.toast.open({
|
||||
message: 'List change applied',
|
||||
type: 'is-success',
|
||||
queue: false,
|
||||
});
|
||||
});
|
||||
},
|
||||
},
|
||||
|
||||
computed: {
|
||||
...mapState(['subscribers', 'lists', 'loading']),
|
||||
|
||||
// Turns the value into the simple input field into an SQL query expression.
|
||||
fullQueryExp() {
|
||||
const q = this.queryParams.query.replace(/'/g, "''").trim();
|
||||
if (!q) {
|
||||
return '';
|
||||
}
|
||||
return `(name ~* '${q}' OR email ~* '${q}')`;
|
||||
},
|
||||
|
||||
numSelectedSubscribers() {
|
||||
if (this.bulk.all) {
|
||||
return this.subscribers.total;
|
||||
}
|
||||
return this.bulk.checked.length;
|
||||
},
|
||||
|
||||
// Returns the list that the subscribers are being filtered by in.
|
||||
currentList() {
|
||||
if (!this.queryParams.listID || !this.lists.results) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.lists.results.find((l) => l.id === this.queryParams.listID);
|
||||
},
|
||||
},
|
||||
|
||||
mounted() {
|
||||
if (this.$route.params.listID) {
|
||||
this.queryParams.listID = parseInt(this.$route.params.listID, 10);
|
||||
}
|
||||
|
||||
// Get subscribers on load.
|
||||
this.querySubscribers();
|
||||
},
|
||||
});
|
||||
</script>
|
|
@ -0,0 +1,139 @@
|
|||
<template>
|
||||
<section>
|
||||
<form @submit.prevent="onSubmit">
|
||||
<div class="modal-card content template-modal-content" style="width: auto">
|
||||
<header class="modal-card-head">
|
||||
<b-button @click="previewTemplate"
|
||||
class="is-pulled-right" type="is-primary"
|
||||
icon-left="file-find-outline">Preview</b-button>
|
||||
|
||||
<h4 v-if="isEditing">{{ data.name }}</h4>
|
||||
<h4 v-else>New template</h4>
|
||||
</header>
|
||||
<section expanded class="modal-card-body">
|
||||
<b-field label="Name">
|
||||
<b-input :maxlength="200" :ref="'focus'" v-model="form.name"
|
||||
placeholder="Name" required></b-input>
|
||||
</b-field>
|
||||
|
||||
<b-field label="Raw HTML">
|
||||
<b-input v-model="form.body" type="textarea" required />
|
||||
</b-field>
|
||||
|
||||
<p class="is-size-7">
|
||||
The placeholder <code>{{ egPlaceholder }}</code>
|
||||
should appear in the template.
|
||||
<a target="_blank" href="https://listmonk.app/docs/templating">Learn more.</a>
|
||||
</p>
|
||||
</section>
|
||||
<footer class="modal-card-foot has-text-right">
|
||||
<b-button @click="$parent.close()">Close</b-button>
|
||||
<b-button native-type="submit" type="is-primary"
|
||||
:loading="loading.templates">Save</b-button>
|
||||
</footer>
|
||||
</div>
|
||||
</form>
|
||||
<campaign-preview v-if="previewItem"
|
||||
type='template'
|
||||
:title="previewItem.name"
|
||||
:body="form.body"
|
||||
@close="closePreview"></campaign-preview>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Vue from 'vue';
|
||||
import { mapState } from 'vuex';
|
||||
import CampaignPreview from '../components/CampaignPreview.vue';
|
||||
|
||||
export default Vue.extend({
|
||||
components: {
|
||||
CampaignPreview,
|
||||
},
|
||||
|
||||
props: {
|
||||
data: Object,
|
||||
isEditing: null,
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
// Binds form input values.
|
||||
form: {
|
||||
name: '',
|
||||
type: '',
|
||||
optin: '',
|
||||
},
|
||||
previewItem: null,
|
||||
egPlaceholder: '{{ template "content" . }}',
|
||||
};
|
||||
},
|
||||
|
||||
methods: {
|
||||
previewTemplate() {
|
||||
this.previewItem = this.data;
|
||||
},
|
||||
|
||||
closePreview() {
|
||||
this.previewItem = null;
|
||||
},
|
||||
|
||||
onSubmit() {
|
||||
if (this.isEditing) {
|
||||
this.updateTemplate();
|
||||
return;
|
||||
}
|
||||
|
||||
this.createTemplate();
|
||||
},
|
||||
|
||||
createTemplate() {
|
||||
const data = {
|
||||
id: this.data.id,
|
||||
name: this.form.name,
|
||||
body: this.form.body,
|
||||
};
|
||||
|
||||
this.$api.createTemplate(data).then((resp) => {
|
||||
this.$emit('finished');
|
||||
this.$parent.close();
|
||||
this.$buefy.toast.open({
|
||||
message: `'${resp.data.name}' created`,
|
||||
type: 'is-success',
|
||||
queue: false,
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
updateTemplate() {
|
||||
const data = {
|
||||
id: this.data.id,
|
||||
name: this.form.name,
|
||||
body: this.form.body,
|
||||
};
|
||||
|
||||
this.$api.updateTemplate(data).then((resp) => {
|
||||
this.$emit('finished');
|
||||
this.$parent.close();
|
||||
this.$buefy.toast.open({
|
||||
message: `'${resp.data.name}' updated`,
|
||||
type: 'is-success',
|
||||
queue: false,
|
||||
});
|
||||
});
|
||||
},
|
||||
},
|
||||
|
||||
computed: {
|
||||
...mapState(['loading']),
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.form = { ...this.$props.data };
|
||||
|
||||
this.$nextTick(() => {
|
||||
this.$refs.focus.focus();
|
||||
});
|
||||
},
|
||||
});
|
||||
</script>
|
|
@ -0,0 +1,160 @@
|
|||
<template>
|
||||
<section class="templates">
|
||||
<header class="columns">
|
||||
<div class="column is-two-thirds">
|
||||
<h1 class="title is-4">Templates
|
||||
<span v-if="templates.length > 0">({{ templates.length }})</span></h1>
|
||||
</div>
|
||||
<div class="column has-text-right">
|
||||
<b-button type="is-primary" icon-left="plus" @click="showNewForm">New</b-button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<b-table :data="templates" :hoverable="true" :loading="loading.templates"
|
||||
default-sort="createdAt">
|
||||
<template slot-scope="props">
|
||||
<b-table-column field="name" label="Name" sortable>
|
||||
<a :href="props.row.id" @click.prevent="showEditForm(props.row)">
|
||||
{{ props.row.name }}
|
||||
</a>
|
||||
<b-tag v-if="props.row.isDefault">default</b-tag>
|
||||
</b-table-column>
|
||||
|
||||
<b-table-column field="createdAt" label="Created" sortable>
|
||||
{{ $utils.niceDate(props.row.createdAt) }}
|
||||
</b-table-column>
|
||||
|
||||
<b-table-column field="updatedAt" label="Updated" sortable>
|
||||
{{ $utils.niceDate(props.row.updatedAt) }}
|
||||
</b-table-column>
|
||||
|
||||
<b-table-column class="actions" align="right">
|
||||
<a href="#" @click.prevent="previewTemplate(props.row)">
|
||||
<b-tooltip label="Preview" type="is-dark">
|
||||
<b-icon icon="file-find-outline" size="is-small" />
|
||||
</b-tooltip>
|
||||
</a>
|
||||
<a href="#" @click.prevent="showEditForm(props.row)">
|
||||
<b-tooltip label="Edit" type="is-dark">
|
||||
<b-icon icon="pencil-outline" size="is-small" />
|
||||
</b-tooltip>
|
||||
</a>
|
||||
<a v-if="!props.row.isDefault" href="#"
|
||||
@click.prevent="$utils.confirm(null, () => makeTemplateDefault(props.row))">
|
||||
<b-tooltip label="Make default" type="is-dark">
|
||||
<b-icon icon="check-circle-outline" size="is-small" />
|
||||
</b-tooltip>
|
||||
</a>
|
||||
<a v-if="!props.row.isDefault"
|
||||
href="#" @click.prevent="$utils.confirm(null, () => deleteTemplate(props.row))">
|
||||
<b-tooltip label="Delete" type="is-dark">
|
||||
<b-icon icon="trash-can-outline" size="is-small" />
|
||||
</b-tooltip>
|
||||
</a>
|
||||
</b-table-column>
|
||||
</template>
|
||||
|
||||
<template slot="empty" v-if="!loading.templates">
|
||||
<empty-placeholder />
|
||||
</template>
|
||||
</b-table>
|
||||
|
||||
<!-- Add / edit form modal -->
|
||||
<b-modal scroll="keep" :aria-modal="true" :active.sync="isFormVisible"
|
||||
:width="1200" :can-cancel="false" class="template-modal">
|
||||
<template-form :data="curItem" :isEditing="isEditing"
|
||||
@finished="formFinished"></template-form>
|
||||
</b-modal>
|
||||
|
||||
<campaign-preview v-if="previewItem"
|
||||
type='template'
|
||||
:id="previewItem.id"
|
||||
:title="previewItem.name"
|
||||
@close="closePreview"></campaign-preview>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Vue from 'vue';
|
||||
import { mapState } from 'vuex';
|
||||
import TemplateForm from './TemplateForm.vue';
|
||||
import CampaignPreview from '../components/CampaignPreview.vue';
|
||||
import EmptyPlaceholder from '../components/EmptyPlaceholder.vue';
|
||||
|
||||
export default Vue.extend({
|
||||
components: {
|
||||
CampaignPreview,
|
||||
TemplateForm,
|
||||
EmptyPlaceholder,
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
curItem: null,
|
||||
isEditing: false,
|
||||
isFormVisible: false,
|
||||
previewItem: null,
|
||||
};
|
||||
},
|
||||
|
||||
methods: {
|
||||
// Show the edit form.
|
||||
showEditForm(data) {
|
||||
this.curItem = data;
|
||||
this.isFormVisible = true;
|
||||
this.isEditing = true;
|
||||
},
|
||||
|
||||
// Show the new form.
|
||||
showNewForm() {
|
||||
this.curItem = {};
|
||||
this.isFormVisible = true;
|
||||
this.isEditing = false;
|
||||
},
|
||||
|
||||
formFinished() {
|
||||
this.$api.getTemplates();
|
||||
},
|
||||
|
||||
previewTemplate(c) {
|
||||
this.previewItem = c;
|
||||
},
|
||||
|
||||
closePreview() {
|
||||
this.previewItem = null;
|
||||
},
|
||||
|
||||
makeTemplateDefault(tpl) {
|
||||
this.$api.makeTemplateDefault(tpl.id).then(() => {
|
||||
this.$api.getTemplates();
|
||||
|
||||
this.$buefy.toast.open({
|
||||
message: `'${tpl.name}' made default`,
|
||||
type: 'is-success',
|
||||
queue: false,
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
deleteTemplate(tpl) {
|
||||
this.$api.deleteTemplate(tpl.id).then(() => {
|
||||
this.$api.getTemplates();
|
||||
|
||||
this.$buefy.toast.open({
|
||||
message: `'${tpl.name}' deleted`,
|
||||
type: 'is-success',
|
||||
queue: false,
|
||||
});
|
||||
});
|
||||
},
|
||||
},
|
||||
|
||||
computed: {
|
||||
...mapState(['templates', 'loading']),
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.$api.getTemplates();
|
||||
},
|
||||
});
|
||||
</script>
|
|
@ -0,0 +1,17 @@
|
|||
module.exports = {
|
||||
publicPath: '/',
|
||||
outputDir: 'dist',
|
||||
|
||||
// This is to make all static file requests generated by Vue to go to
|
||||
// /frontend/*. However, this also ends up creating a `dist/frontend`
|
||||
// directory and moves all the static files in it. The physical directory
|
||||
// and the URI for assets are tightly coupled. This is handled in the Go app
|
||||
// by using stuffbin aliases.
|
||||
assetsDir: 'frontend',
|
||||
|
||||
// Move the index.html file from dist/index.html to dist/frontend/index.html
|
||||
indexPath: './frontend/index.html',
|
||||
|
||||
productionSourceMap: false,
|
||||
filenameHashing: false,
|
||||
};
|
File diff suppressed because it is too large
Load Diff
|
@ -38,7 +38,8 @@ var reUUID = regexp.MustCompile("^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[
|
|||
func registerHTTPHandlers(e *echo.Echo) {
|
||||
e.GET("/", handleIndexPage)
|
||||
e.GET("/api/config.js", handleGetConfigScript)
|
||||
e.GET("/api/dashboard/stats", handleGetDashboardStats)
|
||||
e.GET("/api/dashboard/charts", handleGetDashboardCharts)
|
||||
e.GET("/api/dashboard/counts", handleGetDashboardCounts)
|
||||
|
||||
e.GET("/api/subscribers/:id", handleGetSubscriber)
|
||||
e.GET("/api/subscribers/:id/export", handleExportSubscriberData)
|
||||
|
@ -115,6 +116,7 @@ func registerHTTPHandlers(e *echo.Echo) {
|
|||
|
||||
// Static views.
|
||||
e.GET("/lists", handleIndexPage)
|
||||
e.GET("/lists/forms", handleIndexPage)
|
||||
e.GET("/subscribers", handleIndexPage)
|
||||
e.GET("/subscribers/lists/:listID", handleIndexPage)
|
||||
e.GET("/subscribers/import", handleIndexPage)
|
||||
|
|
9
init.go
9
init.go
|
@ -52,8 +52,10 @@ func initFS(staticDir string) stuffbin.FileSystem {
|
|||
"static/public:/public",
|
||||
|
||||
// The frontend app's static assets are aliased to /frontend
|
||||
// so that they are accessible at localhost:port/frontend/static/ ...
|
||||
"frontend/build:/frontend",
|
||||
// so that they are accessible at /frontend/js/* etc.
|
||||
// Alias all files inside dist/ and dist/frontend to frontend/*.
|
||||
"frontend/dist/:/frontend",
|
||||
"frontend/dist/frontend:/frontend",
|
||||
}
|
||||
|
||||
fs, err = stuffbin.NewLocalFS("/", files...)
|
||||
|
@ -85,10 +87,13 @@ func initFS(staticDir string) stuffbin.FileSystem {
|
|||
// initDB initializes the main DB connection pool and parse and loads the app's
|
||||
// SQL queries into a prepared query map.
|
||||
func initDB() *sqlx.DB {
|
||||
|
||||
var dbCfg dbConf
|
||||
if err := ko.Unmarshal("db", &dbCfg); err != nil {
|
||||
lo.Fatalf("error loading db config: %v", err)
|
||||
}
|
||||
|
||||
lo.Printf("connecting to db: %s:%d/%s", dbCfg.Host, dbCfg.Port, dbCfg.DBName)
|
||||
db, err := connectDB(dbCfg)
|
||||
if err != nil {
|
||||
lo.Fatalf("error connecting to DB: %v", err)
|
||||
|
|
|
@ -158,7 +158,7 @@ type Campaign struct {
|
|||
Name string `db:"name" json:"name"`
|
||||
Subject string `db:"subject" json:"subject"`
|
||||
FromEmail string `db:"from_email" json:"from_email"`
|
||||
Body string `db:"body" json:"body,omitempty"`
|
||||
Body string `db:"body" json:"body"`
|
||||
SendAt null.Time `db:"send_at" json:"send_at"`
|
||||
Status string `db:"status" json:"status"`
|
||||
ContentType string `db:"content_type" json:"content_type"`
|
||||
|
@ -177,7 +177,7 @@ type Campaign struct {
|
|||
|
||||
// CampaignMeta contains fields tracking a campaign's progress.
|
||||
type CampaignMeta struct {
|
||||
CampaignID int `db:"campaign_id" json:""`
|
||||
CampaignID int `db:"campaign_id" json:"-"`
|
||||
Views int `db:"views" json:"views"`
|
||||
Clicks int `db:"clicks" json:"clicks"`
|
||||
|
||||
|
|
|
@ -11,7 +11,8 @@ import (
|
|||
|
||||
// Queries contains all prepared SQL queries.
|
||||
type Queries struct {
|
||||
GetDashboardStats *sqlx.Stmt `query:"get-dashboard-stats"`
|
||||
GetDashboardCharts *sqlx.Stmt `query:"get-dashboard-charts"`
|
||||
GetDashboardCounts *sqlx.Stmt `query:"get-dashboard-counts"`
|
||||
|
||||
InsertSubscriber *sqlx.Stmt `query:"insert-subscriber"`
|
||||
UpsertSubscriber *sqlx.Stmt `query:"upsert-subscriber"`
|
||||
|
|
53
queries.sql
53
queries.sql
|
@ -379,7 +379,7 @@ FROM campaigns
|
|||
WHERE ($1 = 0 OR id = $1)
|
||||
AND status=ANY(CASE WHEN ARRAY_LENGTH($2::campaign_status[], 1) != 0 THEN $2::campaign_status[] ELSE ARRAY[status] END)
|
||||
AND ($3 = '' OR (to_tsvector(name || subject) @@ to_tsquery($3)))
|
||||
ORDER BY created_at DESC OFFSET $4 LIMIT $5;
|
||||
ORDER BY campaigns.updated_at DESC OFFSET $4 LIMIT $5;
|
||||
|
||||
-- name: get-campaign
|
||||
SELECT campaigns.*,
|
||||
|
@ -678,21 +678,8 @@ INSERT INTO link_clicks (campaign_id, subscriber_id, link_id)
|
|||
RETURNING (SELECT url FROM link);
|
||||
|
||||
|
||||
-- name: get-dashboard-stats
|
||||
WITH lists AS (
|
||||
SELECT JSON_OBJECT_AGG(type, num) FROM (SELECT type, COUNT(id) AS num FROM lists GROUP BY type) row
|
||||
),
|
||||
subs AS (
|
||||
SELECT JSON_OBJECT_AGG(status, num) FROM (SELECT status, COUNT(id) AS num FROM subscribers GROUP by status) row
|
||||
),
|
||||
orphans AS (
|
||||
SELECT COUNT(id) FROM subscribers LEFT JOIN subscriber_lists ON (subscribers.id = subscriber_lists.subscriber_id)
|
||||
WHERE subscriber_lists.subscriber_id IS NULL
|
||||
),
|
||||
camps AS (
|
||||
SELECT JSON_OBJECT_AGG(status, num) FROM (SELECT status, COUNT(id) AS num FROM campaigns GROUP by status) row
|
||||
),
|
||||
clicks AS (
|
||||
-- name: get-dashboard-charts
|
||||
WITH clicks AS (
|
||||
-- Clicks by day for the last 3 months
|
||||
SELECT JSON_AGG(ROW_TO_JSON(row))
|
||||
FROM (SELECT COUNT(*) AS count, created_at::DATE as date
|
||||
|
@ -706,9 +693,31 @@ views AS (
|
|||
FROM campaign_views GROUP by date ORDER BY date DESC LIMIT 100
|
||||
) row
|
||||
)
|
||||
SELECT JSON_BUILD_OBJECT('lists', COALESCE((SELECT * FROM lists), '[]'),
|
||||
'subscribers', COALESCE((SELECT * FROM subs), '[]'),
|
||||
'orphan_subscribers', (SELECT * FROM orphans),
|
||||
'campaigns', COALESCE((SELECT * FROM camps), '[]'),
|
||||
'link_clicks', COALESCE((SELECT * FROM clicks), '[]'),
|
||||
'campaign_views', COALESCE((SELECT * FROM views), '[]')) AS stats;
|
||||
SELECT JSON_BUILD_OBJECT('link_clicks', COALESCE((SELECT * FROM clicks), '[]'),
|
||||
'campaign_views', COALESCE((SELECT * FROM views), '[]'));
|
||||
|
||||
-- name: get-dashboard-counts
|
||||
SELECT JSON_BUILD_OBJECT('subscribers', JSON_BUILD_OBJECT(
|
||||
'total', (SELECT COUNT(*) FROM subscribers),
|
||||
'blacklisted', (SELECT COUNT(*) FROM subscribers WHERE status='blacklisted'),
|
||||
'orphans', (
|
||||
SELECT COUNT(id) FROM subscribers
|
||||
LEFT JOIN subscriber_lists ON (subscribers.id = subscriber_lists.subscriber_id)
|
||||
WHERE subscriber_lists.subscriber_id IS NULL
|
||||
)
|
||||
),
|
||||
'lists', JSON_BUILD_OBJECT(
|
||||
'total', (SELECT COUNT(*) FROM lists),
|
||||
'private', (SELECT COUNT(*) FROM lists WHERE type='private'),
|
||||
'public', (SELECT COUNT(*) FROM lists WHERE type='public'),
|
||||
'optin_single', (SELECT COUNT(*) FROM lists WHERE optin='single'),
|
||||
'optin_double', (SELECT COUNT(*) FROM lists WHERE optin='double')
|
||||
),
|
||||
'campaigns', JSON_BUILD_OBJECT(
|
||||
'total', (SELECT COUNT(*) FROM campaigns),
|
||||
'by_status', (
|
||||
SELECT JSON_OBJECT_AGG (status, num) FROM
|
||||
(SELECT status, COUNT(*) AS num FROM campaigns GROUP BY status) r
|
||||
)
|
||||
),
|
||||
'messages', (SELECT SUM(sent) AS messages FROM campaigns));
|
||||
|
|
|
@ -118,7 +118,7 @@ func handleQuerySubscribers(c echo.Context) error {
|
|||
defer tx.Rollback()
|
||||
|
||||
// Run the query.
|
||||
if err := tx.Select(&out.Results, stmt, listIDs, "id", pg.Offset, pg.Limit); err != nil {
|
||||
if err := tx.Select(&out.Results, stmt, listIDs, "updaated_at", pg.Offset, pg.Limit); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError,
|
||||
fmt.Sprintf("Error querying subscribers: %v", pqErrMsg(err)))
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue