Rewrite frontend with Vue+Buevy and ditch React+Ant Design.
- antd+react was resulting in extremely clunky and unreadable spaghetti frontend code (primarily due to how antd is). - Buefy is lighter by an order of magnitude, has excellent responsive views (especially tables) and usability. - Vue's templating produces far more readable template code.
This commit is contained in:
parent
693b939a72
commit
97583fe4b4
70 changed files with 88250 additions and 6246 deletions
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:
|
||||
|
|
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)
|
||||
|
|
5
frontend/.babelrc
vendored
5
frontend/.babelrc
vendored
|
@ -1,5 +0,0 @@
|
|||
{
|
||||
"presets": ["env", "react"],
|
||||
"plugins": [["transform-react-jsx", { "pragma": "h" }]]
|
||||
}
|
||||
|
3
frontend/.browserslistrc
vendored
Normal file
3
frontend/.browserslistrc
vendored
Normal file
|
@ -0,0 +1,3 @@
|
|||
> 1%
|
||||
last 2 versions
|
||||
not dead
|
7
frontend/.editorconfig
vendored
Normal file
7
frontend/.editorconfig
vendored
Normal file
|
@ -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
|
17
frontend/.eslintrc.js
vendored
Normal file
17
frontend/.eslintrc.js
vendored
Normal file
|
@ -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',
|
||||
},
|
||||
};
|
33
frontend/.gitignore
vendored
33
frontend/.gitignore
vendored
|
@ -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?
|
||||
|
|
16
frontend/README.md
vendored
Normal file
16
frontend/README.md
vendored
Normal file
|
@ -0,0 +1,16 @@
|
|||
# listmonk frontend (Vue + Buefy)
|
||||
|
||||
It's best if the `listmonk/frontend` editor is opened in an IDE as a separate project where the frontend directory is the rool of the project.
|
||||
|
||||
|
||||
## 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`
|
5
frontend/babel.config.js
vendored
Normal file
5
frontend/babel.config.js
vendored
Normal file
|
@ -0,0 +1,5 @@
|
|||
module.exports = {
|
||||
presets: [
|
||||
'@vue/cli-plugin-babel/preset',
|
||||
],
|
||||
};
|
31
frontend/config-overrides.js
vendored
31
frontend/config-overrides.js
vendored
|
@ -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
|
||||
}
|
74840
frontend/fontello/config.json
Normal file
74840
frontend/fontello/config.json
Normal file
File diff suppressed because it is too large
Load diff
57
frontend/package.json
vendored
57
frontend/package.json
vendored
|
@ -2,36 +2,39 @@
|
|||
"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",
|
||||
"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-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,18 @@
|
|||
<!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>
|
||||
</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
|
124
frontend/src/App.vue
Normal file
124
frontend/src/App.vue
Normal file
|
@ -0,0 +1,124 @@
|
|||
<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>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Vue from 'vue';
|
||||
|
||||
export default Vue.extend({
|
||||
name: 'App',
|
||||
|
||||
data() {
|
||||
return {
|
||||
activeItem: {},
|
||||
activeGroup: {},
|
||||
};
|
||||
},
|
||||
|
||||
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;
|
183
frontend/src/api/index.js
Normal file
183
frontend/src/api/index.js
Normal file
|
@ -0,0 +1,183 @@
|
|||
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: { ... } )
|
||||
|
||||
// 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 });
|
43
frontend/src/assets/buefy.scss
Normal file
43
frontend/src/assets/buefy.scss
Normal file
|
@ -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";
|
BIN
frontend/src/assets/favicon.png
Normal file
BIN
frontend/src/assets/favicon.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.2 KiB |
72
frontend/src/assets/icons/fontello.css
vendored
Normal file
72
frontend/src/assets/icons/fontello.css
vendored
Normal file
|
@ -0,0 +1,72 @@
|
|||
@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'; } /* '' */
|
BIN
frontend/src/assets/icons/fontello.woff2
Normal file
BIN
frontend/src/assets/icons/fontello.woff2
Normal file
Binary file not shown.
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 10 KiB |
480
frontend/src/assets/style.scss
Normal file
480
frontend/src/assets/style.scss
Normal file
|
@ -0,0 +1,480 @@
|
|||
/* 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;
|
||||
}
|
||||
|
||||
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: 5px;
|
||||
}
|
||||
}
|
||||
.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 {
|
||||
.counts {
|
||||
.title {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.level-item {
|
||||
background-color: $white-bis;
|
||||
padding: 30px;
|
||||
margin: 10px;
|
||||
|
||||
&:first-child, &:last-child {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* 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;
|
||||
}
|
||||
}
|
||||
|
||||
@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;
|
||||
}
|
||||
}
|
93
frontend/src/components/CampaignPreview.vue
Normal file
93
frontend/src/components/CampaignPreview.vue
Normal file
|
@ -0,0 +1,93 @@
|
|||
<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(() => {
|
||||
this.$refs.form.submit();
|
||||
}, 100);
|
||||
},
|
||||
};
|
||||
</script>
|
183
frontend/src/components/Editor.vue
Normal file
183
frontend/src/components/Editor.vue
Normal file
|
@ -0,0 +1,183 @@
|
|||
<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)"
|
||||
/>
|
||||
|
||||
<!-- 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 Vue from 'vue';
|
||||
import 'quill/dist/quill.snow.css';
|
||||
import 'quill/dist/quill.core.css';
|
||||
import { quillEditor } from 'vue-quill-editor';
|
||||
// import Delta from 'quill-delta';
|
||||
import CampaignPreview from './CampaignPreview.vue';
|
||||
import Media from '../views/Media.vue';
|
||||
|
||||
Vue.component('media', Media);
|
||||
Vue.component('campaign-preview', CampaignPreview);
|
||||
|
||||
export default {
|
||||
components: {
|
||||
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';
|
||||
},
|
||||
);
|
||||
},
|
||||
|
||||
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>
|
111
frontend/src/components/ListSelector.vue
Normal file
111
frontend/src/components/ListSelector.vue
Normal file
|
@ -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,22 @@
|
|||
export const DateFormat = "ddd D MMM YYYY, hh:mm A"
|
||||
export const models = Object.freeze({
|
||||
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"
|
||||
|
||||
// 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 timestamp = 'ddd D MMM YYYY, hh:mm A';
|
||||
|
|
|
@ -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 |
21
frontend/src/main.js
Normal file
21
frontend/src/main.js
Normal file
|
@ -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();
|
||||
});
|
||||
}
|
||||
}
|
95
frontend/src/router/index.js
Normal file
95
frontend/src/router/index.js
Normal file
|
@ -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 |
48
frontend/src/store/index.js
Normal file
48
frontend/src/store/index.js
Normal file
|
@ -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,92 @@
|
|||
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) => {
|
||||
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;
|
||||
}
|
||||
|
||||
// HttpError takes an axios error and returns an error dict after some sanity checks.
|
||||
static HttpError = err => {
|
||||
if (!err.response) {
|
||||
return err
|
||||
// Whole number without decimals.
|
||||
const out = (n / div);
|
||||
if (Math.floor(out) === n) {
|
||||
return out + pfx;
|
||||
}
|
||||
|
||||
if (!err.response.data || !err.response.data.message) {
|
||||
return {
|
||||
message: err.message + " - " + err.response.request.responseURL,
|
||||
data: {}
|
||||
}
|
||||
return out.toFixed(2) + pfx;
|
||||
}
|
||||
|
||||
return {
|
||||
message: err.response.data.message,
|
||||
data: err.response.data.data
|
||||
}
|
||||
}
|
||||
// UI shortcuts.
|
||||
static confirm = (msg, onConfirm, onCancel) => {
|
||||
Dialog.confirm({
|
||||
scroll: 'keep',
|
||||
message: !msg ? 'Are you sure?' : msg,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
});
|
||||
};
|
||||
|
||||
// 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")
|
||||
)
|
||||
}
|
||||
}
|
||||
static prompt = (msg, inputAttrs, onConfirm, onCancel) => {
|
||||
Dialog.prompt({
|
||||
scroll: 'keep',
|
||||
message: msg,
|
||||
confirmText: 'OK',
|
||||
inputAttrs: {
|
||||
type: 'string',
|
||||
maxlength: 200,
|
||||
...inputAttrs,
|
||||
},
|
||||
trapFocus: true,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
});
|
||||
};
|
||||
|
||||
export default Utils
|
||||
static toast = (msg, typ) => {
|
||||
Toast.open({
|
||||
message: msg,
|
||||
type: !typ ? 'is-success' : typ,
|
||||
queue: false,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
|
5
frontend/src/views/About.vue
Normal file
5
frontend/src/views/About.vue
Normal file
|
@ -0,0 +1,5 @@
|
|||
<template>
|
||||
<div class="about">
|
||||
<h1>This is an about page</h1>
|
||||
</div>
|
||||
</template>
|
366
frontend/src/views/Campaign.vue
Normal file
366
frontend/src/views/Campaign.vue
Normal file
|
@ -0,0 +1,366 @@
|
|||
<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" 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';
|
||||
|
||||
Vue.component('list-selector', ListSelector);
|
||||
Vue.component('editor', Editor);
|
||||
|
||||
export default Vue.extend({
|
||||
name: 'Campaign',
|
||||
|
||||
data() {
|
||||
return {
|
||||
isNew: false,
|
||||
isEditing: false,
|
||||
activeTab: 0,
|
||||
|
||||
data: {},
|
||||
|
||||
// Binds form input values.
|
||||
form: {
|
||||
name: '',
|
||||
subject: '',
|
||||
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) {
|
||||
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', 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);
|
||||
}
|
||||
|
||||
this.$nextTick(() => {
|
||||
this.$refs.focus.focus();
|
||||
});
|
||||
},
|
||||
});
|
||||
</script>
|
368
frontend/src/views/Campaigns.vue
Normal file
368
frontend/src/views/Campaigns.vue
Normal file
|
@ -0,0 +1,368 @@
|
|||
<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.lists">
|
||||
<section class="section">
|
||||
<div class="content has-text-grey has-text-centered">
|
||||
<p>
|
||||
<b-icon icon="plus" size="is-large" />
|
||||
</p>
|
||||
<p>Nothing here.</p>
|
||||
</div>
|
||||
</section>
|
||||
</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';
|
||||
|
||||
Vue.component('campaign-preview', CampaignPreview);
|
||||
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
export default Vue.extend({
|
||||
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>
|
58
frontend/src/views/Dashboard.vue
Normal file
58
frontend/src/views/Dashboard.vue
Normal file
|
@ -0,0 +1,58 @@
|
|||
<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>
|
||||
<div class="column has-text-right">
|
||||
<b-button type="is-primary" icon-left="plus" @click="showNewForm">New</b-button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="columns counts">
|
||||
<div class="column is-half">
|
||||
<div class="level">
|
||||
<div class="level-item has-text-centered">
|
||||
<div>
|
||||
<p class="title">0</p>
|
||||
<p class="heading">Subscribers</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="level-item has-text-centered">
|
||||
<div>
|
||||
<p class="title">0</p>
|
||||
<p class="heading">Lists</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="level-item has-text-centered">
|
||||
<div>
|
||||
<p class="title">0</p>
|
||||
<p class="heading">Campaigns</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="level-item has-text-centered">
|
||||
<div>
|
||||
<p class="title">0</p>
|
||||
<p class="heading">Messages sent</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Vue from 'vue';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
export default Vue.extend({
|
||||
name: 'Home',
|
||||
|
||||
computed: {
|
||||
dayjs() {
|
||||
return dayjs;
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
75
frontend/src/views/Forms.vue
Normal file
75
frontend/src/views/Forms.vue
Normal file
|
@ -0,0 +1,75 @@
|
|||
<template>
|
||||
<section class="forms content">
|
||||
<h1 class="title is-4">Forms</h1>
|
||||
<hr />
|
||||
<div class="columns">
|
||||
<div class="column is-4">
|
||||
<h4>Public lists</h4>
|
||||
<p>Select lists to add to the form.</p>
|
||||
<ul class="no">
|
||||
<li v-for="l in lists" :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 lists"><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>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Vue from 'vue';
|
||||
|
||||
export default Vue.extend({
|
||||
name: 'ListForm',
|
||||
|
||||
data() {
|
||||
return {
|
||||
checked: [],
|
||||
};
|
||||
},
|
||||
|
||||
computed: {
|
||||
lists() {
|
||||
if (!this.$store.state.lists.results) {
|
||||
return [];
|
||||
}
|
||||
return this.$store.state.lists.results.filter((l) => l.type === 'public');
|
||||
},
|
||||
selected() {
|
||||
const sel = [];
|
||||
this.checked.forEach((uuid) => {
|
||||
sel[uuid] = true;
|
||||
});
|
||||
return sel;
|
||||
},
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.$api.getLists();
|
||||
},
|
||||
});
|
||||
</script>
|
298
frontend/src/views/Import.vue
Normal file
298
frontend/src/views/Import.vue
Normal file
|
@ -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';
|
||||
|
||||
Vue.component('list-selector', ListSelector);
|
||||
|
||||
export default Vue.extend({
|
||||
name: 'Import',
|
||||
|
||||
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>
|
118
frontend/src/views/ListForm.vue
Normal file
118
frontend/src/views/ListForm.vue
Normal file
|
@ -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>
|
160
frontend/src/views/Lists.vue
Normal file
160
frontend/src/views/Lists.vue
Normal file
|
@ -0,0 +1,160 @@
|
|||
<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="subscribers" 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">
|
||||
<section class="section">
|
||||
<div class="content has-text-grey has-text-centered">
|
||||
<p>
|
||||
<b-icon icon="plus" size="is-large" />
|
||||
</p>
|
||||
<p>Nothing here.</p>
|
||||
</div>
|
||||
</section>
|
||||
</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';
|
||||
|
||||
Vue.component('list-form', ListForm);
|
||||
|
||||
export default Vue.extend({
|
||||
components: {
|
||||
ListForm,
|
||||
},
|
||||
|
||||
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>
|
179
frontend/src/views/Media.vue
Normal file
179
frontend/src/views/Media.vue
Normal file
|
@ -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>
|
75
frontend/src/views/SubscriberBulkList.vue
Normal file
75
frontend/src/views/SubscriberBulkList.vue
Normal file
|
@ -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';
|
||||
|
||||
Vue.component('list-selector', ListSelector);
|
||||
|
||||
export default Vue.extend({
|
||||
name: 'SubscriberBulkList',
|
||||
|
||||
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>
|
197
frontend/src/views/SubscriberForm.vue
Normal file
197
frontend/src/views/SubscriberForm.vue
Normal file
|
@ -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';
|
||||
|
||||
Vue.component('list-selector', ListSelector);
|
||||
|
||||
export default Vue.extend({
|
||||
name: 'SubscriberForm',
|
||||
|
||||
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>
|
457
frontend/src/views/Subscribers.vue
Normal file
457
frontend/src/views/Subscribers.vue
Normal file
|
@ -0,0 +1,457 @@
|
|||
<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">
|
||||
<section class="section">
|
||||
<div class="content has-text-grey has-text-centered">
|
||||
<p>
|
||||
<b-icon icon="plus" size="is-large" />
|
||||
</p>
|
||||
<p>No subscribers.</p>
|
||||
</div>
|
||||
</section>
|
||||
</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';
|
||||
|
||||
Vue.component('subscriber-form', SubscriberForm);
|
||||
Vue.component('subscriber-bulk-list', SubscriberBulkList);
|
||||
|
||||
export default Vue.extend({
|
||||
components: {
|
||||
SubscriberForm,
|
||||
},
|
||||
|
||||
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>
|
139
frontend/src/views/TemplateForm.vue
Normal file
139
frontend/src/views/TemplateForm.vue
Normal file
|
@ -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';
|
||||
|
||||
Vue.component('campaign-preview', CampaignPreview);
|
||||
|
||||
export default Vue.extend({
|
||||
name: 'TemplateForm',
|
||||
|
||||
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>
|
167
frontend/src/views/Templates.vue
Normal file
167
frontend/src/views/Templates.vue
Normal file
|
@ -0,0 +1,167 @@
|
|||
<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">
|
||||
<section class="section">
|
||||
<div class="content has-text-grey has-text-centered">
|
||||
<p>
|
||||
<b-icon icon="plus" size="is-large" />
|
||||
</p>
|
||||
<p>Nothing here.</p>
|
||||
</div>
|
||||
</section>
|
||||
</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';
|
||||
|
||||
Vue.component('campaign-preview', CampaignPreview);
|
||||
Vue.component('template-form', TemplateForm);
|
||||
|
||||
export default Vue.extend({
|
||||
components: {
|
||||
TemplateForm,
|
||||
},
|
||||
|
||||
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>
|
17
frontend/vue.config.js
vendored
Normal file
17
frontend/vue.config.js
vendored
Normal file
|
@ -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,
|
||||
};
|
9060
frontend/yarn.lock
vendored
Normal file
9060
frontend/yarn.lock
vendored
Normal file
File diff suppressed because it is too large
Load diff
|
@ -115,6 +115,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)
|
||||
|
|
6
init.go
6
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...)
|
||||
|
|
|
@ -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"`
|
||||
|
||||
|
|
|
@ -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.*,
|
||||
|
|
Loading…
Reference in a new issue