mirror of
https://github.com/knadh/listmonk.git
synced 2025-12-05 16:00:03 +01:00
feat: Integrate email-builder on campaign/template editor UI and backend.
This commit is contained in:
@@ -193,7 +193,7 @@ func installTemplates(q *models.Queries) (int, int) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var campTplID int
|
var campTplID int
|
||||||
if err := q.CreateTemplate.Get(&campTplID, "Default campaign template", models.TemplateTypeCampaign, "", campTpl.ReadBytes()); err != nil {
|
if err := q.CreateTemplate.Get(&campTplID, "Default campaign template", models.TemplateTypeCampaign, "", campTpl.ReadBytes(), nil); err != nil {
|
||||||
lo.Fatalf("error creating default campaign template: %v", err)
|
lo.Fatalf("error creating default campaign template: %v", err)
|
||||||
}
|
}
|
||||||
if _, err := q.SetDefaultTemplate.Exec(campTplID); err != nil {
|
if _, err := q.SetDefaultTemplate.Exec(campTplID); err != nil {
|
||||||
@@ -207,7 +207,7 @@ func installTemplates(q *models.Queries) (int, int) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var archiveTplID int
|
var archiveTplID int
|
||||||
if err := q.CreateTemplate.Get(&archiveTplID, "Default archive template", models.TemplateTypeCampaign, "", archiveTpl.ReadBytes()); err != nil {
|
if err := q.CreateTemplate.Get(&archiveTplID, "Default archive template", models.TemplateTypeCampaign, "", archiveTpl.ReadBytes(), nil); err != nil {
|
||||||
lo.Fatalf("error creating default campaign template: %v", err)
|
lo.Fatalf("error creating default campaign template: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -217,7 +217,7 @@ func installTemplates(q *models.Queries) (int, int) {
|
|||||||
lo.Fatalf("error reading default e-mail template: %v", err)
|
lo.Fatalf("error reading default e-mail template: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := q.CreateTemplate.Exec("Sample transactional template", models.TemplateTypeTx, "Welcome {{ .Subscriber.Name }}", txTpl.ReadBytes()); err != nil {
|
if _, err := q.CreateTemplate.Exec("Sample transactional template", models.TemplateTypeTx, "Welcome {{ .Subscriber.Name }}", txTpl.ReadBytes(), nil); err != nil {
|
||||||
lo.Fatalf("error creating sample transactional template: %v", err)
|
lo.Fatalf("error creating sample transactional template: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -252,6 +252,7 @@ func installCampaign(campTplID, archiveTplID int, q *models.Queries) {
|
|||||||
archiveTplID,
|
archiveTplID,
|
||||||
`{"name": "Subscriber"}`,
|
`{"name": "Subscriber"}`,
|
||||||
nil,
|
nil,
|
||||||
|
nil,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
lo.Fatalf("error creating sample campaign: %v", err)
|
lo.Fatalf("error creating sample campaign: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
1
frontend/.eslintrc.js
vendored
1
frontend/.eslintrc.js
vendored
@@ -28,4 +28,5 @@ module.exports = {
|
|||||||
comments: 200,
|
comments: 200,
|
||||||
}],
|
}],
|
||||||
},
|
},
|
||||||
|
ignorePatterns: ['src/email-builder.js'],
|
||||||
};
|
};
|
||||||
|
|||||||
2
frontend/package.json
vendored
2
frontend/package.json
vendored
@@ -17,6 +17,7 @@
|
|||||||
"chart.js": "^4.4.1",
|
"chart.js": "^4.4.1",
|
||||||
"codeflask": "^1.4.1",
|
"codeflask": "^1.4.1",
|
||||||
"dayjs": "^1.11.10",
|
"dayjs": "^1.11.10",
|
||||||
|
"indent.js": "^0.3.5",
|
||||||
"js-beautify": "^1.15.1",
|
"js-beautify": "^1.15.1",
|
||||||
"prismjs": "^1.30.0",
|
"prismjs": "^1.30.0",
|
||||||
"qs": "^6.10.1",
|
"qs": "^6.10.1",
|
||||||
@@ -30,6 +31,7 @@
|
|||||||
"vuex": "^3.6.2"
|
"vuex": "^3.6.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/js-beautify": "^1.14.3",
|
||||||
"@vitejs/plugin-vue2": "^2.3.1",
|
"@vitejs/plugin-vue2": "^2.3.1",
|
||||||
"@vue/eslint-config-airbnb": "^7.0.1",
|
"@vue/eslint-config-airbnb": "^7.0.1",
|
||||||
"cypress": "13.15.0",
|
"cypress": "13.15.0",
|
||||||
|
|||||||
@@ -53,8 +53,7 @@
|
|||||||
|
|
||||||
<!-- body //-->
|
<!-- body //-->
|
||||||
<div class="main">
|
<div class="main">
|
||||||
<div class="global-notices"
|
<div class="global-notices" v-if="isGlobalNotices">
|
||||||
v-if="serverConfig.needs_restart || serverConfig.update || serverConfig.has_legacy_user">
|
|
||||||
<div v-if="serverConfig.needs_restart" class="notification is-danger">
|
<div v-if="serverConfig.needs_restart" class="notification is-danger">
|
||||||
{{ $t('settings.needsRestart') }}
|
{{ $t('settings.needsRestart') }}
|
||||||
—
|
—
|
||||||
@@ -188,6 +187,14 @@ export default Vue.extend({
|
|||||||
computed: {
|
computed: {
|
||||||
...mapState(['serverConfig', 'profile']),
|
...mapState(['serverConfig', 'profile']),
|
||||||
|
|
||||||
|
isGlobalNotices() {
|
||||||
|
return (this.serverConfig.needs_restart
|
||||||
|
|| this.serverConfig.has_legacy_user
|
||||||
|
|| (this.serverConfig.update
|
||||||
|
&& this.serverConfig.update.messages
|
||||||
|
&& this.serverConfig.update.messages.length > 0));
|
||||||
|
},
|
||||||
|
|
||||||
version() {
|
version() {
|
||||||
return import.meta.env.VUE_APP_VERSION;
|
return import.meta.env.VUE_APP_VERSION;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -305,23 +305,21 @@ body.is-noscroll {
|
|||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.plain-editor textarea {
|
|
||||||
height: 65vh;
|
|
||||||
}
|
|
||||||
|
|
||||||
.alt-body textarea {
|
.alt-body textarea {
|
||||||
height: 30vh;
|
height: 30vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.editor {
|
.editor {
|
||||||
margin-bottom: 30px;
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.richtext-editor {
|
||||||
.tox-tinymce {
|
.tox-tinymce {
|
||||||
|
height: 100%;
|
||||||
box-shadow: 2px 2px 0 #f3f3f3;
|
box-shadow: 2px 2px 0 #f3f3f3;
|
||||||
border: 1px solid #e6e6e6;
|
border: 1px solid #e6e6e6;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
|
|
||||||
.tox-toolbar__primary {
|
.tox-toolbar__primary {
|
||||||
border-color: #e6e6e6 !important;
|
border-color: #e6e6e6 !important;
|
||||||
}
|
}
|
||||||
@@ -336,10 +334,8 @@ body.is-noscroll {
|
|||||||
.tox-tinymce--toolbar-sticky-on .tox-editor-header {
|
.tox-tinymce--toolbar-sticky-on .tox-editor-header {
|
||||||
padding-top: 48px !important;
|
padding-top: 48px !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.tox.tox-silver-sink {
|
.tox.tox-silver-sink {
|
||||||
z-index: 850;
|
z-index: 850;
|
||||||
|
|
||||||
@@ -647,7 +643,7 @@ body.is-noscroll {
|
|||||||
border-radius: 30px !important;
|
border-radius: 30px !important;
|
||||||
border: 0;
|
border: 0;
|
||||||
padding: 0 20px !important;
|
padding: 0 20px !important;
|
||||||
|
|
||||||
&.is-small {
|
&.is-small {
|
||||||
font-size: 0.65rem;
|
font-size: 0.65rem;
|
||||||
background: $white-ter;
|
background: $white-ter;
|
||||||
@@ -655,7 +651,7 @@ body.is-noscroll {
|
|||||||
padding: 3px 5px;
|
padding: 3px 5px;
|
||||||
min-width: auto !important;
|
min-width: auto !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:not(body) {
|
&:not(body) {
|
||||||
background-color: #eee;
|
background-color: #eee;
|
||||||
font-size: 0.85em;
|
font-size: 0.85em;
|
||||||
@@ -676,7 +672,7 @@ body.is-noscroll {
|
|||||||
color: $green;
|
color: $green;
|
||||||
background: #dcfce7;
|
background: #dcfce7;
|
||||||
}
|
}
|
||||||
&.blocklisted, &.cancelled, &.status-unsubscribed {
|
&.blocklisted, &.cancelled, &.status-unsubscribed, &.campaign_visual {
|
||||||
$color: $red;
|
$color: $red;
|
||||||
color: $color;
|
color: $color;
|
||||||
background: #fff1f0;
|
background: #fff1f0;
|
||||||
|
|||||||
@@ -9,14 +9,14 @@
|
|||||||
</div>
|
</div>
|
||||||
<section expanded class="modal-card-body preview">
|
<section expanded class="modal-card-body preview">
|
||||||
<b-loading :active="isLoading" :is-full-page="false" />
|
<b-loading :active="isLoading" :is-full-page="false" />
|
||||||
<form v-if="body" method="post" :action="previewURL" target="iframe" ref="form">
|
<form v-if="isPost" method="post" :action="previewURL" target="iframe" ref="form">
|
||||||
<input type="hidden" name="template_id" :value="templateId" />
|
<input type="hidden" name="template_id" :value="templateId" />
|
||||||
<input type="hidden" name="content_type" :value="contentType" />
|
<input type="hidden" name="content_type" :value="contentType" />
|
||||||
<input type="hidden" name="template_type" :value="templateType" />
|
<input type="hidden" name="template_type" :value="templateType" />
|
||||||
<input type="hidden" name="body" :value="body" />
|
<input type="hidden" name="body" :value="body" />
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<iframe id="iframe" name="iframe" ref="iframe" :title="title" :src="body ? 'about:blank' : previewURL"
|
<iframe id="iframe" name="iframe" ref="iframe" :title="title" :src="isPost ? 'about:blank' : previewURL"
|
||||||
@load="onLoaded" />
|
@load="onLoaded" />
|
||||||
</section>
|
</section>
|
||||||
<footer class="modal-card-foot has-text-right">
|
<footer class="modal-card-foot has-text-right">
|
||||||
@@ -36,6 +36,8 @@ export default {
|
|||||||
name: 'CampaignPreview',
|
name: 'CampaignPreview',
|
||||||
|
|
||||||
props: {
|
props: {
|
||||||
|
isPost: { type: Boolean, default: false },
|
||||||
|
|
||||||
// Template or campaign ID.
|
// Template or campaign ID.
|
||||||
id: { type: Number, default: 0 },
|
id: { type: Number, default: 0 },
|
||||||
title: { type: String, default: '' },
|
title: { type: String, default: '' },
|
||||||
@@ -48,7 +50,7 @@ export default {
|
|||||||
|
|
||||||
body: { type: String, default: '' },
|
body: { type: String, default: '' },
|
||||||
contentType: { type: String, default: '' },
|
contentType: { type: String, default: '' },
|
||||||
templateId: { type: Number, default: 0 },
|
templateId: { type: [Number, null], default: null },
|
||||||
},
|
},
|
||||||
|
|
||||||
data() {
|
data() {
|
||||||
@@ -93,7 +95,7 @@ export default {
|
|||||||
|
|
||||||
mounted() {
|
mounted() {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (this.$refs.form) {
|
if (this.isPost) {
|
||||||
this.$refs.form.submit();
|
this.$refs.form.submit();
|
||||||
}
|
}
|
||||||
}, 100);
|
}, 100);
|
||||||
|
|||||||
@@ -2,32 +2,45 @@
|
|||||||
<!-- Two-way Data-Binding -->
|
<!-- Two-way Data-Binding -->
|
||||||
<section class="editor">
|
<section class="editor">
|
||||||
<div class="columns">
|
<div class="columns">
|
||||||
<div class="column is-6">
|
<div class="column is-three-quarters is-inline-flex">
|
||||||
<b-field label="Format">
|
<b-field :label="$t('campaigns.format')" label-position="on-border" class="mr-4 mb-0">
|
||||||
<div>
|
<b-select v-model="contentType">
|
||||||
<b-radio v-model="form.radioFormat" @input="onFormatChange" :disabled="disabled" name="format"
|
<option :disabled="disabled" name="format" value="richtext" data-cy="check-richtext">
|
||||||
native-value="richtext" data-cy="check-richtext">
|
|
||||||
{{ $t('campaigns.richText') }}
|
{{ $t('campaigns.richText') }}
|
||||||
</b-radio>
|
</option>
|
||||||
|
|
||||||
<b-radio v-model="form.radioFormat" @input="onFormatChange" :disabled="disabled" name="format"
|
<option :disabled="disabled" name="format" value="html" data-cy="check-html">
|
||||||
native-value="html" data-cy="check-html">
|
|
||||||
{{ $t('campaigns.rawHTML') }}
|
{{ $t('campaigns.rawHTML') }}
|
||||||
</b-radio>
|
</option>
|
||||||
|
|
||||||
<b-radio v-model="form.radioFormat" @input="onFormatChange" :disabled="disabled" name="format"
|
<option :disabled="disabled" name="format" value="markdown" data-cy="check-markdown">
|
||||||
native-value="markdown" data-cy="check-markdown">
|
|
||||||
{{ $t('campaigns.markdown') }}
|
{{ $t('campaigns.markdown') }}
|
||||||
</b-radio>
|
</option>
|
||||||
|
|
||||||
<b-radio v-model="form.radioFormat" @input="onFormatChange" :disabled="disabled" name="format"
|
<option :disabled="disabled" name="format" value="plain" data-cy="check-plain">
|
||||||
native-value="plain" data-cy="check-plain">
|
|
||||||
{{ $t('campaigns.plainText') }}
|
{{ $t('campaigns.plainText') }}
|
||||||
</b-radio>
|
</option>
|
||||||
</div>
|
|
||||||
|
<option :disabled="disabled" name="format" value="visual" data-cy="check-visual">
|
||||||
|
{{ $t('campaigns.visual') }}
|
||||||
|
</option>
|
||||||
|
</b-select>
|
||||||
|
</b-field>
|
||||||
|
|
||||||
|
<b-field :label="$t('globals.terms.baseTemplate')" label-position="on-border">
|
||||||
|
<b-select :placeholder="$t('globals.terms.none')" v-model="templateId" name="template" :disabled="disabled">
|
||||||
|
<option :value="null" key="none" v-if="templateId !== null">
|
||||||
|
{{ $t('globals.terms.none') }}
|
||||||
|
</option>
|
||||||
|
<template v-for="t in applicableTemplates">
|
||||||
|
<option v-if="t.type === 'campaign' || t.type === 'campaign_visual'" :value="t.id" :key="t.id">
|
||||||
|
{{ t.name }}
|
||||||
|
</option>
|
||||||
|
</template>
|
||||||
|
</b-select>
|
||||||
</b-field>
|
</b-field>
|
||||||
</div>
|
</div>
|
||||||
<div class="column is-6 has-text-right">
|
<div class="column is- has-text-right">
|
||||||
<b-button @click="onTogglePreview" type="is-primary" icon-left="file-find-outline" data-cy="btn-preview">
|
<b-button @click="onTogglePreview" type="is-primary" icon-left="file-find-outline" data-cy="btn-preview">
|
||||||
{{ $t('campaigns.preview') }} (F9)
|
{{ $t('campaigns.preview') }} (F9)
|
||||||
</b-button>
|
</b-button>
|
||||||
@@ -35,70 +48,24 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- wsywig //-->
|
<!-- wsywig //-->
|
||||||
<template v-if="isRichtextReady && form.format === 'richtext'">
|
<richtext-editor v-if="computedValue.contentType === 'richtext'" v-model="computedValue.body" />
|
||||||
<tiny-mce v-model="form.body" :disabled="disabled" :init="richtextConf" />
|
|
||||||
|
|
||||||
<b-modal scroll="keep" :width="1200" :aria-modal="true" :active.sync="isRichtextSourceVisible">
|
<!-- visual editor //-->
|
||||||
<div>
|
<visual-editor v-if="computedValue.contentType === 'visual'" :source="computedValue.bodySource" @change="onChangeVisualEditor" />
|
||||||
<section expanded class="modal-card-body preview">
|
|
||||||
<html-editor v-model="richTextSourceBody" />
|
|
||||||
</section>
|
|
||||||
<footer class="modal-card-foot has-text-right">
|
|
||||||
<b-button @click="onFormatRichtextHTML">
|
|
||||||
{{ $t('campaigns.formatHTML') }}
|
|
||||||
</b-button>
|
|
||||||
<b-button @click="() => { this.isRichtextSourceVisible = false; }">
|
|
||||||
{{ $t('globals.buttons.close') }}
|
|
||||||
</b-button>
|
|
||||||
<b-button @click="onSaveRichTextSource" class="is-primary">
|
|
||||||
{{ $t('globals.buttons.save') }}
|
|
||||||
</b-button>
|
|
||||||
</footer>
|
|
||||||
</div>
|
|
||||||
</b-modal>
|
|
||||||
|
|
||||||
<b-modal scroll="keep" :width="750" :aria-modal="true" :active.sync="isInsertHTMLVisible">
|
|
||||||
<div>
|
|
||||||
<section expanded class="modal-card-body preview">
|
|
||||||
<html-editor v-model="insertHTMLSnippet" />
|
|
||||||
</section>
|
|
||||||
<footer class="modal-card-foot has-text-right">
|
|
||||||
<b-button @click="onFormatRichtextHTML">
|
|
||||||
{{ $t('campaigns.formatHTML') }}
|
|
||||||
</b-button>
|
|
||||||
<b-button @click="() => { this.isInsertHTMLVisible = false; }">
|
|
||||||
{{ $t('globals.buttons.close') }}
|
|
||||||
</b-button>
|
|
||||||
<b-button @click="onInsertHTML" class="is-primary">
|
|
||||||
{{ $t('globals.buttons.insert') }}
|
|
||||||
</b-button>
|
|
||||||
</footer>
|
|
||||||
</div>
|
|
||||||
</b-modal>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- raw html editor //-->
|
<!-- raw html editor //-->
|
||||||
<html-editor v-if="form.format === 'html'" v-model="form.body" />
|
<html-editor v-if="computedValue.contentType === 'html'" v-model="computedValue.body" />
|
||||||
|
|
||||||
<!-- markdown editor //-->
|
<!-- markdown editor //-->
|
||||||
<markdown-editor v-if="form.format === 'markdown'" v-model="form.body" />
|
<markdown-editor v-if="computedValue.contentType === 'markdown'" v-model="computedValue.body" />
|
||||||
|
|
||||||
<!-- plain text //-->
|
<!-- plain text //-->
|
||||||
<b-input v-if="form.format === 'plain'" v-model="form.body" @input="onEditorChange" type="textarea" name="content"
|
<b-input v-if="computedValue.contentType === 'plain'" v-model="computedValue.body"
|
||||||
ref="plainEditor" class="plain-editor" />
|
type="textarea" name="content" ref="plainEditor" class="plain-editor" />
|
||||||
|
|
||||||
<!-- campaign preview //-->
|
<!-- campaign preview //-->
|
||||||
<campaign-preview v-if="isPreviewing" @close="onTogglePreview" type="campaign" :id="id" :title="title"
|
<campaign-preview v-if="isPreviewing" is-post @close="onTogglePreview" type="campaign" :id="id" :title="title"
|
||||||
:content-type="form.format" :template-id="templateId" :body="form.body" />
|
:content-type="computedValue.contentType" :template-id="templateId" :body="computedValue.body" />
|
||||||
|
|
||||||
<!-- 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 is-modal @selected="onMediaSelect" />
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</b-modal>
|
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -107,186 +74,55 @@ import { html as beautifyHTML } from 'js-beautify';
|
|||||||
import TurndownService from 'turndown';
|
import TurndownService from 'turndown';
|
||||||
import { mapState } from 'vuex';
|
import { mapState } from 'vuex';
|
||||||
|
|
||||||
import TinyMce from '@tinymce/tinymce-vue';
|
|
||||||
import 'tinymce';
|
|
||||||
import 'tinymce/icons/default';
|
|
||||||
import 'tinymce/plugins/anchor';
|
|
||||||
import 'tinymce/plugins/autolink';
|
|
||||||
import 'tinymce/plugins/autoresize';
|
|
||||||
import 'tinymce/plugins/charmap';
|
|
||||||
import 'tinymce/plugins/colorpicker';
|
|
||||||
import 'tinymce/plugins/contextmenu';
|
|
||||||
import 'tinymce/plugins/emoticons';
|
|
||||||
import 'tinymce/plugins/emoticons/js/emojis';
|
|
||||||
import 'tinymce/plugins/fullscreen';
|
|
||||||
import 'tinymce/plugins/help';
|
|
||||||
import 'tinymce/plugins/hr';
|
|
||||||
import 'tinymce/plugins/image';
|
|
||||||
import 'tinymce/plugins/imagetools';
|
|
||||||
import 'tinymce/plugins/link';
|
|
||||||
import 'tinymce/plugins/lists';
|
|
||||||
import 'tinymce/plugins/paste';
|
|
||||||
import 'tinymce/plugins/searchreplace';
|
|
||||||
import 'tinymce/plugins/table';
|
|
||||||
import 'tinymce/plugins/textcolor';
|
|
||||||
import 'tinymce/plugins/visualblocks';
|
|
||||||
import 'tinymce/plugins/visualchars';
|
|
||||||
import 'tinymce/plugins/wordcount';
|
|
||||||
import 'tinymce/skins/ui/oxide/skin.css';
|
|
||||||
import 'tinymce/themes/silver';
|
|
||||||
|
|
||||||
import { colors, uris } from '../constants';
|
|
||||||
import Media from '../views/Media.vue';
|
|
||||||
import CampaignPreview from './CampaignPreview.vue';
|
import CampaignPreview from './CampaignPreview.vue';
|
||||||
import HTMLEditor from './HTMLEditor.vue';
|
import HTMLEditor from './HTMLEditor.vue';
|
||||||
import MarkdownEditor from './MarkdownEditor.vue';
|
import MarkdownEditor from './MarkdownEditor.vue';
|
||||||
|
import VisualEditor from './VisualEditor.vue';
|
||||||
|
import RichtextEditor from './RichtextEditor.vue';
|
||||||
|
|
||||||
const turndown = new TurndownService();
|
const turndown = new TurndownService();
|
||||||
|
|
||||||
// Map of listmonk language codes to corresponding TinyMCE language files.
|
|
||||||
const LANGS = {
|
|
||||||
'cs-cz': 'cs',
|
|
||||||
de: 'de',
|
|
||||||
es: 'es_419',
|
|
||||||
fr: 'fr_FR',
|
|
||||||
it: 'it_IT',
|
|
||||||
pl: 'pl',
|
|
||||||
pt: 'pt_PT',
|
|
||||||
'pt-BR': 'pt_BR',
|
|
||||||
ro: 'ro',
|
|
||||||
tr: 'tr',
|
|
||||||
};
|
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
components: {
|
components: {
|
||||||
Media,
|
|
||||||
CampaignPreview,
|
CampaignPreview,
|
||||||
'html-editor': HTMLEditor,
|
'html-editor': HTMLEditor,
|
||||||
'markdown-editor': MarkdownEditor,
|
'markdown-editor': MarkdownEditor,
|
||||||
TinyMce,
|
'visual-editor': VisualEditor,
|
||||||
|
'richtext-editor': RichtextEditor,
|
||||||
},
|
},
|
||||||
|
|
||||||
props: {
|
props: {
|
||||||
id: { type: Number, default: 0 },
|
id: { type: Number, default: 0 },
|
||||||
title: { type: String, default: '' },
|
title: { type: String, default: '' },
|
||||||
body: { type: String, default: '' },
|
|
||||||
contentType: { type: String, default: '' },
|
|
||||||
templateId: { type: Number, default: 0 },
|
|
||||||
disabled: { type: Boolean, default: false },
|
disabled: { type: Boolean, default: false },
|
||||||
|
templates: { type: Array, default: null },
|
||||||
|
value: {
|
||||||
|
type: Object,
|
||||||
|
default: () => ({
|
||||||
|
body: '',
|
||||||
|
bodySource: null,
|
||||||
|
contentType: '',
|
||||||
|
templateId: null,
|
||||||
|
}),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
isPreviewing: false,
|
isPreviewing: false,
|
||||||
isMediaVisible: false,
|
contentType: this.$props.value.contentType,
|
||||||
isEditorFullscreen: false,
|
templateId: '',
|
||||||
isReady: false,
|
|
||||||
isRichtextReady: false,
|
|
||||||
isRichtextSourceVisible: false,
|
|
||||||
isInsertHTMLVisible: false,
|
|
||||||
insertHTMLSnippet: '',
|
|
||||||
isTrackLink: false,
|
|
||||||
richtextConf: {},
|
|
||||||
richTextSourceBody: '',
|
|
||||||
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,
|
|
||||||
},
|
|
||||||
|
|
||||||
// Last position of the cursor in the editor before the media popup
|
|
||||||
// was opened. This is used to insert media on selection from the poup
|
|
||||||
// where the caret may be lost.
|
|
||||||
lastSel: null,
|
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
methods: {
|
methods: {
|
||||||
initRichtextEditor() {
|
onContentTypeChange(to, from) {
|
||||||
const { lang } = this.serverConfig;
|
if (this.computedValue.body.trim() === '') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
this.richtextConf = {
|
// To avoid prompt loop.
|
||||||
init_instance_callback: () => { this.isReady = true; },
|
if (to === this.computedValue.contentType) {
|
||||||
urlconverter_callback: this.onEditorURLConvert,
|
|
||||||
|
|
||||||
setup: (editor) => {
|
|
||||||
editor.on('init', () => {
|
|
||||||
editor.focus();
|
|
||||||
this.onEditorDialogOpen(editor);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Custom HTML editor.
|
|
||||||
editor.ui.registry.addButton('html', {
|
|
||||||
icon: 'sourcecode',
|
|
||||||
tooltip: 'Source code',
|
|
||||||
onAction: this.onRichtextViewSource,
|
|
||||||
});
|
|
||||||
|
|
||||||
editor.ui.registry.addButton('insert-html', {
|
|
||||||
icon: 'code-sample',
|
|
||||||
tooltip: 'Insert HTML',
|
|
||||||
onAction: this.onOpenInsertHTML,
|
|
||||||
});
|
|
||||||
|
|
||||||
editor.on('CloseWindow', () => {
|
|
||||||
editor.selection.getNode().scrollIntoView(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
editor.on('keydown', (e) => {
|
|
||||||
if (e.key === 'F9') {
|
|
||||||
this.onTogglePreview();
|
|
||||||
e.preventDefault();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
browser_spellcheck: true,
|
|
||||||
min_height: 500,
|
|
||||||
toolbar_sticky: true,
|
|
||||||
entity_encoding: 'raw',
|
|
||||||
convert_urls: true,
|
|
||||||
plugins: [
|
|
||||||
'anchor', 'autoresize', 'autolink', 'charmap', 'emoticons', 'fullscreen',
|
|
||||||
'help', 'hr', 'image', 'imagetools', 'link', 'lists', 'paste', 'searchreplace',
|
|
||||||
'table', 'visualblocks', 'visualchars', 'wordcount',
|
|
||||||
],
|
|
||||||
toolbar: `undo redo | formatselect styleselect fontsizeselect |
|
|
||||||
bold italic underline strikethrough forecolor backcolor subscript superscript |
|
|
||||||
alignleft aligncenter alignright alignjustify |
|
|
||||||
bullist numlist table image insert-html | outdent indent | link hr removeformat |
|
|
||||||
html fullscreen help`,
|
|
||||||
fontsize_formats: '10px 11px 12px 14px 15px 16px 18px 24px 36px',
|
|
||||||
skin: false,
|
|
||||||
content_css: false,
|
|
||||||
content_style: `
|
|
||||||
body { font-family: 'Inter', sans-serif; font-size: 15px; }
|
|
||||||
img { max-width: 100%; }
|
|
||||||
a { color: ${colors.primary}; }
|
|
||||||
table, td { border-color: #ccc;}
|
|
||||||
`,
|
|
||||||
|
|
||||||
language: LANGS[lang] || null,
|
|
||||||
language_url: LANGS[lang] ? `${uris.static}/tinymce/lang/${LANGS[lang]}.js` : null,
|
|
||||||
|
|
||||||
file_picker_types: 'image',
|
|
||||||
file_picker_callback: (callback) => {
|
|
||||||
this.isMediaVisible = true;
|
|
||||||
this.runTinyMceImageCallback = callback;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
this.isRichtextReady = true;
|
|
||||||
},
|
|
||||||
|
|
||||||
onFormatChange(format) {
|
|
||||||
if (this.form.body.trim() === '') {
|
|
||||||
this.form.format = format;
|
|
||||||
this.onEditorChange();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -294,124 +130,58 @@ export default {
|
|||||||
this.$utils.confirm(
|
this.$utils.confirm(
|
||||||
this.$t('campaigns.confirmSwitchFormat'),
|
this.$t('campaigns.confirmSwitchFormat'),
|
||||||
() => {
|
() => {
|
||||||
this.form.format = format;
|
this.computedValue.contentType = this.contentType;
|
||||||
this.onEditorChange();
|
|
||||||
},
|
},
|
||||||
() => {
|
() => {
|
||||||
// On cancel, undo the radio selection.
|
this.contentType = from;
|
||||||
this.form.radioFormat = this.form.format;
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
onEditorURLConvert(url) {
|
convertContentType(to, from) {
|
||||||
let u = url;
|
let body;
|
||||||
if (this.isTrackLink) {
|
if ((from === 'richtext' || from === 'html') && to === 'plain') {
|
||||||
u = `${u}@TrackLink`;
|
// richtext, html => plain
|
||||||
|
|
||||||
|
// Preserve line breaks when converting HTML to plaintext.
|
||||||
|
const d = document.createElement('div');
|
||||||
|
d.innerHTML = this.beautifyHTML(this.computedValue.body);
|
||||||
|
body = this.trimLines(d.innerText.trim(), true);
|
||||||
|
} else if ((from === 'richtext' || from === 'html') && to === 'markdown') {
|
||||||
|
// richtext, html => markdown
|
||||||
|
body = turndown.turndown(this.computedValue.body).replace(/\n\n+/ig, '\n\n');
|
||||||
|
} else if (from === 'plain' && (to === 'richtext' || to === 'html')) {
|
||||||
|
// plain => richtext, html
|
||||||
|
body = this.computedValue.body.replace(/\n/ig, '<br>\n');
|
||||||
|
} else if (from === 'richtext' && to === 'html') {
|
||||||
|
// richtext => html
|
||||||
|
body = this.beautifyHTML(this.computedValue.body);
|
||||||
|
} else if (from === 'markdown' && (to === 'richtext' || to === 'html')) {
|
||||||
|
// markdown => richtext, html.
|
||||||
|
this.$api.convertCampaignContent({
|
||||||
|
id: 1, body: this.computedValue.body, from, to,
|
||||||
|
}).then((data) => {
|
||||||
|
this.$nextTick(() => {
|
||||||
|
this.computedValue.body = this.beautifyHTML(data.trim());
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
this.isTrackLink = false;
|
// Update the current body.
|
||||||
return u;
|
this.$nextTick(() => {
|
||||||
},
|
this.computedValue.body = body;
|
||||||
|
});
|
||||||
|
|
||||||
onRichtextViewSource() {
|
// Reset template ID only if its converted to or from visual template.
|
||||||
this.richTextSourceBody = this.form.body;
|
if (to === 'visual' || from === 'visual') {
|
||||||
this.isRichtextSourceVisible = true;
|
this.computedValue.templateId = '';
|
||||||
},
|
|
||||||
|
|
||||||
onOpenInsertHTML() {
|
|
||||||
this.isInsertHTMLVisible = true;
|
|
||||||
},
|
|
||||||
|
|
||||||
onInsertHTML() {
|
|
||||||
this.isInsertHTMLVisible = false;
|
|
||||||
window.tinymce.editors[0].execCommand('mceInsertContent', false, this.insertHTMLSnippet);
|
|
||||||
},
|
|
||||||
|
|
||||||
onFormatRichtextHTML() {
|
|
||||||
this.richTextSourceBody = this.beautifyHTML(this.richTextSourceBody);
|
|
||||||
},
|
|
||||||
|
|
||||||
onSaveRichTextSource() {
|
|
||||||
this.form.body = this.richTextSourceBody;
|
|
||||||
window.tinymce.editors[0].setContent(this.form.body);
|
|
||||||
this.richTextSourceBody = '';
|
|
||||||
this.isRichtextSourceVisible = false;
|
|
||||||
},
|
|
||||||
|
|
||||||
onEditorDialogOpen(editor) {
|
|
||||||
const ed = editor;
|
|
||||||
const oldEd = ed.windowManager.open;
|
|
||||||
const self = this;
|
|
||||||
|
|
||||||
ed.windowManager.open = (t, r) => {
|
|
||||||
const isOK = t.initialData && 'url' in t.initialData && 'anchor' in t.initialData;
|
|
||||||
|
|
||||||
// Not the link modal.
|
|
||||||
if (!isOK) {
|
|
||||||
return oldEd.apply(this, [t, r]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// If an existing link is being edited, check for the tracking flag `@TrackLink` at the end
|
|
||||||
// of the url. Remove that from the URL and instead check the checkbox.
|
|
||||||
let checked = false;
|
|
||||||
if (!t.initialData.link !== '') {
|
|
||||||
const t2 = t;
|
|
||||||
const url = t2.initialData.url.value.replace(/@TrackLink$/, '');
|
|
||||||
|
|
||||||
if (t2.initialData.url.value !== url) {
|
|
||||||
t2.initialData.url.value = url;
|
|
||||||
checked = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Execute the modal.
|
|
||||||
const modal = oldEd.apply(this, [t, r]);
|
|
||||||
|
|
||||||
// Is it the link dialog?
|
|
||||||
if (isOK) {
|
|
||||||
// Insert tracking checkbox.
|
|
||||||
const c = document.createElement('input');
|
|
||||||
c.setAttribute('type', 'checkbox');
|
|
||||||
|
|
||||||
if (checked) {
|
|
||||||
c.setAttribute('checked', checked);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Store the checkbox's state in the Vue instance to pick up from
|
|
||||||
// the TinyMCE link conversion callback.
|
|
||||||
c.onchange = (e) => {
|
|
||||||
self.isTrackLink = e.target.checked;
|
|
||||||
};
|
|
||||||
|
|
||||||
const l = document.createElement('label');
|
|
||||||
l.appendChild(c);
|
|
||||||
l.appendChild(document.createTextNode('Track link?'));
|
|
||||||
l.classList.add('tox-label', 'tox-track-link');
|
|
||||||
|
|
||||||
document.querySelector('.tox-form__controls-h-stack .tox-control-wrap').appendChild(l);
|
|
||||||
}
|
|
||||||
return modal;
|
|
||||||
};
|
|
||||||
},
|
|
||||||
|
|
||||||
onEditorChange() {
|
|
||||||
if (!this.isReady) {
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// The parent's v-model gets { contentType, body }.
|
|
||||||
this.$emit('input', { contentType: this.form.format, body: this.form.body });
|
|
||||||
},
|
},
|
||||||
|
|
||||||
onTogglePreview() {
|
onTogglePreview() {
|
||||||
this.isPreviewing = !this.isPreviewing;
|
this.isPreviewing = !this.isPreviewing;
|
||||||
},
|
},
|
||||||
|
|
||||||
onMediaSelect(media) {
|
|
||||||
this.runTinyMceImageCallback(media.url);
|
|
||||||
},
|
|
||||||
|
|
||||||
onPreviewShortcut(e) {
|
onPreviewShortcut(e) {
|
||||||
if (e.key === 'F9') {
|
if (e.key === 'F9') {
|
||||||
this.onTogglePreview();
|
this.onTogglePreview();
|
||||||
@@ -419,10 +189,14 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
onChangeVisualEditor({ body, source }) {
|
||||||
|
this.computedValue.body = body;
|
||||||
|
this.computedValue.bodySource = source;
|
||||||
|
},
|
||||||
|
|
||||||
beautifyHTML(str) {
|
beautifyHTML(str) {
|
||||||
// Pad all tags with linebreaks.
|
// Pad all tags with linebreaks.
|
||||||
let s = this.trimLines(str.replace(/(<(?!(\/)?a|span)([^>]+)>)/ig, '\n$1\n'), true);
|
let s = this.trimLines(str.replace(/(<(?!(\/)?a|span)([^>]+)>)/ig, '\n$1\n'), true);
|
||||||
|
|
||||||
// Remove extra linebreaks.
|
// Remove extra linebreaks.
|
||||||
s = s.replace(/\n+/g, '\n');
|
s = s.replace(/\n+/g, '\n');
|
||||||
|
|
||||||
@@ -450,7 +224,9 @@ export default {
|
|||||||
},
|
},
|
||||||
|
|
||||||
mounted() {
|
mounted() {
|
||||||
this.initRichtextEditor();
|
// Set initial content type for the selector.
|
||||||
|
this.contentType = this.value.contentType;
|
||||||
|
this.templateId = this.value.templateId;
|
||||||
|
|
||||||
window.addEventListener('keydown', this.onPreviewShortcut);
|
window.addEventListener('keydown', this.onPreviewShortcut);
|
||||||
},
|
},
|
||||||
@@ -462,69 +238,61 @@ export default {
|
|||||||
computed: {
|
computed: {
|
||||||
...mapState(['serverConfig']),
|
...mapState(['serverConfig']),
|
||||||
|
|
||||||
htmlFormat() {
|
computedValue: {
|
||||||
return this.form.format;
|
get() {
|
||||||
|
return this.value;
|
||||||
|
},
|
||||||
|
set(newValue) {
|
||||||
|
this.$emit('input', newValue);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
applicableTemplates() {
|
||||||
|
if (this.computedValue.contentType === 'visual') {
|
||||||
|
return this.templates.filter((t) => t.type === 'campaign_visual');
|
||||||
|
}
|
||||||
|
return this.templates.filter((t) => t.type !== 'campaign_visual');
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
watch: {
|
watch: {
|
||||||
// Capture contentType and body passed from the parent as props.
|
contentType(to, from) {
|
||||||
contentType(f) {
|
this.onContentTypeChange(to, from, true);
|
||||||
this.form.format = f;
|
|
||||||
this.form.radioFormat = f;
|
|
||||||
|
|
||||||
if (f !== 'richtext') {
|
|
||||||
this.isReady = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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;
|
|
||||||
this.onEditorChange();
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// eslint-disable-next-line func-names
|
// eslint-disable-next-line func-names
|
||||||
'form.body': function () {
|
'computedValue.contentType': function (to, from) {
|
||||||
this.onEditorChange();
|
this.convertContentType(to, from);
|
||||||
},
|
},
|
||||||
|
|
||||||
htmlFormat(to, from) {
|
templateId(to, from) {
|
||||||
if ((from === 'richtext' || from === 'html') && to === 'plain') {
|
if (this.computedValue.templateId === to) {
|
||||||
// richtext, html => plain
|
return;
|
||||||
|
|
||||||
// Preserve line breaks when converting HTML to plaintext.
|
|
||||||
const d = document.createElement('div');
|
|
||||||
d.innerHTML = this.beautifyHTML(this.form.body);
|
|
||||||
this.$nextTick(() => {
|
|
||||||
this.form.body = this.trimLines(d.innerText.trim(), true);
|
|
||||||
});
|
|
||||||
} else if ((from === 'richtext' || from === 'html') && to === 'markdown') {
|
|
||||||
// richtext, html => markdown
|
|
||||||
this.form.body = turndown.turndown(this.form.body).replace(/\n\n+/ig, '\n\n');
|
|
||||||
} else if (from === 'plain' && (to === 'richtext' || to === 'html')) {
|
|
||||||
// plain => richtext, html
|
|
||||||
this.form.body = this.form.body.replace(/\n/ig, '<br>\n');
|
|
||||||
} else if (from === 'richtext' && to === 'html') {
|
|
||||||
// richtext => html
|
|
||||||
this.form.body = this.beautifyHTML(this.form.body);
|
|
||||||
} else if (from === 'markdown' && (to === 'richtext' || to === 'html')) {
|
|
||||||
// markdown => richtext, html.
|
|
||||||
this.$api.convertCampaignContent({
|
|
||||||
id: 1, body: this.form.body, from, to,
|
|
||||||
}).then((data) => {
|
|
||||||
this.form.body = this.beautifyHTML(data.trim());
|
|
||||||
// Update the HTML editor.
|
|
||||||
if (to === 'html') {
|
|
||||||
this.updateHTMLEditor();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.onEditorChange();
|
if (this.computedValue.contentType === 'visual') {
|
||||||
|
this.$utils.confirm(
|
||||||
|
this.$t('campaigns.confirmApplyVisualTemplate'),
|
||||||
|
() => {
|
||||||
|
this.computedValue.templateId = to;
|
||||||
|
|
||||||
|
if (!to) {
|
||||||
|
this.computedValue.body = '';
|
||||||
|
this.computedValue.bodySource = null;
|
||||||
|
} else {
|
||||||
|
this.templates.forEach((t) => {
|
||||||
|
if (t.id === to) {
|
||||||
|
this.computedValue.body = t.body;
|
||||||
|
this.computedValue.bodySource = t.bodySource;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
this.templateId = from;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
353
frontend/src/components/RichtextEditor.vue
Normal file
353
frontend/src/components/RichtextEditor.vue
Normal file
@@ -0,0 +1,353 @@
|
|||||||
|
<template>
|
||||||
|
<div class="richtext-editor" v-if="isRichtextReady">
|
||||||
|
<tiny-mce v-model="computedValue" :disabled="disabled" :init="richtextConf" />
|
||||||
|
|
||||||
|
<b-modal scroll="keep" :width="1200" :aria-modal="true" :active.sync="isRichtextSourceVisible">
|
||||||
|
<div>
|
||||||
|
<section expanded class="modal-card-body preview">
|
||||||
|
<html-editor v-model="richTextSourceBody" />
|
||||||
|
</section>
|
||||||
|
<footer class="modal-card-foot has-text-right">
|
||||||
|
<b-button @click="onFormatRichtextHTML">
|
||||||
|
{{ $t('campaigns.formatHTML') }}
|
||||||
|
</b-button>
|
||||||
|
<b-button @click="() => { this.isRichtextSourceVisible = false; }">
|
||||||
|
{{ $t('globals.buttons.close') }}
|
||||||
|
</b-button>
|
||||||
|
<b-button @click="onSaveRichTextSource" class="is-primary">
|
||||||
|
{{ $t('globals.buttons.save') }}
|
||||||
|
</b-button>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</b-modal>
|
||||||
|
|
||||||
|
<b-modal scroll="keep" :width="750" :aria-modal="true" :active.sync="isInsertHTMLVisible">
|
||||||
|
<div>
|
||||||
|
<section expanded class="modal-card-body preview">
|
||||||
|
<html-editor v-model="insertHTMLSnippet" />
|
||||||
|
</section>
|
||||||
|
<footer class="modal-card-foot has-text-right">
|
||||||
|
<b-button @click="onFormatRichtextHTMLSnippet">
|
||||||
|
{{ $t('campaigns.formatHTML') }}
|
||||||
|
</b-button>
|
||||||
|
<b-button @click="() => { this.isInsertHTMLVisible = false; }">
|
||||||
|
{{ $t('globals.buttons.close') }}
|
||||||
|
</b-button>
|
||||||
|
<b-button @click="onInsertHTML" class="is-primary">
|
||||||
|
{{ $t('globals.buttons.insert') }}
|
||||||
|
</b-button>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</b-modal>
|
||||||
|
|
||||||
|
<!-- 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 is-modal @selected="onMediaSelect" />
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</b-modal>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { html } from 'js-beautify';
|
||||||
|
import { mapState } from 'vuex';
|
||||||
|
|
||||||
|
import TinyMce from '@tinymce/tinymce-vue';
|
||||||
|
import 'tinymce';
|
||||||
|
import 'tinymce/icons/default';
|
||||||
|
import 'tinymce/plugins/anchor';
|
||||||
|
import 'tinymce/plugins/autolink';
|
||||||
|
import 'tinymce/plugins/autoresize';
|
||||||
|
import 'tinymce/plugins/charmap';
|
||||||
|
import 'tinymce/plugins/colorpicker';
|
||||||
|
import 'tinymce/plugins/contextmenu';
|
||||||
|
import 'tinymce/plugins/emoticons';
|
||||||
|
import 'tinymce/plugins/emoticons/js/emojis';
|
||||||
|
import 'tinymce/plugins/fullscreen';
|
||||||
|
import 'tinymce/plugins/help';
|
||||||
|
import 'tinymce/plugins/hr';
|
||||||
|
import 'tinymce/plugins/image';
|
||||||
|
import 'tinymce/plugins/imagetools';
|
||||||
|
import 'tinymce/plugins/link';
|
||||||
|
import 'tinymce/plugins/lists';
|
||||||
|
import 'tinymce/plugins/paste';
|
||||||
|
import 'tinymce/plugins/searchreplace';
|
||||||
|
import 'tinymce/plugins/table';
|
||||||
|
import 'tinymce/plugins/textcolor';
|
||||||
|
import 'tinymce/plugins/visualblocks';
|
||||||
|
import 'tinymce/plugins/visualchars';
|
||||||
|
import 'tinymce/plugins/wordcount';
|
||||||
|
import 'tinymce/skins/ui/oxide/skin.css';
|
||||||
|
import 'tinymce/themes/silver';
|
||||||
|
|
||||||
|
import { colors, uris } from '../constants';
|
||||||
|
import HTMLEditor from './HTMLEditor.vue';
|
||||||
|
import Media from '../views/Media.vue';
|
||||||
|
|
||||||
|
// Map of listmonk language codes to corresponding TinyMCE language files.
|
||||||
|
const LANGS = {
|
||||||
|
'cs-cz': 'cs',
|
||||||
|
de: 'de',
|
||||||
|
es: 'es_419',
|
||||||
|
fr: 'fr_FR',
|
||||||
|
it: 'it_IT',
|
||||||
|
pl: 'pl',
|
||||||
|
pt: 'pt_PT',
|
||||||
|
'pt-BR': 'pt_BR',
|
||||||
|
ro: 'ro',
|
||||||
|
tr: 'tr',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default {
|
||||||
|
components: {
|
||||||
|
Media,
|
||||||
|
'tiny-mce': TinyMce,
|
||||||
|
'html-editor': HTMLEditor,
|
||||||
|
},
|
||||||
|
|
||||||
|
props: {
|
||||||
|
disabled: { type: Boolean, default: false },
|
||||||
|
value: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
isPreviewing: false,
|
||||||
|
isMediaVisible: false,
|
||||||
|
isReady: false,
|
||||||
|
isRichtextReady: false,
|
||||||
|
isRichtextSourceVisible: false,
|
||||||
|
isInsertHTMLVisible: false,
|
||||||
|
insertHTMLSnippet: '',
|
||||||
|
isTrackLink: false,
|
||||||
|
richtextConf: {},
|
||||||
|
richTextSourceBody: '',
|
||||||
|
contentType: '',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
initRichtextEditor() {
|
||||||
|
const { lang } = this.serverConfig;
|
||||||
|
|
||||||
|
this.richtextConf = {
|
||||||
|
init_instance_callback: () => { this.isReady = true; },
|
||||||
|
urlconverter_callback: this.onEditorURLConvert,
|
||||||
|
|
||||||
|
setup: (editor) => {
|
||||||
|
editor.on('init', () => {
|
||||||
|
editor.focus();
|
||||||
|
this.onEditorDialogOpen(editor);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Custom HTML editor.
|
||||||
|
editor.ui.registry.addButton('html', {
|
||||||
|
icon: 'sourcecode',
|
||||||
|
tooltip: 'Source code',
|
||||||
|
onAction: this.onRichtextViewSource,
|
||||||
|
});
|
||||||
|
|
||||||
|
editor.ui.registry.addButton('insert-html', {
|
||||||
|
icon: 'code-sample',
|
||||||
|
tooltip: 'Insert HTML',
|
||||||
|
onAction: this.onOpenInsertHTML,
|
||||||
|
});
|
||||||
|
|
||||||
|
editor.on('CloseWindow', () => {
|
||||||
|
editor.selection.getNode().scrollIntoView(false);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
browser_spellcheck: true,
|
||||||
|
min_height: 500,
|
||||||
|
toolbar_sticky: true,
|
||||||
|
entity_encoding: 'raw',
|
||||||
|
convert_urls: true,
|
||||||
|
plugins: [
|
||||||
|
'anchor', 'autoresize', 'autolink', 'charmap', 'emoticons', 'fullscreen',
|
||||||
|
'help', 'hr', 'image', 'imagetools', 'link', 'lists', 'paste', 'searchreplace',
|
||||||
|
'table', 'visualblocks', 'visualchars', 'wordcount',
|
||||||
|
],
|
||||||
|
toolbar: `undo redo | formatselect styleselect fontsizeselect |
|
||||||
|
bold italic underline strikethrough forecolor backcolor subscript superscript |
|
||||||
|
alignleft aligncenter alignright alignjustify |
|
||||||
|
bullist numlist table image insert-html | outdent indent | link hr removeformat |
|
||||||
|
html fullscreen help`,
|
||||||
|
fontsize_formats: '10px 11px 12px 14px 15px 16px 18px 24px 36px',
|
||||||
|
skin: false,
|
||||||
|
content_css: false,
|
||||||
|
content_style: `
|
||||||
|
body { font-family: 'Inter', sans-serif; font-size: 15px; }
|
||||||
|
img { max-width: 100%; }
|
||||||
|
a { color: ${colors.primary}; }
|
||||||
|
table, td { border-color: #ccc;}
|
||||||
|
`,
|
||||||
|
|
||||||
|
language: LANGS[lang] || null,
|
||||||
|
language_url: LANGS[lang] ? `${uris.static}/tinymce/lang/${LANGS[lang]}.js` : null,
|
||||||
|
|
||||||
|
file_picker_types: 'image',
|
||||||
|
file_picker_callback: (callback) => {
|
||||||
|
this.isMediaVisible = true;
|
||||||
|
this.imageCallack = callback;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
this.isRichtextReady = true;
|
||||||
|
},
|
||||||
|
|
||||||
|
onEditorURLConvert(url) {
|
||||||
|
let u = url;
|
||||||
|
if (this.isTrackLink) {
|
||||||
|
u = `${u}@TrackLink`;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isTrackLink = false;
|
||||||
|
return u;
|
||||||
|
},
|
||||||
|
|
||||||
|
onRichtextViewSource() {
|
||||||
|
this.richTextSourceBody = this.computedValue;
|
||||||
|
this.isRichtextSourceVisible = true;
|
||||||
|
},
|
||||||
|
|
||||||
|
onOpenInsertHTML() {
|
||||||
|
this.isInsertHTMLVisible = true;
|
||||||
|
},
|
||||||
|
|
||||||
|
onInsertHTML() {
|
||||||
|
this.isInsertHTMLVisible = false;
|
||||||
|
window.tinymce.editors[0].execCommand('mceInsertContent', false, this.insertHTMLSnippet);
|
||||||
|
this.insertHTMLSnippet = '';
|
||||||
|
},
|
||||||
|
|
||||||
|
onFormatRichtextHTML() {
|
||||||
|
this.richTextSourceBody = this.beautifyHTML(this.richTextSourceBody);
|
||||||
|
},
|
||||||
|
|
||||||
|
onFormatRichtextHTMLSnippet() {
|
||||||
|
this.insertHTMLSnippet = this.beautifyHTML(this.insertHTMLSnippet);
|
||||||
|
},
|
||||||
|
|
||||||
|
onSaveRichTextSource() {
|
||||||
|
this.computedValue = this.richTextSourceBody;
|
||||||
|
window.tinymce.editors[0].setContent(this.computedValue);
|
||||||
|
this.richTextSourceBody = '';
|
||||||
|
this.isRichtextSourceVisible = false;
|
||||||
|
},
|
||||||
|
|
||||||
|
onEditorDialogOpen(editor) {
|
||||||
|
const ed = editor;
|
||||||
|
const oldEd = ed.windowManager.open;
|
||||||
|
const self = this;
|
||||||
|
|
||||||
|
ed.windowManager.open = (t, r) => {
|
||||||
|
const isOK = t.initialData && 'url' in t.initialData && 'anchor' in t.initialData;
|
||||||
|
|
||||||
|
// Not the link modal.
|
||||||
|
if (!isOK) {
|
||||||
|
return oldEd.apply(this, [t, r]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If an existing link is being edited, check for the tracking flag `@TrackLink` at the end
|
||||||
|
// of the url. Remove that from the URL and instead check the checkbox.
|
||||||
|
let checked = false;
|
||||||
|
if (!t.initialData.link !== '') {
|
||||||
|
const t2 = t;
|
||||||
|
const url = t2.initialData.url.value.replace(/@TrackLink$/, '');
|
||||||
|
|
||||||
|
if (t2.initialData.url.value !== url) {
|
||||||
|
t2.initialData.url.value = url;
|
||||||
|
checked = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute the modal.
|
||||||
|
const modal = oldEd.apply(this, [t, r]);
|
||||||
|
|
||||||
|
// Is it the link dialog?
|
||||||
|
if (isOK) {
|
||||||
|
// Insert tracking checkbox.
|
||||||
|
const c = document.createElement('input');
|
||||||
|
c.setAttribute('type', 'checkbox');
|
||||||
|
|
||||||
|
if (checked) {
|
||||||
|
c.setAttribute('checked', checked);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store the checkbox's state in the Vue instance to pick up from
|
||||||
|
// the TinyMCE link conversion callback.
|
||||||
|
c.onchange = (e) => {
|
||||||
|
self.isTrackLink = e.target.checked;
|
||||||
|
};
|
||||||
|
|
||||||
|
const l = document.createElement('label');
|
||||||
|
l.appendChild(c);
|
||||||
|
l.appendChild(document.createTextNode('Track link?'));
|
||||||
|
l.classList.add('tox-label', 'tox-track-link');
|
||||||
|
|
||||||
|
document.querySelector('.tox-form__controls-h-stack .tox-control-wrap').appendChild(l);
|
||||||
|
}
|
||||||
|
return modal;
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
onMediaSelect(media) {
|
||||||
|
this.imageCallack(media.url);
|
||||||
|
},
|
||||||
|
|
||||||
|
beautifyHTML(str) {
|
||||||
|
// Pad all tags with linebreaks.
|
||||||
|
let s = this.trimLines(str.replace(/(<(?!(\/)?a|span)([^>]+)>)/ig, '\n$1\n'), true);
|
||||||
|
// Remove extra linebreaks.
|
||||||
|
s = s.replace(/\n+/g, '\n');
|
||||||
|
|
||||||
|
try {
|
||||||
|
s = html(s).trim();
|
||||||
|
} catch (error) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log('error formatting HTML', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return s;
|
||||||
|
},
|
||||||
|
|
||||||
|
trimLines(str, removeEmptyLines) {
|
||||||
|
const out = str.split('\n');
|
||||||
|
for (let i = 0; i < out.length; i += 1) {
|
||||||
|
const line = out[i].trim();
|
||||||
|
if (removeEmptyLines) {
|
||||||
|
out[i] = line;
|
||||||
|
} else if (line === '') {
|
||||||
|
out[i] = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return out.join('\n').replace(/\n\s*\n\s*\n/g, '\n\n');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
mounted() {
|
||||||
|
this.initRichtextEditor();
|
||||||
|
},
|
||||||
|
|
||||||
|
computed: {
|
||||||
|
...mapState(['serverConfig']),
|
||||||
|
|
||||||
|
computedValue: {
|
||||||
|
get() {
|
||||||
|
return this.value;
|
||||||
|
},
|
||||||
|
set(newValue) {
|
||||||
|
this.$emit('input', newValue);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
63
frontend/src/components/VisualEditor.vue
Normal file
63
frontend/src/components/VisualEditor.vue
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
<template>
|
||||||
|
<div class="visual-editor-wrapper">
|
||||||
|
<div ref="visualEditor" id="visual-editor" class="visual-editor email-builder-container" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { render, isRendered, setDocument, DEFAULT_SOURCE } from '../email-builder';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
source: { type: String, default: '' },
|
||||||
|
height: { type: String, default: 'auto' },
|
||||||
|
},
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
initEditor() {
|
||||||
|
let source = null;
|
||||||
|
if (this.$props.source) {
|
||||||
|
source = JSON.parse(this.$props.source);
|
||||||
|
}
|
||||||
|
|
||||||
|
render('visual-editor', {
|
||||||
|
data: source,
|
||||||
|
onChange: (data, body) => {
|
||||||
|
this.$emit('change', { source: JSON.stringify(data), body });
|
||||||
|
},
|
||||||
|
height: this.height,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
mounted() {
|
||||||
|
this.initEditor();
|
||||||
|
},
|
||||||
|
|
||||||
|
watch: {
|
||||||
|
source(val) {
|
||||||
|
if (isRendered('visual-editor')) {
|
||||||
|
if (val) {
|
||||||
|
setDocument(JSON.parse(val));
|
||||||
|
} else {
|
||||||
|
setDocument(DEFAULT_SOURCE);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.initEditor();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="css">
|
||||||
|
.visual-editor-wrapper {
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid #eaeaea;
|
||||||
|
max-width: 100vw;
|
||||||
|
}
|
||||||
|
|
||||||
|
#visual-editor {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -81,17 +81,6 @@
|
|||||||
<list-selector v-model="form.lists" :selected="form.lists" :all="lists.results" :disabled="!canEdit"
|
<list-selector v-model="form.lists" :selected="form.lists" :all="lists.results" :disabled="!canEdit"
|
||||||
:label="$t('globals.terms.lists')" :placeholder="$t('campaigns.sendToLists')" />
|
:label="$t('globals.terms.lists')" :placeholder="$t('campaigns.sendToLists')" />
|
||||||
|
|
||||||
<b-field :label="$tc('globals.terms.template')" label-position="on-border">
|
|
||||||
<b-select :placeholder="$tc('globals.terms.template')" v-model="form.templateId" name="template"
|
|
||||||
:disabled="!canEdit" required>
|
|
||||||
<template v-for="t in templates">
|
|
||||||
<option v-if="t.type === 'campaign'" :value="t.id" :key="t.id">
|
|
||||||
{{ t.name }}
|
|
||||||
</option>
|
|
||||||
</template>
|
|
||||||
</b-select>
|
|
||||||
</b-field>
|
|
||||||
|
|
||||||
<b-field :label="$tc('globals.terms.messenger')" label-position="on-border">
|
<b-field :label="$tc('globals.terms.messenger')" label-position="on-border">
|
||||||
<b-select :placeholder="$tc('globals.terms.messenger')" v-model="form.messenger" name="messenger"
|
<b-select :placeholder="$tc('globals.terms.messenger')" v-model="form.messenger" name="messenger"
|
||||||
:disabled="!canEdit" required>
|
:disabled="!canEdit" required>
|
||||||
@@ -180,8 +169,8 @@
|
|||||||
</b-tab-item><!-- campaign -->
|
</b-tab-item><!-- campaign -->
|
||||||
|
|
||||||
<b-tab-item :label="$t('campaigns.content')" icon="text" :disabled="isNew" value="content">
|
<b-tab-item :label="$t('campaigns.content')" icon="text" :disabled="isNew" value="content">
|
||||||
<editor v-model="form.content" :id="data.id" :title="data.name" :template-id="form.templateId"
|
<editor v-if="data.id" v-model="form.content"
|
||||||
:content-type="data.contentType" :body="data.body" :disabled="!canEdit" />
|
:id="data.id" :title="data.name" :disabled="!canEdit" :templates="templates" />
|
||||||
|
|
||||||
<div class="columns">
|
<div class="columns">
|
||||||
<div class="column is-6">
|
<div class="column is-6">
|
||||||
@@ -335,11 +324,10 @@ export default Vue.extend({
|
|||||||
headersStr: '[]',
|
headersStr: '[]',
|
||||||
headers: [],
|
headers: [],
|
||||||
messenger: 'email',
|
messenger: 'email',
|
||||||
templateId: 0,
|
|
||||||
lists: [],
|
lists: [],
|
||||||
tags: [],
|
tags: [],
|
||||||
sendAt: null,
|
sendAt: null,
|
||||||
content: { contentType: 'richtext', body: '' },
|
content: { contentType: 'richtext', body: '', bodySource: null, templateId: null },
|
||||||
altbody: null,
|
altbody: null,
|
||||||
media: [],
|
media: [],
|
||||||
|
|
||||||
@@ -459,7 +447,7 @@ export default Vue.extend({
|
|||||||
archiveMetaStr: data.archiveMeta ? JSON.stringify(data.archiveMeta, null, 4) : '{}',
|
archiveMetaStr: data.archiveMeta ? JSON.stringify(data.archiveMeta, null, 4) : '{}',
|
||||||
|
|
||||||
// The structure that is populated by editor input event.
|
// The structure that is populated by editor input event.
|
||||||
content: { contentType: data.contentType, body: data.body },
|
content: { contentType: data.contentType, body: data.body, bodySource: data.bodySource, templateId: data.templateId },
|
||||||
};
|
};
|
||||||
this.isAttachFieldVisible = this.form.media.length > 0;
|
this.isAttachFieldVisible = this.form.media.length > 0;
|
||||||
|
|
||||||
@@ -483,7 +471,7 @@ export default Vue.extend({
|
|||||||
type: 'regular',
|
type: 'regular',
|
||||||
headers: this.form.headers,
|
headers: this.form.headers,
|
||||||
tags: this.form.tags,
|
tags: this.form.tags,
|
||||||
template_id: this.form.templateId,
|
template_id: this.form.content.templateId,
|
||||||
content_type: this.form.content.contentType,
|
content_type: this.form.content.contentType,
|
||||||
body: this.form.content.body,
|
body: this.form.content.body,
|
||||||
altbody: this.form.content.contentType !== 'plain' ? this.form.altbody : null,
|
altbody: this.form.content.contentType !== 'plain' ? this.form.altbody : null,
|
||||||
@@ -504,15 +492,12 @@ export default Vue.extend({
|
|||||||
subject: this.form.subject,
|
subject: this.form.subject,
|
||||||
lists: this.form.lists.map((l) => l.id),
|
lists: this.form.lists.map((l) => l.id),
|
||||||
from_email: this.form.fromEmail,
|
from_email: this.form.fromEmail,
|
||||||
content_type: 'richtext',
|
|
||||||
messenger: this.form.messenger,
|
messenger: this.form.messenger,
|
||||||
type: 'regular',
|
type: 'regular',
|
||||||
tags: this.form.tags,
|
tags: this.form.tags,
|
||||||
send_at: this.form.sendLater ? this.form.sendAtDate : null,
|
send_at: this.form.sendLater ? this.form.sendAtDate : null,
|
||||||
headers: this.form.headers,
|
headers: this.form.headers,
|
||||||
template_id: this.form.templateId,
|
|
||||||
media: this.form.media.map((m) => m.id),
|
media: this.form.media.map((m) => m.id),
|
||||||
// body: this.form.body,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
this.$api.createCampaign(data).then((d) => {
|
this.$api.createCampaign(data).then((d) => {
|
||||||
@@ -533,9 +518,10 @@ export default Vue.extend({
|
|||||||
tags: this.form.tags,
|
tags: this.form.tags,
|
||||||
send_at: this.form.sendLater ? this.form.sendAtDate : null,
|
send_at: this.form.sendLater ? this.form.sendAtDate : null,
|
||||||
headers: this.form.headers,
|
headers: this.form.headers,
|
||||||
template_id: this.form.templateId,
|
template_id: this.form.content.templateId,
|
||||||
content_type: this.form.content.contentType,
|
content_type: this.form.content.contentType,
|
||||||
body: this.form.content.body,
|
body: this.form.content.body,
|
||||||
|
body_source: this.form.content.bodySource,
|
||||||
altbody: this.form.content.contentType !== 'plain' ? this.form.altbody : null,
|
altbody: this.form.content.contentType !== 'plain' ? this.form.altbody : null,
|
||||||
archive: this.form.archive,
|
archive: this.form.archive,
|
||||||
archive_template_id: this.form.archiveTemplateId,
|
archive_template_id: this.form.archiveTemplateId,
|
||||||
@@ -722,7 +708,8 @@ export default Vue.extend({
|
|||||||
this.$api.getTemplates().then((data) => {
|
this.$api.getTemplates().then((data) => {
|
||||||
if (data.length > 0) {
|
if (data.length > 0) {
|
||||||
if (!this.form.templateId) {
|
if (!this.form.templateId) {
|
||||||
this.form.templateId = data.find((i) => i.isDefault === true).id;
|
const tpl = data.find((i) => i.isDefault === true);
|
||||||
|
this.form.templateId = tpl.id;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
{{ $t('templates.newTemplate') }}
|
{{ $t('templates.newTemplate') }}
|
||||||
</h4>
|
</h4>
|
||||||
</header>
|
</header>
|
||||||
<section expanded class="modal-card-body">
|
<section expanded class="modal-card-body mb-0 pb-0">
|
||||||
<div class="columns">
|
<div class="columns">
|
||||||
<div class="column is-9">
|
<div class="column is-9">
|
||||||
<b-field :label="$t('globals.fields.name')" label-position="on-border">
|
<b-field :label="$t('globals.fields.name')" label-position="on-border">
|
||||||
@@ -29,10 +29,13 @@
|
|||||||
<b-field :label="$t('globals.fields.type')" label-position="on-border">
|
<b-field :label="$t('globals.fields.type')" label-position="on-border">
|
||||||
<b-select v-model="form.type" :disabled="isEditing" expanded>
|
<b-select v-model="form.type" :disabled="isEditing" expanded>
|
||||||
<option value="campaign">
|
<option value="campaign">
|
||||||
{{ $tc('globals.terms.campaign') }}
|
{{ $tc('templates.typeCampaignHTML') }}
|
||||||
|
</option>
|
||||||
|
<option value="campaign_visual">
|
||||||
|
{{ $tc('templates.typeCampaignVisual') }}
|
||||||
</option>
|
</option>
|
||||||
<option value="tx">
|
<option value="tx">
|
||||||
{{ $tc('globals.terms.tx') }}
|
{{ $tc('templates.typeTransactional') }}
|
||||||
</option>
|
</option>
|
||||||
</b-select>
|
</b-select>
|
||||||
</b-field>
|
</b-field>
|
||||||
@@ -47,9 +50,15 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<b-field v-if="form.body !== null" :label="$t('templates.rawHTML')" label-position="on-border">
|
<template v-if="form.body !== null">
|
||||||
<html-editor v-model="form.body" name="body" />
|
<b-field v-if="form.type === 'campaign_visual'" label-position="on-border" class="mb-1">
|
||||||
</b-field>
|
<visual-editor v-if="form.type === 'campaign_visual'" name="body" :source="form.bodySource" @change="onChangeVisualEditor" height="610px" />
|
||||||
|
</b-field>
|
||||||
|
|
||||||
|
<b-field v-else :label="$t('templates.rawHTML')" label-position="on-border">
|
||||||
|
<html-editor v-model="form.body" name="body" />
|
||||||
|
</b-field>
|
||||||
|
</template>
|
||||||
|
|
||||||
<p class="is-size-7">
|
<p class="is-size-7">
|
||||||
<template v-if="form.type === 'campaign'">
|
<template v-if="form.type === 'campaign'">
|
||||||
@@ -70,7 +79,7 @@
|
|||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
<campaign-preview v-if="previewItem" type="template" :title="previewItem.name" :template-type="previewItem.type"
|
<campaign-preview v-if="previewItem" is-post type="template" :title="previewItem.name" :template-type="previewItem.type"
|
||||||
:body="form.body" @close="onTogglePreview" />
|
:body="form.body" @close="onTogglePreview" />
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
@@ -80,6 +89,7 @@ import Vue from 'vue';
|
|||||||
import { mapState } from 'vuex';
|
import { mapState } from 'vuex';
|
||||||
import CampaignPreview from '../components/CampaignPreview.vue';
|
import CampaignPreview from '../components/CampaignPreview.vue';
|
||||||
import HTMLEditor from '../components/HTMLEditor.vue';
|
import HTMLEditor from '../components/HTMLEditor.vue';
|
||||||
|
import VisualEditor from '../components/VisualEditor.vue';
|
||||||
import CopyText from '../components/CopyText.vue';
|
import CopyText from '../components/CopyText.vue';
|
||||||
|
|
||||||
export default Vue.extend({
|
export default Vue.extend({
|
||||||
@@ -87,6 +97,7 @@ export default Vue.extend({
|
|||||||
CampaignPreview,
|
CampaignPreview,
|
||||||
CopyText,
|
CopyText,
|
||||||
'html-editor': HTMLEditor,
|
'html-editor': HTMLEditor,
|
||||||
|
'visual-editor': VisualEditor,
|
||||||
},
|
},
|
||||||
|
|
||||||
props: {
|
props: {
|
||||||
@@ -103,6 +114,7 @@ export default Vue.extend({
|
|||||||
type: 'campaign',
|
type: 'campaign',
|
||||||
optin: '',
|
optin: '',
|
||||||
body: null,
|
body: null,
|
||||||
|
bodySource: null,
|
||||||
},
|
},
|
||||||
previewItem: null,
|
previewItem: null,
|
||||||
egPlaceholder: '{{ template "content" . }}',
|
egPlaceholder: '{{ template "content" . }}',
|
||||||
@@ -137,6 +149,7 @@ export default Vue.extend({
|
|||||||
type: this.form.type,
|
type: this.form.type,
|
||||||
subject: this.form.subject,
|
subject: this.form.subject,
|
||||||
body: this.form.body,
|
body: this.form.body,
|
||||||
|
body_source: this.form.bodySource,
|
||||||
};
|
};
|
||||||
|
|
||||||
this.$api.createTemplate(data).then((d) => {
|
this.$api.createTemplate(data).then((d) => {
|
||||||
@@ -153,6 +166,7 @@ export default Vue.extend({
|
|||||||
type: this.form.type,
|
type: this.form.type,
|
||||||
subject: this.form.subject,
|
subject: this.form.subject,
|
||||||
body: this.form.body,
|
body: this.form.body,
|
||||||
|
body_source: this.form.bodySource,
|
||||||
};
|
};
|
||||||
|
|
||||||
this.$api.updateTemplate(data).then((d) => {
|
this.$api.updateTemplate(data).then((d) => {
|
||||||
@@ -161,6 +175,11 @@ export default Vue.extend({
|
|||||||
this.$utils.toast(`'${d.name}' updated`);
|
this.$utils.toast(`'${d.name}' updated`);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
onChangeVisualEditor({ source, body }) {
|
||||||
|
this.form.body = body;
|
||||||
|
this.form.bodySource = source;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
computed: {
|
computed: {
|
||||||
|
|||||||
@@ -32,10 +32,13 @@
|
|||||||
|
|
||||||
<b-table-column v-slot="props" field="type" :label="$t('globals.fields.type')" sortable>
|
<b-table-column v-slot="props" field="type" :label="$t('globals.fields.type')" sortable>
|
||||||
<b-tag v-if="props.row.type === 'campaign'" :class="props.row.type" :data-cy="`type-${props.row.type}`">
|
<b-tag v-if="props.row.type === 'campaign'" :class="props.row.type" :data-cy="`type-${props.row.type}`">
|
||||||
{{ $tc('globals.terms.campaign', 1) }}
|
{{ $tc('templates.typeCampaignHTML') }}
|
||||||
|
</b-tag>
|
||||||
|
<b-tag v-else-if="props.row.type === 'campaign_visual'" :class="props.row.type" :data-cy="`type-${props.row.type}`">
|
||||||
|
{{ $tc('templates.typeCampaignVisual') }}
|
||||||
</b-tag>
|
</b-tag>
|
||||||
<b-tag v-else :class="props.row.type" :data-cy="`type-${props.row.type}`">
|
<b-tag v-else :class="props.row.type" :data-cy="`type-${props.row.type}`">
|
||||||
{{ $tc('globals.terms.tx', 1) }}
|
{{ $tc('templates.typeTransactional') }}
|
||||||
</b-tag>
|
</b-tag>
|
||||||
</b-table-column>
|
</b-table-column>
|
||||||
|
|
||||||
|
|||||||
184
frontend/yarn.lock
vendored
184
frontend/yarn.lock
vendored
@@ -230,6 +230,18 @@
|
|||||||
resolved "https://registry.yarnpkg.com/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz#4a2868d75d6d6963e423bcf90b7fd1be343409d3"
|
resolved "https://registry.yarnpkg.com/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz#4a2868d75d6d6963e423bcf90b7fd1be343409d3"
|
||||||
integrity sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==
|
integrity sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==
|
||||||
|
|
||||||
|
"@isaacs/cliui@^8.0.2":
|
||||||
|
version "8.0.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/@isaacs/cliui/-/cliui-8.0.2.tgz#b37667b7bc181c168782259bab42474fbf52b550"
|
||||||
|
integrity sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==
|
||||||
|
dependencies:
|
||||||
|
string-width "^5.1.2"
|
||||||
|
string-width-cjs "npm:string-width@^4.2.0"
|
||||||
|
strip-ansi "^7.0.1"
|
||||||
|
strip-ansi-cjs "npm:strip-ansi@^6.0.1"
|
||||||
|
wrap-ansi "^8.1.0"
|
||||||
|
wrap-ansi-cjs "npm:wrap-ansi@^7.0.0"
|
||||||
|
|
||||||
"@kurkle/color@^0.3.0":
|
"@kurkle/color@^0.3.0":
|
||||||
version "0.3.4"
|
version "0.3.4"
|
||||||
resolved "https://registry.yarnpkg.com/@kurkle/color/-/color-0.3.4.tgz#4d4ff677e1609214fc71c580125ddddd86abcabf"
|
resolved "https://registry.yarnpkg.com/@kurkle/color/-/color-0.3.4.tgz#4d4ff677e1609214fc71c580125ddddd86abcabf"
|
||||||
@@ -266,6 +278,16 @@
|
|||||||
resolved "https://registry.yarnpkg.com/@one-ini/wasm/-/wasm-0.1.1.tgz#6013659736c9dbfccc96e8a9c2b3de317df39323"
|
resolved "https://registry.yarnpkg.com/@one-ini/wasm/-/wasm-0.1.1.tgz#6013659736c9dbfccc96e8a9c2b3de317df39323"
|
||||||
integrity sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==
|
integrity sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==
|
||||||
|
|
||||||
|
"@pkgjs/parseargs@^0.11.0":
|
||||||
|
version "0.11.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/@pkgjs/parseargs/-/parseargs-0.11.0.tgz#a77ea742fab25775145434eb1d2328cf5013ac33"
|
||||||
|
integrity sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==
|
||||||
|
|
||||||
|
"@rollup/rollup-android-arm-eabi@4.22.4":
|
||||||
|
version "4.22.4"
|
||||||
|
resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.22.4.tgz#8b613b9725e8f9479d142970b106b6ae878610d5"
|
||||||
|
integrity sha512-Fxamp4aEZnfPOcGA8KSNEohV8hX7zVHOemC8jVBoBUHu5zpJK/Eu3uJwt6BMgy9fkvzxDaurgj96F/NiLukF2w==
|
||||||
|
|
||||||
"@parcel/watcher-android-arm64@2.5.0":
|
"@parcel/watcher-android-arm64@2.5.0":
|
||||||
version "2.5.0"
|
version "2.5.0"
|
||||||
resolved "https://registry.yarnpkg.com/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.0.tgz#e32d3dda6647791ee930556aee206fcd5ea0fb7a"
|
resolved "https://registry.yarnpkg.com/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.0.tgz#e32d3dda6647791ee930556aee206fcd5ea0fb7a"
|
||||||
@@ -470,6 +492,11 @@
|
|||||||
resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.6.tgz#628effeeae2064a1b4e79f78e81d87b7e5fc7b50"
|
resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.6.tgz#628effeeae2064a1b4e79f78e81d87b7e5fc7b50"
|
||||||
integrity sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==
|
integrity sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==
|
||||||
|
|
||||||
|
"@types/js-beautify@^1.14.3":
|
||||||
|
version "1.14.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/@types/js-beautify/-/js-beautify-1.14.3.tgz#6ced76f79935e37e0d613110dea369881d93c1ff"
|
||||||
|
integrity sha512-FMbQHz+qd9DoGvgLHxeqqVPaNRffpIu5ZjozwV8hf9JAGpIOzuAf4wGbRSo8LNITHqGjmmVjaMggTT5P4v4IHg==
|
||||||
|
|
||||||
"@types/json5@^0.0.29":
|
"@types/json5@^0.0.29":
|
||||||
version "0.0.29"
|
version "0.0.29"
|
||||||
resolved "https://registry.yarnpkg.com/@types/json5/-/json5-0.0.29.tgz#ee28707ae94e11d2b827bcbe5270bcea7f3e71ee"
|
resolved "https://registry.yarnpkg.com/@types/json5/-/json5-0.0.29.tgz#ee28707ae94e11d2b827bcbe5270bcea7f3e71ee"
|
||||||
@@ -589,6 +616,11 @@ ansi-regex@^5.0.1:
|
|||||||
resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.1.tgz#082cb2c89c9fe8659a311a53bd6a4dc5301db304"
|
resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.1.tgz#082cb2c89c9fe8659a311a53bd6a4dc5301db304"
|
||||||
integrity sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==
|
integrity sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==
|
||||||
|
|
||||||
|
ansi-regex@^6.0.1:
|
||||||
|
version "6.1.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-6.1.0.tgz#95ec409c69619d6cb1b8b34f14b660ef28ebd654"
|
||||||
|
integrity sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==
|
||||||
|
|
||||||
ansi-styles@^4.0.0, ansi-styles@^4.1.0:
|
ansi-styles@^4.0.0, ansi-styles@^4.1.0:
|
||||||
version "4.3.0"
|
version "4.3.0"
|
||||||
resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-4.3.0.tgz#edd803628ae71c04c85ae7a0906edad34b648937"
|
resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-4.3.0.tgz#edd803628ae71c04c85ae7a0906edad34b648937"
|
||||||
@@ -596,6 +628,19 @@ ansi-styles@^4.0.0, ansi-styles@^4.1.0:
|
|||||||
dependencies:
|
dependencies:
|
||||||
color-convert "^2.0.1"
|
color-convert "^2.0.1"
|
||||||
|
|
||||||
|
ansi-styles@^6.1.0:
|
||||||
|
version "6.2.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-6.2.1.tgz#0e62320cf99c21afff3b3012192546aacbfb05c5"
|
||||||
|
integrity sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==
|
||||||
|
|
||||||
|
anymatch@~3.1.2:
|
||||||
|
version "3.1.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-3.1.3.tgz#790c58b19ba1720a84205b57c618d5ad8524973e"
|
||||||
|
integrity sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==
|
||||||
|
dependencies:
|
||||||
|
normalize-path "^3.0.0"
|
||||||
|
picomatch "^2.0.4"
|
||||||
|
|
||||||
arch@^2.2.0:
|
arch@^2.2.0:
|
||||||
version "2.2.0"
|
version "2.2.0"
|
||||||
resolved "https://registry.yarnpkg.com/arch/-/arch-2.2.0.tgz#1bc47818f305764f23ab3306b0bfc086c5a29d11"
|
resolved "https://registry.yarnpkg.com/arch/-/arch-2.2.0.tgz#1bc47818f305764f23ab3306b0bfc086c5a29d11"
|
||||||
@@ -819,7 +864,7 @@ brace-expansion@^2.0.1:
|
|||||||
dependencies:
|
dependencies:
|
||||||
balanced-match "^1.0.0"
|
balanced-match "^1.0.0"
|
||||||
|
|
||||||
braces@^3.0.3:
|
braces@~3.0.2:
|
||||||
version "3.0.3"
|
version "3.0.3"
|
||||||
resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.3.tgz#490332f40919452272d55a8480adc0c441358789"
|
resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.3.tgz#490332f40919452272d55a8480adc0c441358789"
|
||||||
integrity sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==
|
integrity sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==
|
||||||
@@ -1228,6 +1273,11 @@ dunder-proto@^1.0.0, dunder-proto@^1.0.1:
|
|||||||
es-errors "^1.3.0"
|
es-errors "^1.3.0"
|
||||||
gopd "^1.2.0"
|
gopd "^1.2.0"
|
||||||
|
|
||||||
|
eastasianwidth@^0.2.0:
|
||||||
|
version "0.2.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/eastasianwidth/-/eastasianwidth-0.2.0.tgz#696ce2ec0aa0e6ea93a397ffcf24aa7840c827cb"
|
||||||
|
integrity sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==
|
||||||
|
|
||||||
ecc-jsbn@~0.1.1:
|
ecc-jsbn@~0.1.1:
|
||||||
version "0.1.2"
|
version "0.1.2"
|
||||||
resolved "https://registry.yarnpkg.com/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz#3a83a904e54353287874c564b7549386849a98c9"
|
resolved "https://registry.yarnpkg.com/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz#3a83a904e54353287874c564b7549386849a98c9"
|
||||||
@@ -1933,6 +1983,18 @@ glob@^10.3.3:
|
|||||||
package-json-from-dist "^1.0.0"
|
package-json-from-dist "^1.0.0"
|
||||||
path-scurry "^1.11.1"
|
path-scurry "^1.11.1"
|
||||||
|
|
||||||
|
glob@^10.3.3:
|
||||||
|
version "10.4.5"
|
||||||
|
resolved "https://registry.yarnpkg.com/glob/-/glob-10.4.5.tgz#f4d9f0b90ffdbab09c9d77f5f29b4262517b0956"
|
||||||
|
integrity sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==
|
||||||
|
dependencies:
|
||||||
|
foreground-child "^3.1.0"
|
||||||
|
jackspeak "^3.1.2"
|
||||||
|
minimatch "^9.0.4"
|
||||||
|
minipass "^7.1.2"
|
||||||
|
package-json-from-dist "^1.0.0"
|
||||||
|
path-scurry "^1.11.1"
|
||||||
|
|
||||||
glob@^7.1.3:
|
glob@^7.1.3:
|
||||||
version "7.2.3"
|
version "7.2.3"
|
||||||
resolved "https://registry.yarnpkg.com/glob/-/glob-7.2.3.tgz#b8df0fb802bbfa8e89bd1d938b4e16578ed44f2b"
|
resolved "https://registry.yarnpkg.com/glob/-/glob-7.2.3.tgz#b8df0fb802bbfa8e89bd1d938b4e16578ed44f2b"
|
||||||
@@ -2100,6 +2162,15 @@ ini@^1.3.4:
|
|||||||
resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.8.tgz#a29da425b48806f34767a4efce397269af28432c"
|
resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.8.tgz#a29da425b48806f34767a4efce397269af28432c"
|
||||||
integrity sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==
|
integrity sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==
|
||||||
|
|
||||||
|
internal-slot@^1.0.5:
|
||||||
|
version "1.0.5"
|
||||||
|
resolved "https://registry.yarnpkg.com/internal-slot/-/internal-slot-1.0.5.tgz#f2a2ee21f668f8627a4667f309dc0f4fb6674986"
|
||||||
|
integrity sha512-Y+R5hJrzs52QCG2laLn4udYVnxsfny9CpOhNhUvk/SSSVyF6T27FzRbF0sroPidSu3X8oEAkOn2K804mjpt6UQ==
|
||||||
|
dependencies:
|
||||||
|
get-intrinsic "^1.2.0"
|
||||||
|
has "^1.0.3"
|
||||||
|
side-channel "^1.0.4"
|
||||||
|
|
||||||
internal-slot@^1.1.0:
|
internal-slot@^1.1.0:
|
||||||
version "1.1.0"
|
version "1.1.0"
|
||||||
resolved "https://registry.yarnpkg.com/internal-slot/-/internal-slot-1.1.0.tgz#1eac91762947d2f7056bc838d93e13b2e9604961"
|
resolved "https://registry.yarnpkg.com/internal-slot/-/internal-slot-1.1.0.tgz#1eac91762947d2f7056bc838d93e13b2e9604961"
|
||||||
@@ -2377,6 +2448,31 @@ js-cookie@^3.0.5:
|
|||||||
resolved "https://registry.yarnpkg.com/js-cookie/-/js-cookie-3.0.5.tgz#0b7e2fd0c01552c58ba86e0841f94dc2557dcdbc"
|
resolved "https://registry.yarnpkg.com/js-cookie/-/js-cookie-3.0.5.tgz#0b7e2fd0c01552c58ba86e0841f94dc2557dcdbc"
|
||||||
integrity sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==
|
integrity sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==
|
||||||
|
|
||||||
|
jackspeak@^3.1.2:
|
||||||
|
version "3.4.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/jackspeak/-/jackspeak-3.4.3.tgz#8833a9d89ab4acde6188942bd1c53b6390ed5a8a"
|
||||||
|
integrity sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==
|
||||||
|
dependencies:
|
||||||
|
"@isaacs/cliui" "^8.0.2"
|
||||||
|
optionalDependencies:
|
||||||
|
"@pkgjs/parseargs" "^0.11.0"
|
||||||
|
|
||||||
|
js-beautify@^1.15.1:
|
||||||
|
version "1.15.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/js-beautify/-/js-beautify-1.15.1.tgz#4695afb508c324e1084ee0b952a102023fc65b64"
|
||||||
|
integrity sha512-ESjNzSlt/sWE8sciZH8kBF8BPlwXPwhR6pWKAw8bw4Bwj+iZcnKW6ONWUutJ7eObuBZQpiIb8S7OYspWrKt7rA==
|
||||||
|
dependencies:
|
||||||
|
config-chain "^1.1.13"
|
||||||
|
editorconfig "^1.0.4"
|
||||||
|
glob "^10.3.3"
|
||||||
|
js-cookie "^3.0.5"
|
||||||
|
nopt "^7.2.0"
|
||||||
|
|
||||||
|
js-cookie@^3.0.5:
|
||||||
|
version "3.0.5"
|
||||||
|
resolved "https://registry.yarnpkg.com/js-cookie/-/js-cookie-3.0.5.tgz#0b7e2fd0c01552c58ba86e0841f94dc2557dcdbc"
|
||||||
|
integrity sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==
|
||||||
|
|
||||||
"js-tokens@^3.0.0 || ^4.0.0":
|
"js-tokens@^3.0.0 || ^4.0.0":
|
||||||
version "4.0.0"
|
version "4.0.0"
|
||||||
resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499"
|
resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499"
|
||||||
@@ -2553,10 +2649,12 @@ lru-cache@^10.2.0:
|
|||||||
resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-10.4.3.tgz#410fc8a17b70e598013df257c2446b7f3383f119"
|
resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-10.4.3.tgz#410fc8a17b70e598013df257c2446b7f3383f119"
|
||||||
integrity sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==
|
integrity sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==
|
||||||
|
|
||||||
math-intrinsics@^1.1.0:
|
lru-cache@^6.0.0:
|
||||||
version "1.1.0"
|
version "6.0.0"
|
||||||
resolved "https://registry.yarnpkg.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz#a0dd74be81e2aa5c2f27e65ce283605ee4e2b7f9"
|
resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-6.0.0.tgz#6d6fe6570ebd96aaf90fcad1dafa3b2566db3a94"
|
||||||
integrity sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==
|
integrity sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==
|
||||||
|
dependencies:
|
||||||
|
yallist "^4.0.0"
|
||||||
|
|
||||||
merge-stream@^2.0.0:
|
merge-stream@^2.0.0:
|
||||||
version "2.0.0"
|
version "2.0.0"
|
||||||
@@ -2619,6 +2717,11 @@ minimist@^1.2.0, minimist@^1.2.6, minimist@^1.2.8:
|
|||||||
resolved "https://registry.yarnpkg.com/minipass/-/minipass-7.1.2.tgz#93a9626ce5e5e66bd4db86849e7515e92340a707"
|
resolved "https://registry.yarnpkg.com/minipass/-/minipass-7.1.2.tgz#93a9626ce5e5e66bd4db86849e7515e92340a707"
|
||||||
integrity sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==
|
integrity sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==
|
||||||
|
|
||||||
|
ms@2.1.2:
|
||||||
|
version "2.1.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009"
|
||||||
|
integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==
|
||||||
|
|
||||||
ms@^2.1.1, ms@^2.1.3:
|
ms@^2.1.1, ms@^2.1.3:
|
||||||
version "2.1.3"
|
version "2.1.3"
|
||||||
resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2"
|
resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2"
|
||||||
@@ -2634,11 +2737,6 @@ natural-compare@^1.4.0:
|
|||||||
resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7"
|
resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7"
|
||||||
integrity sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==
|
integrity sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==
|
||||||
|
|
||||||
node-addon-api@^7.0.0:
|
|
||||||
version "7.1.1"
|
|
||||||
resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-7.1.1.tgz#1aba6693b0f255258a049d621329329322aad558"
|
|
||||||
integrity sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==
|
|
||||||
|
|
||||||
nopt@^7.2.0:
|
nopt@^7.2.0:
|
||||||
version "7.2.1"
|
version "7.2.1"
|
||||||
resolved "https://registry.yarnpkg.com/nopt/-/nopt-7.2.1.tgz#1cac0eab9b8e97c9093338446eddd40b2c8ca1e7"
|
resolved "https://registry.yarnpkg.com/nopt/-/nopt-7.2.1.tgz#1cac0eab9b8e97c9093338446eddd40b2c8ca1e7"
|
||||||
@@ -2646,6 +2744,11 @@ nopt@^7.2.0:
|
|||||||
dependencies:
|
dependencies:
|
||||||
abbrev "^2.0.0"
|
abbrev "^2.0.0"
|
||||||
|
|
||||||
|
normalize-path@^3.0.0, normalize-path@~3.0.0:
|
||||||
|
version "3.0.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-3.0.0.tgz#0dcd69ff23a1c9b11fd0978316644a0388216a65"
|
||||||
|
integrity sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==
|
||||||
|
|
||||||
npm-run-path@^4.0.0:
|
npm-run-path@^4.0.0:
|
||||||
version "4.0.1"
|
version "4.0.1"
|
||||||
resolved "https://registry.yarnpkg.com/npm-run-path/-/npm-run-path-4.0.1.tgz#b7ecd1e5ed53da8e37a55e1c2269e0b97ed748ea"
|
resolved "https://registry.yarnpkg.com/npm-run-path/-/npm-run-path-4.0.1.tgz#b7ecd1e5ed53da8e37a55e1c2269e0b97ed748ea"
|
||||||
@@ -3275,6 +3378,15 @@ sshpk@^1.18.0:
|
|||||||
safer-buffer "^2.0.2"
|
safer-buffer "^2.0.2"
|
||||||
tweetnacl "~0.14.0"
|
tweetnacl "~0.14.0"
|
||||||
|
|
||||||
|
"string-width-cjs@npm:string-width@^4.2.0":
|
||||||
|
version "4.2.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
|
||||||
|
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
|
||||||
|
dependencies:
|
||||||
|
emoji-regex "^8.0.0"
|
||||||
|
is-fullwidth-code-point "^3.0.0"
|
||||||
|
strip-ansi "^6.0.1"
|
||||||
|
|
||||||
string-width@^4.1.0, string-width@^4.2.0:
|
string-width@^4.1.0, string-width@^4.2.0:
|
||||||
version "4.2.3"
|
version "4.2.3"
|
||||||
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
|
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
|
||||||
@@ -3284,10 +3396,19 @@ string-width@^4.1.0, string-width@^4.2.0:
|
|||||||
is-fullwidth-code-point "^3.0.0"
|
is-fullwidth-code-point "^3.0.0"
|
||||||
strip-ansi "^6.0.1"
|
strip-ansi "^6.0.1"
|
||||||
|
|
||||||
string.prototype.includes@^2.0.1:
|
string-width@^5.0.1, string-width@^5.1.2:
|
||||||
version "2.0.1"
|
version "5.1.2"
|
||||||
resolved "https://registry.yarnpkg.com/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz#eceef21283640761a81dbe16d6c7171a4edf7d92"
|
resolved "https://registry.yarnpkg.com/string-width/-/string-width-5.1.2.tgz#14f8daec6d81e7221d2a357e668cab73bdbca794"
|
||||||
integrity sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==
|
integrity sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==
|
||||||
|
dependencies:
|
||||||
|
eastasianwidth "^0.2.0"
|
||||||
|
emoji-regex "^9.2.2"
|
||||||
|
strip-ansi "^7.0.1"
|
||||||
|
|
||||||
|
string.prototype.matchall@^4.0.8:
|
||||||
|
version "4.0.10"
|
||||||
|
resolved "https://registry.yarnpkg.com/string.prototype.matchall/-/string.prototype.matchall-4.0.10.tgz#a1553eb532221d4180c51581d6072cd65d1ee100"
|
||||||
|
integrity sha512-rGXbGmOEosIQi6Qva94HUjgPs9vKW+dkG7Y8Q5O2OYkWL6wFaTRZO8zM4mhP94uX55wgyrXzfS2aGtGzUL7EJQ==
|
||||||
dependencies:
|
dependencies:
|
||||||
call-bind "^1.0.7"
|
call-bind "^1.0.7"
|
||||||
define-properties "^1.2.1"
|
define-properties "^1.2.1"
|
||||||
@@ -3352,6 +3473,13 @@ string.prototype.trimstart@^1.0.8:
|
|||||||
define-properties "^1.2.1"
|
define-properties "^1.2.1"
|
||||||
es-object-atoms "^1.0.0"
|
es-object-atoms "^1.0.0"
|
||||||
|
|
||||||
|
"strip-ansi-cjs@npm:strip-ansi@^6.0.1":
|
||||||
|
version "6.0.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
|
||||||
|
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
|
||||||
|
dependencies:
|
||||||
|
ansi-regex "^5.0.1"
|
||||||
|
|
||||||
strip-ansi@^6.0.0, strip-ansi@^6.0.1:
|
strip-ansi@^6.0.0, strip-ansi@^6.0.1:
|
||||||
version "6.0.1"
|
version "6.0.1"
|
||||||
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
|
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
|
||||||
@@ -3359,6 +3487,13 @@ strip-ansi@^6.0.0, strip-ansi@^6.0.1:
|
|||||||
dependencies:
|
dependencies:
|
||||||
ansi-regex "^5.0.1"
|
ansi-regex "^5.0.1"
|
||||||
|
|
||||||
|
strip-ansi@^7.0.1:
|
||||||
|
version "7.1.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.0.tgz#d5b6568ca689d8561370b0707685d22434faff45"
|
||||||
|
integrity sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==
|
||||||
|
dependencies:
|
||||||
|
ansi-regex "^6.0.1"
|
||||||
|
|
||||||
strip-bom@^3.0.0:
|
strip-bom@^3.0.0:
|
||||||
version "3.0.0"
|
version "3.0.0"
|
||||||
resolved "https://registry.yarnpkg.com/strip-bom/-/strip-bom-3.0.0.tgz#2334c18e9c759f7bdd56fdef7e9ae3d588e68ed3"
|
resolved "https://registry.yarnpkg.com/strip-bom/-/strip-bom-3.0.0.tgz#2334c18e9c759f7bdd56fdef7e9ae3d588e68ed3"
|
||||||
@@ -3715,10 +3850,14 @@ which@^2.0.1:
|
|||||||
dependencies:
|
dependencies:
|
||||||
isexe "^2.0.0"
|
isexe "^2.0.0"
|
||||||
|
|
||||||
word-wrap@^1.2.5:
|
"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0":
|
||||||
version "1.2.5"
|
version "7.0.0"
|
||||||
resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.5.tgz#d2c45c6dd4fbce621a66f136cbe328afd0410b34"
|
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
|
||||||
integrity sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==
|
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
|
||||||
|
dependencies:
|
||||||
|
ansi-styles "^4.0.0"
|
||||||
|
string-width "^4.1.0"
|
||||||
|
strip-ansi "^6.0.0"
|
||||||
|
|
||||||
wrap-ansi@^6.2.0:
|
wrap-ansi@^6.2.0:
|
||||||
version "6.2.0"
|
version "6.2.0"
|
||||||
@@ -3738,6 +3877,15 @@ wrap-ansi@^7.0.0:
|
|||||||
string-width "^4.1.0"
|
string-width "^4.1.0"
|
||||||
strip-ansi "^6.0.0"
|
strip-ansi "^6.0.0"
|
||||||
|
|
||||||
|
wrap-ansi@^8.1.0:
|
||||||
|
version "8.1.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214"
|
||||||
|
integrity sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==
|
||||||
|
dependencies:
|
||||||
|
ansi-styles "^6.1.0"
|
||||||
|
string-width "^5.0.1"
|
||||||
|
strip-ansi "^7.0.1"
|
||||||
|
|
||||||
wrappy@1:
|
wrappy@1:
|
||||||
version "1.0.2"
|
version "1.0.2"
|
||||||
resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f"
|
resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f"
|
||||||
|
|||||||
@@ -31,6 +31,7 @@
|
|||||||
"campaigns.confirmDelete": "Delete {name}",
|
"campaigns.confirmDelete": "Delete {name}",
|
||||||
"campaigns.confirmSchedule": "This campaign will start automatically at the scheduled date and time. Schedule now?",
|
"campaigns.confirmSchedule": "This campaign will start automatically at the scheduled date and time. Schedule now?",
|
||||||
"campaigns.confirmSwitchFormat": "The content may lose formatting. Continue?",
|
"campaigns.confirmSwitchFormat": "The content may lose formatting. Continue?",
|
||||||
|
"campaigns.confirmApplyVisualTemplate": "Apply template body overriding current change. Continue?",
|
||||||
"campaigns.content": "Content",
|
"campaigns.content": "Content",
|
||||||
"campaigns.contentHelp": "Content here",
|
"campaigns.contentHelp": "Content here",
|
||||||
"campaigns.continue": "Continue",
|
"campaigns.continue": "Continue",
|
||||||
@@ -73,6 +74,8 @@
|
|||||||
"campaigns.rawHTML": "Raw HTML",
|
"campaigns.rawHTML": "Raw HTML",
|
||||||
"campaigns.removeAltText": "Remove alternate plain text message",
|
"campaigns.removeAltText": "Remove alternate plain text message",
|
||||||
"campaigns.richText": "Rich text",
|
"campaigns.richText": "Rich text",
|
||||||
|
"campaigns.visual": "Visual",
|
||||||
|
"campaigns.format": "Format",
|
||||||
"campaigns.schedule": "Schedule campaign",
|
"campaigns.schedule": "Schedule campaign",
|
||||||
"campaigns.scheduled": "Scheduled",
|
"campaigns.scheduled": "Scheduled",
|
||||||
"campaigns.send": "Send",
|
"campaigns.send": "Send",
|
||||||
@@ -235,6 +238,7 @@
|
|||||||
"globals.terms.tags": "Tags",
|
"globals.terms.tags": "Tags",
|
||||||
"globals.terms.template": "Template | Templates",
|
"globals.terms.template": "Template | Templates",
|
||||||
"globals.terms.templates": "Templates",
|
"globals.terms.templates": "Templates",
|
||||||
|
"globals.terms.baseTemplate": "Base template",
|
||||||
"globals.terms.tx": "Transactional | Transactional",
|
"globals.terms.tx": "Transactional | Transactional",
|
||||||
"globals.terms.user": "User | Users",
|
"globals.terms.user": "User | Users",
|
||||||
"globals.terms.users": "Users",
|
"globals.terms.users": "Users",
|
||||||
@@ -605,6 +609,9 @@
|
|||||||
"templates.preview": "Preview",
|
"templates.preview": "Preview",
|
||||||
"templates.rawHTML": "Raw HTML",
|
"templates.rawHTML": "Raw HTML",
|
||||||
"templates.subject": "Subject",
|
"templates.subject": "Subject",
|
||||||
|
"templates.typeCampaignHTML": "Campaign (HTML)",
|
||||||
|
"templates.typeCampaignVisual": "Campaign (Visual)",
|
||||||
|
"templates.typeTransactional": "Transactional",
|
||||||
"users.apiOneTimeToken": "Copy the API access token now. It will not be shown again.",
|
"users.apiOneTimeToken": "Copy the API access token now. It will not be shown again.",
|
||||||
"users.cantDeleteRole": "Cannot delete role that is in use.",
|
"users.cantDeleteRole": "Cannot delete role that is in use.",
|
||||||
"users.firstTime": "This is a fresh install. Pick a username and password for the Super Admin account.",
|
"users.firstTime": "This is a fresh install. Pick a username and password for the Super Admin account.",
|
||||||
|
|||||||
@@ -125,9 +125,26 @@ func (c *Core) getCampaign(id int, uuid, archiveSlug string, tplType string) (mo
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetCampaignForPreview retrieves a campaign with a template body.
|
// GetCampaignForPreview retrieves a campaign with a template body.
|
||||||
func (c *Core) GetCampaignForPreview(id, tplID int) (models.Campaign, error) {
|
func (c *Core) GetCampaignForPreview(id int) (models.Campaign, error) {
|
||||||
var out models.Campaign
|
var out models.Campaign
|
||||||
if err := c.q.GetCampaignForPreview.Get(&out, id, tplID); err != nil {
|
if err := c.q.GetCampaignForPreview.Get(&out, id); err != nil {
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return models.Campaign{}, echo.NewHTTPError(http.StatusBadRequest,
|
||||||
|
c.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.campaign}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
c.log.Printf("error fetching campaign: %v", err)
|
||||||
|
return models.Campaign{}, echo.NewHTTPError(http.StatusInternalServerError,
|
||||||
|
c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.campaign}", "error", pqErrMsg(err)))
|
||||||
|
}
|
||||||
|
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCampaignForPreview retrieves a campaign with a template body.
|
||||||
|
func (c *Core) GetCampaignForPreviewWithTemplate(id int, tplID *int) (models.Campaign, error) {
|
||||||
|
var out models.Campaign
|
||||||
|
if err := c.q.GetCampaignForPreviewWithTemplate.Get(&out, id, tplID); err != nil {
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
return models.Campaign{}, echo.NewHTTPError(http.StatusBadRequest,
|
return models.Campaign{}, echo.NewHTTPError(http.StatusBadRequest,
|
||||||
c.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.campaign}"))
|
c.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.campaign}"))
|
||||||
@@ -182,13 +199,14 @@ func (c *Core) CreateCampaign(o models.Campaign, listIDs []int, mediaIDs []int)
|
|||||||
o.Headers,
|
o.Headers,
|
||||||
pq.StringArray(normalizeTags(o.Tags)),
|
pq.StringArray(normalizeTags(o.Tags)),
|
||||||
o.Messenger,
|
o.Messenger,
|
||||||
o.TemplateID,
|
o.TemplateID.Int64,
|
||||||
pq.Array(listIDs),
|
pq.Array(listIDs),
|
||||||
o.Archive,
|
o.Archive,
|
||||||
o.ArchiveSlug,
|
o.ArchiveSlug,
|
||||||
o.ArchiveTemplateID,
|
o.ArchiveTemplateID.Int64,
|
||||||
o.ArchiveMeta,
|
o.ArchiveMeta,
|
||||||
pq.Array(mediaIDs),
|
pq.Array(mediaIDs),
|
||||||
|
o.BodySource,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
return models.Campaign{}, echo.NewHTTPError(http.StatusBadRequest, c.i18n.T("campaigns.noSubs"))
|
return models.Campaign{}, echo.NewHTTPError(http.StatusBadRequest, c.i18n.T("campaigns.noSubs"))
|
||||||
@@ -226,7 +244,8 @@ func (c *Core) UpdateCampaign(id int, o models.Campaign, listIDs []int, mediaIDs
|
|||||||
o.ArchiveSlug,
|
o.ArchiveSlug,
|
||||||
o.ArchiveTemplateID,
|
o.ArchiveTemplateID,
|
||||||
o.ArchiveMeta,
|
o.ArchiveMeta,
|
||||||
pq.Array(mediaIDs))
|
pq.Array(mediaIDs),
|
||||||
|
o.BodySource)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.log.Printf("error updating campaign: %v", err)
|
c.log.Printf("error updating campaign: %v", err)
|
||||||
return models.Campaign{}, echo.NewHTTPError(http.StatusInternalServerError,
|
return models.Campaign{}, echo.NewHTTPError(http.StatusInternalServerError,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
|
|
||||||
"github.com/knadh/listmonk/models"
|
"github.com/knadh/listmonk/models"
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v4"
|
||||||
|
null "gopkg.in/volatiletech/null.v6"
|
||||||
)
|
)
|
||||||
|
|
||||||
// GetTemplates retrieves all templates.
|
// GetTemplates retrieves all templates.
|
||||||
@@ -36,9 +37,9 @@ func (c *Core) GetTemplate(id int, noBody bool) (models.Template, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// CreateTemplate creates a new template.
|
// CreateTemplate creates a new template.
|
||||||
func (c *Core) CreateTemplate(name, typ, subject string, body []byte) (models.Template, error) {
|
func (c *Core) CreateTemplate(name, typ, subject string, body []byte, bodySource null.String) (models.Template, error) {
|
||||||
var newID int
|
var newID int
|
||||||
if err := c.q.CreateTemplate.Get(&newID, name, typ, subject, body); err != nil {
|
if err := c.q.CreateTemplate.Get(&newID, name, typ, subject, body, bodySource); err != nil {
|
||||||
return models.Template{}, echo.NewHTTPError(http.StatusInternalServerError,
|
return models.Template{}, echo.NewHTTPError(http.StatusInternalServerError,
|
||||||
c.i18n.Ts("globals.messages.errorCreating", "name", "{globals.terms.template}", "error", pqErrMsg(err)))
|
c.i18n.Ts("globals.messages.errorCreating", "name", "{globals.terms.template}", "error", pqErrMsg(err)))
|
||||||
}
|
}
|
||||||
@@ -47,8 +48,8 @@ func (c *Core) CreateTemplate(name, typ, subject string, body []byte) (models.Te
|
|||||||
}
|
}
|
||||||
|
|
||||||
// UpdateTemplate updates a given template.
|
// UpdateTemplate updates a given template.
|
||||||
func (c *Core) UpdateTemplate(id int, name, subject string, body []byte) (models.Template, error) {
|
func (c *Core) UpdateTemplate(id int, name, subject string, body []byte, bodySource null.String) (models.Template, error) {
|
||||||
res, err := c.q.UpdateTemplate.Exec(id, name, subject, body)
|
res, err := c.q.UpdateTemplate.Exec(id, name, subject, body, bodySource)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return models.Template{}, echo.NewHTTPError(http.StatusInternalServerError,
|
return models.Template{}, echo.NewHTTPError(http.StatusInternalServerError,
|
||||||
c.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.template}", "error", pqErrMsg(err)))
|
c.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.template}", "error", pqErrMsg(err)))
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ const (
|
|||||||
CampaignContentTypeHTML = "html"
|
CampaignContentTypeHTML = "html"
|
||||||
CampaignContentTypeMarkdown = "markdown"
|
CampaignContentTypeMarkdown = "markdown"
|
||||||
CampaignContentTypePlain = "plain"
|
CampaignContentTypePlain = "plain"
|
||||||
|
CampaignContentTypeVisual = "visual"
|
||||||
|
|
||||||
// List.
|
// List.
|
||||||
ListTypePrivate = "private"
|
ListTypePrivate = "private"
|
||||||
@@ -78,8 +79,9 @@ const (
|
|||||||
BounceTypeComplaint = "complaint"
|
BounceTypeComplaint = "complaint"
|
||||||
|
|
||||||
// Templates.
|
// Templates.
|
||||||
TemplateTypeCampaign = "campaign"
|
TemplateTypeCampaign = "campaign"
|
||||||
TemplateTypeTx = "tx"
|
TemplateTypeCampaignVisual = "campaign_visual"
|
||||||
|
TemplateTypeTx = "tx"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Headers represents an array of string maps used to represent SMTP, HTTP headers etc.
|
// Headers represents an array of string maps used to represent SMTP, HTTP headers etc.
|
||||||
@@ -225,17 +227,18 @@ type Campaign struct {
|
|||||||
Subject string `db:"subject" json:"subject"`
|
Subject string `db:"subject" json:"subject"`
|
||||||
FromEmail string `db:"from_email" json:"from_email"`
|
FromEmail string `db:"from_email" json:"from_email"`
|
||||||
Body string `db:"body" json:"body"`
|
Body string `db:"body" json:"body"`
|
||||||
|
BodySource null.String `db:"body_source" json:"body_source"`
|
||||||
AltBody null.String `db:"altbody" json:"altbody"`
|
AltBody null.String `db:"altbody" json:"altbody"`
|
||||||
SendAt null.Time `db:"send_at" json:"send_at"`
|
SendAt null.Time `db:"send_at" json:"send_at"`
|
||||||
Status string `db:"status" json:"status"`
|
Status string `db:"status" json:"status"`
|
||||||
ContentType string `db:"content_type" json:"content_type"`
|
ContentType string `db:"content_type" json:"content_type"`
|
||||||
Tags pq.StringArray `db:"tags" json:"tags"`
|
Tags pq.StringArray `db:"tags" json:"tags"`
|
||||||
Headers Headers `db:"headers" json:"headers"`
|
Headers Headers `db:"headers" json:"headers"`
|
||||||
TemplateID int `db:"template_id" json:"template_id"`
|
TemplateID null.Int64 `db:"template_id" json:"template_id"`
|
||||||
Messenger string `db:"messenger" json:"messenger"`
|
Messenger string `db:"messenger" json:"messenger"`
|
||||||
Archive bool `db:"archive" json:"archive"`
|
Archive bool `db:"archive" json:"archive"`
|
||||||
ArchiveSlug null.String `db:"archive_slug" json:"archive_slug"`
|
ArchiveSlug null.String `db:"archive_slug" json:"archive_slug"`
|
||||||
ArchiveTemplateID int `db:"archive_template_id" json:"archive_template_id"`
|
ArchiveTemplateID null.Int64 `db:"archive_template_id" json:"archive_template_id"`
|
||||||
ArchiveMeta json.RawMessage `db:"archive_meta" json:"archive_meta"`
|
ArchiveMeta json.RawMessage `db:"archive_meta" json:"archive_meta"`
|
||||||
|
|
||||||
// TemplateBody is joined in from templates by the next-campaigns query.
|
// TemplateBody is joined in from templates by the next-campaigns query.
|
||||||
@@ -308,10 +311,11 @@ type Template struct {
|
|||||||
|
|
||||||
Name string `db:"name" json:"name"`
|
Name string `db:"name" json:"name"`
|
||||||
// Subject is only for type=tx.
|
// Subject is only for type=tx.
|
||||||
Subject string `db:"subject" json:"subject"`
|
Subject string `db:"subject" json:"subject"`
|
||||||
Type string `db:"type" json:"type"`
|
Type string `db:"type" json:"type"`
|
||||||
Body string `db:"body" json:"body,omitempty"`
|
Body string `db:"body" json:"body,omitempty"`
|
||||||
IsDefault bool `db:"is_default" json:"is_default"`
|
BodySource null.String `db:"body_source" json:"body_source,omitempty"`
|
||||||
|
IsDefault bool `db:"is_default" json:"is_default"`
|
||||||
|
|
||||||
// Only relevant to tx (transactional) templates.
|
// Only relevant to tx (transactional) templates.
|
||||||
SubjectTpl *txttpl.Template `json:"-"`
|
SubjectTpl *txttpl.Template `json:"-"`
|
||||||
@@ -531,6 +535,11 @@ func (c *Campaign) CompileTemplate(f template.FuncMap) error {
|
|||||||
|
|
||||||
// Compile the base template.
|
// Compile the base template.
|
||||||
body := c.TemplateBody
|
body := c.TemplateBody
|
||||||
|
|
||||||
|
if body == "" {
|
||||||
|
body = `{{ template "content" . }}`
|
||||||
|
}
|
||||||
|
|
||||||
for _, r := range regTplFuncs {
|
for _, r := range regTplFuncs {
|
||||||
body = r.regExp.ReplaceAllString(body, r.replace)
|
body = r.regExp.ReplaceAllString(body, r.replace)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,14 +58,15 @@ type Queries struct {
|
|||||||
UpdateListsDate *sqlx.Stmt `query:"update-lists-date"`
|
UpdateListsDate *sqlx.Stmt `query:"update-lists-date"`
|
||||||
DeleteLists *sqlx.Stmt `query:"delete-lists"`
|
DeleteLists *sqlx.Stmt `query:"delete-lists"`
|
||||||
|
|
||||||
CreateCampaign *sqlx.Stmt `query:"create-campaign"`
|
CreateCampaign *sqlx.Stmt `query:"create-campaign"`
|
||||||
QueryCampaigns string `query:"query-campaigns"`
|
QueryCampaigns string `query:"query-campaigns"`
|
||||||
GetCampaign *sqlx.Stmt `query:"get-campaign"`
|
GetCampaign *sqlx.Stmt `query:"get-campaign"`
|
||||||
GetCampaignForPreview *sqlx.Stmt `query:"get-campaign-for-preview"`
|
GetCampaignForPreview *sqlx.Stmt `query:"get-campaign-for-preview"`
|
||||||
GetCampaignStats *sqlx.Stmt `query:"get-campaign-stats"`
|
GetCampaignForPreviewWithTemplate *sqlx.Stmt `query:"get-campaign-for-preview-with-tpl"`
|
||||||
GetCampaignStatus *sqlx.Stmt `query:"get-campaign-status"`
|
GetCampaignStats *sqlx.Stmt `query:"get-campaign-stats"`
|
||||||
GetArchivedCampaigns *sqlx.Stmt `query:"get-archived-campaigns"`
|
GetCampaignStatus *sqlx.Stmt `query:"get-campaign-status"`
|
||||||
CampaignHasLists *sqlx.Stmt `query:"campaign-has-lists"`
|
GetArchivedCampaigns *sqlx.Stmt `query:"get-archived-campaigns"`
|
||||||
|
CampaignHasLists *sqlx.Stmt `query:"campaign-has-lists"`
|
||||||
|
|
||||||
// These two queries are read as strings and based on settings.individual_tracking=on/off,
|
// These two queries are read as strings and based on settings.individual_tracking=on/off,
|
||||||
// are interpolated and copied to view and click counts. Same query, different tables.
|
// are interpolated and copied to view and click counts. Same query, different tables.
|
||||||
|
|||||||
81
queries.sql
81
queries.sql
@@ -501,8 +501,21 @@ DELETE FROM lists WHERE id = ALL($1);
|
|||||||
-- name: create-campaign
|
-- name: create-campaign
|
||||||
-- This creates the campaign and inserts campaign_lists relationships.
|
-- This creates the campaign and inserts campaign_lists relationships.
|
||||||
WITH tpl AS (
|
WITH tpl AS (
|
||||||
-- If there's no template_id given, use the default template.
|
-- Select the template for the given template ID or use the default template.
|
||||||
SELECT (CASE WHEN $13 = 0 THEN id ELSE $13 END) AS id FROM templates WHERE is_default IS TRUE
|
SELECT id,
|
||||||
|
CASE WHEN type = 'campaign_visual' THEN body ELSE '' END AS body,
|
||||||
|
CASE WHEN type = 'campaign_visual' THEN body_source ELSE '' END AS body_source,
|
||||||
|
CASE
|
||||||
|
WHEN type = 'campaign_visual' THEN 'visual'
|
||||||
|
ELSE 'richtext'
|
||||||
|
END AS content_type
|
||||||
|
FROM templates
|
||||||
|
WHERE
|
||||||
|
CASE
|
||||||
|
WHEN $13::INT IS NOT NULL AND $13::INT != 0 THEN id = $13::INT
|
||||||
|
WHEN $13::INT = 0 THEN is_default = TRUE
|
||||||
|
END
|
||||||
|
LIMIT 1
|
||||||
),
|
),
|
||||||
counts AS (
|
counts AS (
|
||||||
-- This is going to be slow on large databases.
|
-- This is going to be slow on large databases.
|
||||||
@@ -519,11 +532,24 @@ counts AS (
|
|||||||
)
|
)
|
||||||
),
|
),
|
||||||
camp AS (
|
camp AS (
|
||||||
INSERT INTO campaigns (uuid, type, name, subject, from_email, body, altbody, content_type, send_at, headers, tags, messenger, template_id, to_send, max_subscriber_id, archive, archive_slug, archive_template_id, archive_meta)
|
INSERT INTO campaigns (uuid, type, name, subject, from_email, body, altbody,
|
||||||
SELECT $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12,
|
content_type, send_at, headers, tags, messenger, template_id, to_send,
|
||||||
(SELECT id FROM tpl), (SELECT to_send FROM counts),
|
max_subscriber_id, archive, archive_slug, archive_template_id, archive_meta, body_source)
|
||||||
(SELECT max_sub_id FROM counts), $15, $16,
|
SELECT $1, $2, $3, $4, $5,
|
||||||
(CASE WHEN $17 = 0 THEN (SELECT id FROM tpl) ELSE $17 END), $18
|
-- Body
|
||||||
|
COALESCE(NULLIF($6, ''), (SELECT body FROM tpl), ''),
|
||||||
|
$7,
|
||||||
|
COALESCE(NULLIF($8, ''), (SELECT content_type FROM tpl), 'richtext')::content_type,
|
||||||
|
$9, $10, $11, $12,
|
||||||
|
(SELECT id FROM tpl),
|
||||||
|
(SELECT to_send FROM counts),
|
||||||
|
(SELECT max_sub_id FROM counts),
|
||||||
|
$15, $16,
|
||||||
|
-- Template ID
|
||||||
|
COALESCE(NULLIF($17, 0), (SELECT id FROM tpl)),
|
||||||
|
$18,
|
||||||
|
-- Body source
|
||||||
|
COALESCE(NULLIF($20, ''), (SELECT body_source FROM tpl))
|
||||||
RETURNING id
|
RETURNING id
|
||||||
),
|
),
|
||||||
med AS (
|
med AS (
|
||||||
@@ -571,7 +597,7 @@ ORDER BY %order% OFFSET $7 LIMIT (CASE WHEN $8 < 1 THEN NULL ELSE $8 END);
|
|||||||
|
|
||||||
-- name: get-campaign
|
-- name: get-campaign
|
||||||
SELECT campaigns.*,
|
SELECT campaigns.*,
|
||||||
COALESCE(templates.body, (SELECT body FROM templates WHERE is_default = true LIMIT 1)) AS template_body
|
COALESCE(templates.body, (SELECT body FROM templates WHERE is_default = true LIMIT 1), '') AS template_body
|
||||||
FROM campaigns
|
FROM campaigns
|
||||||
LEFT JOIN templates ON (
|
LEFT JOIN templates ON (
|
||||||
CASE WHEN $4 = 'default' THEN templates.id = campaigns.template_id
|
CASE WHEN $4 = 'default' THEN templates.id = campaigns.template_id
|
||||||
@@ -585,7 +611,7 @@ SELECT campaigns.*,
|
|||||||
|
|
||||||
-- name: get-archived-campaigns
|
-- name: get-archived-campaigns
|
||||||
SELECT COUNT(*) OVER () AS total, campaigns.*,
|
SELECT COUNT(*) OVER () AS total, campaigns.*,
|
||||||
COALESCE(templates.body, (SELECT body FROM templates WHERE is_default = true LIMIT 1)) AS template_body
|
COALESCE(templates.body, (SELECT body FROM templates WHERE is_default = true LIMIT 1), '') AS template_body
|
||||||
FROM campaigns
|
FROM campaigns
|
||||||
LEFT JOIN templates ON (
|
LEFT JOIN templates ON (
|
||||||
CASE WHEN $3 = 'default' THEN templates.id = campaigns.template_id
|
CASE WHEN $3 = 'default' THEN templates.id = campaigns.template_id
|
||||||
@@ -637,7 +663,7 @@ LEFT JOIN bounces AS b ON (b.campaign_id = id)
|
|||||||
ORDER BY ARRAY_POSITION($1, id);
|
ORDER BY ARRAY_POSITION($1, id);
|
||||||
|
|
||||||
-- name: get-campaign-for-preview
|
-- name: get-campaign-for-preview
|
||||||
SELECT campaigns.*, COALESCE(templates.body, (SELECT body FROM templates WHERE is_default = true LIMIT 1)) AS template_body,
|
SELECT campaigns.*, COALESCE(templates.body, '') AS template_body,
|
||||||
(
|
(
|
||||||
SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(l)), '[]') FROM (
|
SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(l)), '[]') FROM (
|
||||||
SELECT COALESCE(campaign_lists.list_id, 0) AS id,
|
SELECT COALESCE(campaign_lists.list_id, 0) AS id,
|
||||||
@@ -646,7 +672,20 @@ SELECT campaigns.*, COALESCE(templates.body, (SELECT body FROM templates WHERE i
|
|||||||
) l
|
) l
|
||||||
) AS lists
|
) AS lists
|
||||||
FROM campaigns
|
FROM campaigns
|
||||||
LEFT JOIN templates ON (templates.id = (CASE WHEN $2=0 THEN campaigns.template_id ELSE $2 END))
|
LEFT JOIN templates ON templates.id = campaigns.template_id
|
||||||
|
WHERE campaigns.id = $1;
|
||||||
|
|
||||||
|
-- name: get-campaign-for-preview-with-tpl
|
||||||
|
SELECT campaigns.*, COALESCE(templates.body, '') AS template_body,
|
||||||
|
(
|
||||||
|
SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(l)), '[]') FROM (
|
||||||
|
SELECT COALESCE(campaign_lists.list_id, 0) AS id,
|
||||||
|
campaign_lists.list_name AS name
|
||||||
|
FROM campaign_lists WHERE campaign_lists.campaign_id = campaigns.id
|
||||||
|
) l
|
||||||
|
) AS lists
|
||||||
|
FROM campaigns
|
||||||
|
LEFT JOIN templates ON templates.id = $2
|
||||||
WHERE campaigns.id = $1;
|
WHERE campaigns.id = $1;
|
||||||
|
|
||||||
-- name: get-campaign-status
|
-- name: get-campaign-status
|
||||||
@@ -667,7 +706,7 @@ SELECT EXISTS (
|
|||||||
-- a campaign. This is used to fetch and slice subscribers for the campaign in next-campaign-subscribers.
|
-- a campaign. This is used to fetch and slice subscribers for the campaign in next-campaign-subscribers.
|
||||||
WITH camps AS (
|
WITH camps AS (
|
||||||
-- Get all running campaigns and their template bodies (if the template's deleted, the default template body instead)
|
-- Get all running campaigns and their template bodies (if the template's deleted, the default template body instead)
|
||||||
SELECT campaigns.*, COALESCE(templates.body, (SELECT body FROM templates WHERE is_default = true LIMIT 1)) AS template_body
|
SELECT campaigns.*, COALESCE(templates.body, (SELECT body FROM templates WHERE is_default = true LIMIT 1), '') AS template_body
|
||||||
FROM campaigns
|
FROM campaigns
|
||||||
LEFT JOIN templates ON (templates.id = campaigns.template_id)
|
LEFT JOIN templates ON (templates.id = campaigns.template_id)
|
||||||
WHERE (status='running' OR (status='scheduled' AND NOW() >= campaigns.send_at))
|
WHERE (status='running' OR (status='scheduled' AND NOW() >= campaigns.send_at))
|
||||||
@@ -865,6 +904,7 @@ WITH camp AS (
|
|||||||
archive_slug=$15,
|
archive_slug=$15,
|
||||||
archive_template_id=$16,
|
archive_template_id=$16,
|
||||||
archive_meta=$17,
|
archive_meta=$17,
|
||||||
|
body_source=$19,
|
||||||
updated_at=NOW()
|
updated_at=NOW()
|
||||||
WHERE id = $1 RETURNING id
|
WHERE id = $1 RETURNING id
|
||||||
),
|
),
|
||||||
@@ -927,26 +967,29 @@ INSERT INTO campaign_views (campaign_id, subscriber_id)
|
|||||||
|
|
||||||
-- templates
|
-- templates
|
||||||
-- name: get-templates
|
-- name: get-templates
|
||||||
-- Only if the second param ($2) is true, body is returned.
|
-- Only if the second param ($2 - noBody) is true, body and body_source is returned.
|
||||||
SELECT id, name, type, subject, (CASE WHEN $2 = false THEN body ELSE '' END) as body,
|
SELECT id, name, type, subject,
|
||||||
|
(CASE WHEN $2 = false THEN body ELSE '' END) as body,
|
||||||
|
(CASE WHEN $2 = false THEN body_source ELSE NULL END) as body_source,
|
||||||
is_default, created_at, updated_at
|
is_default, created_at, updated_at
|
||||||
FROM templates WHERE ($1 = 0 OR id = $1) AND ($3 = '' OR type = $3::template_type)
|
FROM templates WHERE ($1 = 0 OR id = $1) AND ($3 = '' OR type = $3::template_type)
|
||||||
ORDER BY created_at;
|
ORDER BY created_at;
|
||||||
|
|
||||||
-- name: create-template
|
-- name: create-template
|
||||||
INSERT INTO templates (name, type, subject, body) VALUES($1, $2, $3, $4) RETURNING id;
|
INSERT INTO templates (name, type, subject, body, body_source) VALUES($1, $2, $3, $4, $5) RETURNING id;
|
||||||
|
|
||||||
-- name: update-template
|
-- name: update-template
|
||||||
UPDATE templates SET
|
UPDATE templates SET
|
||||||
name=(CASE WHEN $2 != '' THEN $2 ELSE name END),
|
name=(CASE WHEN $2 != '' THEN $2 ELSE name END),
|
||||||
subject=(CASE WHEN $3 != '' THEN $3 ELSE name END),
|
subject=(CASE WHEN $3 != '' THEN $3 ELSE name END),
|
||||||
body=(CASE WHEN $4 != '' THEN $4 ELSE body END),
|
body=(CASE WHEN $4 != '' THEN $4 ELSE body END),
|
||||||
|
body_source=(CASE WHEN $5 != '' THEN $5 ELSE body_source END),
|
||||||
updated_at=NOW()
|
updated_at=NOW()
|
||||||
WHERE id = $1;
|
WHERE id = $1;
|
||||||
|
|
||||||
-- name: set-default-template
|
-- name: set-default-template
|
||||||
WITH u AS (
|
WITH u AS (
|
||||||
UPDATE templates SET is_default=true WHERE id=$1 AND type='campaign' RETURNING id
|
UPDATE templates SET is_default=true WHERE id=$1 AND (type='campaign' OR type='campaign_visual') RETURNING id
|
||||||
)
|
)
|
||||||
UPDATE templates SET is_default=false WHERE id != $1;
|
UPDATE templates SET is_default=false WHERE id != $1;
|
||||||
|
|
||||||
@@ -957,7 +1000,7 @@ WITH tpl AS (
|
|||||||
DELETE FROM templates WHERE id = $1 AND (SELECT COUNT(id) FROM templates) > 1 AND is_default = false RETURNING id
|
DELETE FROM templates WHERE id = $1 AND (SELECT COUNT(id) FROM templates) > 1 AND is_default = false RETURNING id
|
||||||
),
|
),
|
||||||
def AS (
|
def AS (
|
||||||
SELECT id FROM templates WHERE is_default = true AND type='campaign' LIMIT 1
|
SELECT id FROM templates WHERE is_default = true AND (type='campaign' OR type='campaign_visual') LIMIT 1
|
||||||
),
|
),
|
||||||
up AS (
|
up AS (
|
||||||
UPDATE campaigns SET template_id = (SELECT id FROM def) WHERE (SELECT id FROM tpl) > 0 AND template_id = $1
|
UPDATE campaigns SET template_id = (SELECT id FROM def) WHERE (SELECT id FROM tpl) > 0 AND template_id = $1
|
||||||
@@ -1158,7 +1201,7 @@ lp AS (
|
|||||||
LEFT JOIN lists cl ON cr.list_id = cl.id
|
LEFT JOIN lists cl ON cr.list_id = cl.id
|
||||||
GROUP BY lr.id
|
GROUP BY lr.id
|
||||||
)
|
)
|
||||||
SELECT
|
SELECT
|
||||||
users.*,
|
users.*,
|
||||||
ur.id AS user_role_id,
|
ur.id AS user_role_id,
|
||||||
ur.name AS user_role_name,
|
ur.name AS user_role_name,
|
||||||
@@ -1184,7 +1227,7 @@ WITH sel AS (
|
|||||||
END
|
END
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
SELECT
|
SELECT
|
||||||
sel.*,
|
sel.*,
|
||||||
ur.id AS user_role_id,
|
ur.id AS user_role_id,
|
||||||
ur.name AS user_role_name,
|
ur.name AS user_role_name,
|
||||||
|
|||||||
12
schema.sql
12
schema.sql
@@ -4,9 +4,9 @@ DROP TYPE IF EXISTS subscriber_status CASCADE; CREATE TYPE subscriber_status AS
|
|||||||
DROP TYPE IF EXISTS subscription_status CASCADE; CREATE TYPE subscription_status AS ENUM ('unconfirmed', 'confirmed', 'unsubscribed');
|
DROP TYPE IF EXISTS subscription_status CASCADE; CREATE TYPE subscription_status AS ENUM ('unconfirmed', 'confirmed', 'unsubscribed');
|
||||||
DROP TYPE IF EXISTS campaign_status CASCADE; CREATE TYPE campaign_status AS ENUM ('draft', 'running', 'scheduled', 'paused', 'cancelled', 'finished');
|
DROP TYPE IF EXISTS campaign_status CASCADE; CREATE TYPE campaign_status AS ENUM ('draft', 'running', 'scheduled', 'paused', 'cancelled', 'finished');
|
||||||
DROP TYPE IF EXISTS campaign_type CASCADE; CREATE TYPE campaign_type AS ENUM ('regular', 'optin');
|
DROP TYPE IF EXISTS campaign_type CASCADE; CREATE TYPE campaign_type AS ENUM ('regular', 'optin');
|
||||||
DROP TYPE IF EXISTS content_type CASCADE; CREATE TYPE content_type AS ENUM ('richtext', 'html', 'plain', 'markdown');
|
DROP TYPE IF EXISTS content_type CASCADE; CREATE TYPE content_type AS ENUM ('richtext', 'html', 'plain', 'markdown', 'visual');
|
||||||
DROP TYPE IF EXISTS bounce_type CASCADE; CREATE TYPE bounce_type AS ENUM ('soft', 'hard', 'complaint');
|
DROP TYPE IF EXISTS bounce_type CASCADE; CREATE TYPE bounce_type AS ENUM ('soft', 'hard', 'complaint');
|
||||||
DROP TYPE IF EXISTS template_type CASCADE; CREATE TYPE template_type AS ENUM ('campaign', 'tx');
|
DROP TYPE IF EXISTS template_type CASCADE; CREATE TYPE template_type AS ENUM ('campaign', 'campaign_visual', 'tx');
|
||||||
DROP TYPE IF EXISTS user_type CASCADE; CREATE TYPE user_type AS ENUM ('user', 'api');
|
DROP TYPE IF EXISTS user_type CASCADE; CREATE TYPE user_type AS ENUM ('user', 'api');
|
||||||
DROP TYPE IF EXISTS user_status CASCADE; CREATE TYPE user_status AS ENUM ('enabled', 'disabled');
|
DROP TYPE IF EXISTS user_status CASCADE; CREATE TYPE user_status AS ENUM ('enabled', 'disabled');
|
||||||
DROP TYPE IF EXISTS role_type CASCADE; CREATE TYPE role_type AS ENUM ('user', 'list');
|
DROP TYPE IF EXISTS role_type CASCADE; CREATE TYPE role_type AS ENUM ('user', 'list');
|
||||||
@@ -77,6 +77,7 @@ CREATE TABLE templates (
|
|||||||
type template_type NOT NULL DEFAULT 'campaign',
|
type template_type NOT NULL DEFAULT 'campaign',
|
||||||
subject TEXT NOT NULL,
|
subject TEXT NOT NULL,
|
||||||
body TEXT NOT NULL,
|
body TEXT NOT NULL,
|
||||||
|
body_source TEXT NULL,
|
||||||
is_default BOOLEAN NOT NULL DEFAULT false,
|
is_default BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
|
||||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
@@ -94,6 +95,7 @@ CREATE TABLE campaigns (
|
|||||||
subject TEXT NOT NULL,
|
subject TEXT NOT NULL,
|
||||||
from_email TEXT NOT NULL,
|
from_email TEXT NOT NULL,
|
||||||
body TEXT NOT NULL,
|
body TEXT NOT NULL,
|
||||||
|
body_source TEXT NULL,
|
||||||
altbody TEXT NULL,
|
altbody TEXT NULL,
|
||||||
content_type content_type NOT NULL DEFAULT 'richtext',
|
content_type content_type NOT NULL DEFAULT 'richtext',
|
||||||
send_at TIMESTAMP WITH TIME ZONE,
|
send_at TIMESTAMP WITH TIME ZONE,
|
||||||
@@ -105,9 +107,9 @@ CREATE TABLE campaigns (
|
|||||||
-- For opt-in campaigns, this will be 'unsubscribed'.
|
-- For opt-in campaigns, this will be 'unsubscribed'.
|
||||||
type campaign_type DEFAULT 'regular',
|
type campaign_type DEFAULT 'regular',
|
||||||
|
|
||||||
-- The ID of the messenger backend used to send this campaign.
|
-- The ID of the messenger backend used to send this campaign.
|
||||||
messenger TEXT NOT NULL,
|
messenger TEXT NOT NULL,
|
||||||
template_id INTEGER REFERENCES templates(id) ON DELETE SET DEFAULT DEFAULT 1,
|
template_id INTEGER REFERENCES templates(id) ON DELETE SET NULL,
|
||||||
|
|
||||||
-- Progress and stats.
|
-- Progress and stats.
|
||||||
to_send INT NOT NULL DEFAULT 0,
|
to_send INT NOT NULL DEFAULT 0,
|
||||||
@@ -118,7 +120,7 @@ CREATE TABLE campaigns (
|
|||||||
-- Publishing.
|
-- Publishing.
|
||||||
archive BOOLEAN NOT NULL DEFAULT false,
|
archive BOOLEAN NOT NULL DEFAULT false,
|
||||||
archive_slug TEXT NULL UNIQUE,
|
archive_slug TEXT NULL UNIQUE,
|
||||||
archive_template_id INTEGER REFERENCES templates(id) ON DELETE SET DEFAULT DEFAULT 1,
|
archive_template_id INTEGER REFERENCES templates(id) ON DELETE SET NULL,
|
||||||
archive_meta JSONB NOT NULL DEFAULT '{}',
|
archive_meta JSONB NOT NULL DEFAULT '{}',
|
||||||
|
|
||||||
started_at TIMESTAMP WITH TIME ZONE,
|
started_at TIMESTAMP WITH TIME ZONE,
|
||||||
|
|||||||
Reference in New Issue
Block a user