Add subscription forms
This commit is contained in:
parent
b205761fb3
commit
c08ca14a5b
9 changed files with 264 additions and 56 deletions
96
frontend/src/Forms.js
Normal file
96
frontend/src/Forms.js
Normal file
|
@ -0,0 +1,96 @@
|
|||
import React from "react"
|
||||
import {
|
||||
Row,
|
||||
Col,
|
||||
Checkbox,
|
||||
} from "antd"
|
||||
|
||||
import * as cs from "./constants"
|
||||
|
||||
class Forms extends React.PureComponent {
|
||||
state = {
|
||||
lists: [],
|
||||
selected: []
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.props.pageTitle("Forms")
|
||||
this.props
|
||||
.modelRequest(cs.ModelLists, cs.Routes.GetLists, cs.MethodGet, {
|
||||
per_page: "all"
|
||||
})
|
||||
.then(() => {
|
||||
this.setState({ lists: this.props.data[cs.ModelLists].results })
|
||||
})
|
||||
}
|
||||
|
||||
handleSelection(sel) {
|
||||
let out = []
|
||||
sel.forEach(s => {
|
||||
const item = this.state.lists.find(l => {
|
||||
return l.uuid === s
|
||||
})
|
||||
if (item) {
|
||||
out.push(item)
|
||||
}
|
||||
})
|
||||
|
||||
console.log(out)
|
||||
this.setState({ selected: out })
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<section className="content list-form">
|
||||
<h1>Forms</h1>
|
||||
<Row>
|
||||
<Col span={8}>
|
||||
<Checkbox.Group
|
||||
className="lists"
|
||||
options={this.state.lists.map(l => {
|
||||
return { label: l.name, value: l.uuid }
|
||||
})}
|
||||
defaultValue={[]}
|
||||
onChange={(sel) => this.handleSelection(sel)}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={16}>
|
||||
<h1>Form HTML</h1>
|
||||
<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-subscription">
|
||||
<div>
|
||||
<h3>Subscribe</h3>
|
||||
<p><input type="text" name="email" value="" placeholder="E-mail" /></p>
|
||||
<p><input type="text" name="name" value="" 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,42 +1,43 @@
|
|||
import React from "react";
|
||||
import { Switch, Route } from "react-router-dom";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Layout, Menu, Icon } from "antd";
|
||||
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";
|
||||
import logo from "./static/listmonk.svg"
|
||||
|
||||
// Views.
|
||||
import Dashboard from "./Dashboard";
|
||||
import Lists from "./Lists";
|
||||
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";
|
||||
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();
|
||||
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 });
|
||||
};
|
||||
this.setState({ collapsed })
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
// For small screen devices collapse the menu by default.
|
||||
if (window.screen.width < 768) {
|
||||
this.setState({ collapsed: true });
|
||||
this.setState({ collapsed: true })
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
|
@ -65,12 +66,28 @@ class Base extends React.Component {
|
|||
<span>Dashboard</span>
|
||||
</Link>
|
||||
</Menu.Item>
|
||||
<Menu.Item key="/lists">
|
||||
<Link to="/lists">
|
||||
<Icon type="bars" />
|
||||
<span>Lists</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={
|
||||
|
@ -146,6 +163,14 @@ class Base extends React.Component {
|
|||
<Lists {...{ ...this.props, route: props }} />
|
||||
)}
|
||||
/>
|
||||
<Route
|
||||
exact
|
||||
key="/lists/forms"
|
||||
path="/lists/forms"
|
||||
render={props => (
|
||||
<Forms {...{ ...this.props, route: props }} />
|
||||
)}
|
||||
/>
|
||||
<Route
|
||||
exact
|
||||
key="/subscribers"
|
||||
|
@ -230,8 +255,8 @@ class Base extends React.Component {
|
|||
>
|
||||
listmonk
|
||||
</a>{" "}
|
||||
© 2019 {year !== 2019 ? " - " + year : ""}.
|
||||
Version { process.env.REACT_APP_VERSION } —{" "}
|
||||
© 2019 {year !== 2019 ? " - " + year : ""}. Version{" "}
|
||||
{process.env.REACT_APP_VERSION} —{" "}
|
||||
<a
|
||||
href="https://listmonk.app/docs"
|
||||
target="_blank"
|
||||
|
@ -243,8 +268,8 @@ class Base extends React.Component {
|
|||
</Footer>
|
||||
</Layout>
|
||||
</Layout>
|
||||
);
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default Base;
|
||||
export default Base
|
||||
|
|
|
@ -93,7 +93,6 @@ class CreateFormDef extends React.PureComponent {
|
|||
}
|
||||
|
||||
modalTitle(formType, record) {
|
||||
console.log(formType)
|
||||
if (formType === cs.FormCreate) {
|
||||
return "Create a list"
|
||||
}
|
||||
|
|
|
@ -48,6 +48,13 @@ hr {
|
|||
padding: 30px !important;
|
||||
}
|
||||
|
||||
ul.no {
|
||||
list-style-type: none;
|
||||
}
|
||||
ul.no li {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
/* Layout */
|
||||
body {
|
||||
margin: 0;
|
||||
|
@ -94,6 +101,16 @@ section.content {
|
|||
}
|
||||
|
||||
/* 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 */
|
||||
|
|
|
@ -95,6 +95,7 @@ func registerHandlers(e *echo.Echo) {
|
|||
e.DELETE("/api/templates/:id", handleDeleteTemplate)
|
||||
|
||||
// Subscriber facing views.
|
||||
e.POST("/subscription/form", handleSubscriptionForm)
|
||||
e.GET("/subscription/:campUUID/:subUUID", validateUUID(subscriberExists(handleSubscriptionPage),
|
||||
"campUUID", "subUUID"))
|
||||
e.POST("/subscription/:campUUID/:subUUID", validateUUID(subscriberExists(handleSubscriptionPage),
|
||||
|
|
48
public.go
48
public.go
|
@ -8,9 +8,11 @@ import (
|
|||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/knadh/listmonk/messenger"
|
||||
"github.com/knadh/listmonk/models"
|
||||
"github.com/knadh/listmonk/subimporter"
|
||||
"github.com/labstack/echo"
|
||||
"github.com/lib/pq"
|
||||
)
|
||||
|
@ -58,6 +60,11 @@ type msgTpl struct {
|
|||
Message string
|
||||
}
|
||||
|
||||
type subForm struct {
|
||||
subimporter.SubReq
|
||||
SubListUUIDs []string `form:"l"`
|
||||
}
|
||||
|
||||
var (
|
||||
pixelPNG = drawTransparentImage(3, 14)
|
||||
)
|
||||
|
@ -169,6 +176,47 @@ func handleOptinPage(c echo.Context) error {
|
|||
return c.Render(http.StatusOK, "optin", out)
|
||||
}
|
||||
|
||||
// handleOptinPage handles a double opt-in confirmation from subscribers.
|
||||
func handleSubscriptionForm(c echo.Context) error {
|
||||
var (
|
||||
app = c.Get("app").(*App)
|
||||
req subForm
|
||||
)
|
||||
|
||||
// Get and validate fields.
|
||||
if err := c.Bind(&req); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(req.SubListUUIDs) == 0 {
|
||||
return c.Render(http.StatusInternalServerError, "message",
|
||||
makeMsgTpl("Error", "",
|
||||
`No lists to subscribe to.`))
|
||||
}
|
||||
|
||||
// If there's no name, use the name bit from the e-mail.
|
||||
req.Email = strings.ToLower(req.Email)
|
||||
if req.Name == "" {
|
||||
req.Name = strings.Split(req.Email, "@")[0]
|
||||
}
|
||||
|
||||
// Validate fields.
|
||||
if err := subimporter.ValidateFields(req.SubReq); err != nil {
|
||||
return c.Render(http.StatusInternalServerError, "message",
|
||||
makeMsgTpl("Error", "", err.Error()))
|
||||
}
|
||||
|
||||
// Insert the subscriber into the DB.
|
||||
req.Status = models.SubscriberStatusEnabled
|
||||
req.ListUUIDs = pq.StringArray(req.SubListUUIDs)
|
||||
if _, err := insertSubscriber(req.SubReq, app); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return c.Render(http.StatusInternalServerError, "message",
|
||||
makeMsgTpl("Done", "", `Subscribed successfully.`))
|
||||
}
|
||||
|
||||
// handleLinkRedirect handles link UUID to real link redirection.
|
||||
func handleLinkRedirect(c echo.Context) error {
|
||||
var (
|
||||
|
|
|
@ -54,11 +54,16 @@ WITH sub AS (
|
|||
VALUES($1, $2, $3, $4, $5)
|
||||
returning id
|
||||
),
|
||||
listIDs AS (
|
||||
SELECT id FROM lists WHERE
|
||||
(CASE WHEN ARRAY_LENGTH($6::INT[], 1) > 0 THEN id=ANY($6)
|
||||
ELSE uuid=ANY($7::UUID[]) END)
|
||||
),
|
||||
subs AS (
|
||||
INSERT INTO subscriber_lists (subscriber_id, list_id, status)
|
||||
VALUES(
|
||||
(SELECT id FROM sub),
|
||||
UNNEST($6::INT[]),
|
||||
UNNEST(ARRAY(SELECT id FROM listIDs)),
|
||||
(CASE WHEN $4='blacklisted' THEN 'unsubscribed'::subscription_status ELSE 'unconfirmed' END)
|
||||
)
|
||||
ON CONFLICT (subscriber_id, list_id) DO UPDATE
|
||||
|
@ -302,7 +307,7 @@ SELECT COUNT(*) OVER () AS total, lists.*, COUNT(subscriber_lists.subscriber_id)
|
|||
|
||||
-- name: get-lists-by-optin
|
||||
-- Can have a list of IDs or a list of UUIDs.
|
||||
SELECT * FROM lists WHERE optin=$1::list_optin AND
|
||||
SELECT * FROM lists WHERE (CASE WHEN $1 != '' THEN optin=$1::list_optin ELSE TRUE END) AND
|
||||
(CASE WHEN $2::INT[] IS NOT NULL THEN id = ANY($2::INT[])
|
||||
WHEN $3::UUID[] IS NOT NULL THEN uuid = ANY($3::UUID[])
|
||||
END) ORDER BY name;
|
||||
|
|
|
@ -80,7 +80,8 @@ type Status struct {
|
|||
// SubReq is a wrapper over the Subscriber model.
|
||||
type SubReq struct {
|
||||
models.Subscriber
|
||||
Lists pq.Int64Array `json:"lists"`
|
||||
Lists pq.Int64Array `json:"lists"`
|
||||
ListUUIDs pq.StringArray `json:"list_uuids"`
|
||||
}
|
||||
|
||||
type importStatusTpl struct {
|
||||
|
@ -562,8 +563,11 @@ func (s *Session) mapCSVHeaders(csvHdrs []string, knownHdrs map[string]bool) map
|
|||
|
||||
// ValidateFields validates incoming subscriber field values.
|
||||
func ValidateFields(s SubReq) error {
|
||||
if len(s.Email) > 1000 {
|
||||
return errors.New(`e-mail too long`)
|
||||
}
|
||||
if !govalidator.IsEmail(s.Email) {
|
||||
return errors.New(`invalid email "` + s.Email + `"`)
|
||||
return errors.New(`invalid e-mail "` + s.Email + `"`)
|
||||
}
|
||||
if !govalidator.IsByteLength(s.Name, 1, stdInputMaxLen) {
|
||||
return errors.New(`invalid or empty name "` + s.Name + `"`)
|
||||
|
|
|
@ -161,28 +161,16 @@ func handleCreateSubscriber(c echo.Context) error {
|
|||
// Get and validate fields.
|
||||
if err := c.Bind(&req); err != nil {
|
||||
return err
|
||||
} else if err := subimporter.ValidateFields(req); err != nil {
|
||||
}
|
||||
req.Email = strings.ToLower(strings.TrimSpace(req.Email))
|
||||
if err := subimporter.ValidateFields(req); err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
|
||||
}
|
||||
|
||||
// Insert and read ID.
|
||||
var (
|
||||
email = strings.ToLower(strings.TrimSpace(req.Email))
|
||||
)
|
||||
req.UUID = uuid.NewV4().String()
|
||||
err := app.Queries.InsertSubscriber.Get(&req.ID,
|
||||
req.UUID,
|
||||
email,
|
||||
strings.TrimSpace(req.Name),
|
||||
req.Status,
|
||||
req.Attribs,
|
||||
req.Lists)
|
||||
// Insert the subscriber into the DB.
|
||||
subID, err := insertSubscriber(req, app)
|
||||
if err != nil {
|
||||
if pqErr, ok := err.(*pq.Error); ok && pqErr.Constraint == "subscribers_email_key" {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "The e-mail already exists.")
|
||||
}
|
||||
return echo.NewHTTPError(http.StatusInternalServerError,
|
||||
fmt.Sprintf("Error creating subscriber: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
// If the lists are double-optins, send confirmation e-mails.
|
||||
|
@ -191,7 +179,7 @@ func handleCreateSubscriber(c echo.Context) error {
|
|||
|
||||
// Hand over to the GET handler to return the last insertion.
|
||||
c.SetParamNames("id")
|
||||
c.SetParamValues(fmt.Sprintf("%d", req.ID))
|
||||
c.SetParamValues(fmt.Sprintf("%d", subID))
|
||||
return c.JSON(http.StatusOK, handleGetSubscriber(c))
|
||||
}
|
||||
|
||||
|
@ -506,6 +494,31 @@ func handleExportSubscriberData(c echo.Context) error {
|
|||
return c.Blob(http.StatusOK, "application/json", b)
|
||||
}
|
||||
|
||||
// insertSubscriber inserts a subscriber and returns the ID.
|
||||
func insertSubscriber(req subimporter.SubReq, app *App) (int, error) {
|
||||
req.UUID = uuid.NewV4().String()
|
||||
err := app.Queries.InsertSubscriber.Get(&req.ID,
|
||||
req.UUID,
|
||||
req.Email,
|
||||
strings.TrimSpace(req.Name),
|
||||
req.Status,
|
||||
req.Attribs,
|
||||
req.Lists,
|
||||
req.ListUUIDs)
|
||||
if err != nil {
|
||||
if pqErr, ok := err.(*pq.Error); ok && pqErr.Constraint == "subscribers_email_key" {
|
||||
return 0, echo.NewHTTPError(http.StatusBadRequest, "The e-mail already exists.")
|
||||
}
|
||||
return 0, echo.NewHTTPError(http.StatusInternalServerError,
|
||||
fmt.Sprintf("Error creating subscriber: %v", err))
|
||||
}
|
||||
|
||||
// If the lists are double-optins, send confirmation e-mails.
|
||||
// Todo: This arbitrary goroutine should be moved to a centralised pool.
|
||||
go sendOptinConfirmation(req.Subscriber, []int64(req.Lists), app)
|
||||
return req.ID, nil
|
||||
}
|
||||
|
||||
// exportSubscriberData collates the data of a subscriber including profile,
|
||||
// subscriptions, campaign_views, link_clicks (if they're enabled in the config)
|
||||
// and returns a formatted, indented JSON payload. Either takes a numeric id
|
||||
|
|
Loading…
Reference in a new issue