123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879 |
- 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
|