feat: Integrate email-builder on campaign/template editor UI and backend.

This commit is contained in:
Vivek R
2024-10-15 22:19:17 +05:30
committed by Kailash Nadh
parent 5a0980e55e
commit ae98280858
20 changed files with 931 additions and 499 deletions

View File

@@ -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)
} }

View File

@@ -28,4 +28,5 @@ module.exports = {
comments: 200, comments: 200,
}], }],
}, },
ignorePatterns: ['src/email-builder.js'],
}; };

View File

@@ -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",

View File

@@ -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') }}
&mdash; &mdash;
@@ -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;
}, },

View File

@@ -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;

View File

@@ -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);

View File

@@ -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;
},
);
}
}, },
}, },
}; };

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

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

View File

@@ -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;
} }
} }
}); });

View File

@@ -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: {

View File

@@ -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
View File

@@ -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"

View File

@@ -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.",

View File

@@ -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,

View File

@@ -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)))

View File

@@ -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)
} }

View File

@@ -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.

View File

@@ -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,

View File

@@ -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,