Improve outreach compose, contact merge, and email assets.
Add a Quill email editor with DB-backed image storage, selectable PCM designs, postcard defaults for addressed contacts, and merge-by-phone/address on the contact form.
This commit is contained in:
@@ -108,7 +108,7 @@
|
||||
<td>{% if event.message %}{{ event.message.contact }}{% else %}—{% endif %}</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr><td colspan="3" class="empty-state">No provider events yet. Configure the SMTP2GO webhook after first send.</td></tr>
|
||||
<tr><td colspan="3" class="empty-state">No webhook events yet. SMTP2GO must POST opens/clicks to <code>/portal/messaging/webhooks/email/</code> (see messaging README). SMTP2GO’s own “Clicked” feed does not fill this table by itself.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -126,7 +126,12 @@
|
||||
<tbody id="recipients-body">
|
||||
{% for message in messages %}
|
||||
<tr data-message-id="{{ message.pk }}">
|
||||
<td>{{ message.contact }}</td>
|
||||
<td>
|
||||
<div>{{ message.contact }}</div>
|
||||
{% if message.destination %}
|
||||
<div class="muted" style="font-size:12px;margin-top:2px">{{ message.destination }}</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td><span class="badge badge-{{ message.status }}">{{ message.get_status_display }}</span></td>
|
||||
<td class="muted">{{ message.provider_message_id|default:"—" }}</td>
|
||||
<td class="muted">{{ message.error|truncatechars:60|default:"—" }}</td>
|
||||
@@ -165,8 +170,11 @@
|
||||
body.innerHTML = '<tr><td colspan="4" class="empty-state">No messages on this campaign.</td></tr>';
|
||||
} else {
|
||||
body.innerHTML = data.messages.map(function (m) {
|
||||
var dest = m.destination
|
||||
? '<div class="muted" style="font-size:12px;margin-top:2px">' + esc(m.destination) + '</div>'
|
||||
: '';
|
||||
return "<tr data-message-id=\"" + esc(m.id) + "\">" +
|
||||
"<td>" + esc(m.contact) + "</td>" +
|
||||
"<td><div>" + esc(m.contact) + "</div>" + dest + "</td>" +
|
||||
"<td><span class=\"badge badge-" + esc(m.status) + "\">" + esc(m.status_display) + "</span></td>" +
|
||||
"<td class=\"muted\">" + esc(m.provider_message_id || "—") + "</td>" +
|
||||
"<td class=\"muted\">" + esc(m.error || "—") + "</td></tr>";
|
||||
|
||||
@@ -1,11 +1,22 @@
|
||||
{% extends "portal_base.html" %}
|
||||
{% block title %}Campaigns · Portal{% endblock %}
|
||||
{% block topbar_title %}Campaign composer{% endblock %}
|
||||
{% block extra_head %}
|
||||
<link href="https://cdn.jsdelivr.net/npm/quill@2.0.3/dist/quill.snow.css" rel="stylesheet">
|
||||
<style>
|
||||
.ql-editor { min-height: 160px; font-family: Georgia, "Times New Roman", serif; font-size: 15px; }
|
||||
.ql-toolbar.ql-snow { border-color: var(--monica-border); border-radius: 4px 4px 0 0; }
|
||||
.ql-container.ql-snow { border-color: var(--monica-border); border-radius: 0 0 4px 4px; background: #fff; }
|
||||
#preview-body img { max-width: 100%; height: auto; }
|
||||
#preview-body { line-height: 1.55; color: #212121; }
|
||||
#email-editor-wrap[hidden], #sms-body-wrap[hidden] { display: none !important; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
{% block portal_content %}
|
||||
<div class="channel-tabs">
|
||||
<a class="active" href="#compose-email">Email</a>
|
||||
<a href="#compose-sms">SMS</a>
|
||||
<a href="{% url 'messaging:postcard_designer' %}">Postcard</a>
|
||||
<div class="channel-tabs" id="compose-channel-tabs">
|
||||
<a class="active" href="#compose-email" data-channel="email">Email</a>
|
||||
<a href="#compose-sms" data-channel="sms">SMS</a>
|
||||
<a href="{% url 'messaging:postcard_designer' %}" data-channel="postcard">Postcard</a>
|
||||
</div>
|
||||
|
||||
<div class="split">
|
||||
@@ -26,7 +37,7 @@
|
||||
<input id="id_name" name="name" type="text" required
|
||||
placeholder="Spring seller tips" value="{{ form_data.name }}">
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="field" id="subject-field">
|
||||
<label for="id_subject">Subject</label>
|
||||
<input id="id_subject" name="subject" type="text"
|
||||
placeholder="A quick tip for sellers this week"
|
||||
@@ -34,26 +45,32 @@
|
||||
oninput="syncCampaignPreview()">
|
||||
<div class="hint">Email only — ignored for SMS / postcard</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="id_body">Body</label>
|
||||
<textarea id="id_body" name="body" style="min-height:140px"
|
||||
<div class="field" id="email-editor-wrap">
|
||||
<label>Body</label>
|
||||
<div id="email-editor"></div>
|
||||
<textarea id="id_body" name="body" hidden>{{ form_data.body }}</textarea>
|
||||
<div class="hint">Bold, fonts, sizes, links, images · merge tags: {first_name}, {last_name}</div>
|
||||
</div>
|
||||
<div class="field" id="sms-body-wrap" hidden>
|
||||
<label for="id_body_sms">Body</label>
|
||||
<textarea id="id_body_sms" style="min-height:140px"
|
||||
placeholder="Hi {first_name}, …"
|
||||
oninput="syncCampaignPreview()">{{ form_data.body }}</textarea>
|
||||
<div class="hint">Merge tags: first_name, last_name, unsubscribe_url · optional for postcard</div>
|
||||
oninput="syncSmsBody()">{{ form_data.body }}</textarea>
|
||||
<div class="hint">Plain text for SMS · keep it short</div>
|
||||
</div>
|
||||
<div class="field" id="postcard-template-field">
|
||||
<label for="id_template_id">Postcard template</label>
|
||||
<label for="id_template_id">Postcard design</label>
|
||||
<select id="id_template_id" name="template_id">
|
||||
<option value="">— Select saved design —</option>
|
||||
{% for t in postcard_templates %}
|
||||
<option value="{{ t.pk }}"{% if form_data.template_id == t.pk|stringformat:"s" %} selected{% endif %}>
|
||||
{{ t.name }} (design {{ t.postcard_front.design_id }})
|
||||
<option value="">— Select a PCM design —</option>
|
||||
{% for d in postcard_designs %}
|
||||
<option value="{{ d.value }}"{% if form_data.template_id == d.value %} selected{% endif %}>
|
||||
{{ d.label }}
|
||||
</option>
|
||||
{% empty %}
|
||||
<option value="" disabled>No templates yet — use Postcard designer</option>
|
||||
<option value="" disabled>No designs yet — open Postcard design</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<div class="hint"><a href="{% url 'messaging:postcard_designer' %}">Open postcard designer</a></div>
|
||||
<div class="hint"><a href="{% url 'messaging:postcard_designer' %}">Open postcard designer</a> to create or edit designs</div>
|
||||
</div>
|
||||
<div class="form-grid cols-2">
|
||||
<div class="field">
|
||||
@@ -83,7 +100,7 @@
|
||||
<div class="muted" id="preview-empty">Preview updates as you type.</div>
|
||||
<div id="preview-content" hidden>
|
||||
<div class="hint" id="preview-subject"></div>
|
||||
<div id="preview-body" style="white-space:pre-wrap;margin-top:8px"></div>
|
||||
<div id="preview-body" style="margin-top:8px"></div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="hint-block">After send → open the campaign report for delivery & engagement.</p>
|
||||
@@ -122,37 +139,172 @@
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block extra_js %}
|
||||
<script src="https://cdn.jsdelivr.net/npm/quill@2.0.3/dist/quill.js"></script>
|
||||
<script>
|
||||
function syncCampaignPreview() {
|
||||
var subject = (document.getElementById('id_subject') || {}).value || '';
|
||||
var body = (document.getElementById('id_body') || {}).value || '';
|
||||
var empty = document.getElementById('preview-empty');
|
||||
var content = document.getElementById('preview-content');
|
||||
var subEl = document.getElementById('preview-subject');
|
||||
var bodyEl = document.getElementById('preview-body');
|
||||
if (!empty || !content) return;
|
||||
if (!subject && !body) {
|
||||
empty.hidden = false;
|
||||
content.hidden = true;
|
||||
return;
|
||||
(function () {
|
||||
var uploadUrl = "{{ image_upload_url|escapejs }}";
|
||||
var csrfToken = (document.querySelector('#campaign-compose [name=csrfmiddlewaretoken]') || {}).value || '';
|
||||
var bodyField = document.getElementById('id_body');
|
||||
var smsField = document.getElementById('id_body_sms');
|
||||
var quill = null;
|
||||
|
||||
function csrfHeader() {
|
||||
return { 'X-CSRFToken': csrfToken };
|
||||
}
|
||||
empty.hidden = true;
|
||||
content.hidden = false;
|
||||
subEl.textContent = subject ? ('Subject: ' + subject) : '';
|
||||
bodyEl.textContent = body;
|
||||
}
|
||||
function syncComposeChannel() {
|
||||
var audience = (document.getElementById('id_audience') || {}).value || '';
|
||||
var isPostcard = audience === 'postcard_opt_in';
|
||||
var tmplField = document.getElementById('postcard-template-field');
|
||||
var body = document.getElementById('id_body');
|
||||
if (tmplField) tmplField.style.display = isPostcard ? '' : 'none';
|
||||
if (body) {
|
||||
if (isPostcard) body.removeAttribute('required');
|
||||
else body.setAttribute('required', 'required');
|
||||
|
||||
function syncBodyFromQuill() {
|
||||
if (!quill || !bodyField) return;
|
||||
var html = quill.root.innerHTML;
|
||||
if (html === '<p><br></p>' || html === '<p></p>') html = '';
|
||||
bodyField.value = html;
|
||||
syncCampaignPreview();
|
||||
}
|
||||
}
|
||||
syncCampaignPreview();
|
||||
syncComposeChannel();
|
||||
|
||||
window.syncSmsBody = function () {
|
||||
if (smsField && bodyField) bodyField.value = smsField.value;
|
||||
syncCampaignPreview();
|
||||
};
|
||||
|
||||
window.syncCampaignPreview = function () {
|
||||
var subject = (document.getElementById('id_subject') || {}).value || '';
|
||||
var audience = (document.getElementById('id_audience') || {}).value || '';
|
||||
var isEmail = audience === 'email_opt_in';
|
||||
var body = '';
|
||||
if (isEmail && quill) {
|
||||
body = quill.root.innerHTML;
|
||||
if (body === '<p><br></p>' || body === '<p></p>') body = '';
|
||||
} else {
|
||||
body = (bodyField && bodyField.value) || '';
|
||||
}
|
||||
var empty = document.getElementById('preview-empty');
|
||||
var content = document.getElementById('preview-content');
|
||||
var subEl = document.getElementById('preview-subject');
|
||||
var bodyEl = document.getElementById('preview-body');
|
||||
if (!empty || !content) return;
|
||||
if (!subject && !body) {
|
||||
empty.hidden = false;
|
||||
content.hidden = true;
|
||||
return;
|
||||
}
|
||||
empty.hidden = true;
|
||||
content.hidden = false;
|
||||
subEl.textContent = subject ? ('Subject: ' + subject) : '';
|
||||
if (isEmail) {
|
||||
bodyEl.style.whiteSpace = 'normal';
|
||||
bodyEl.innerHTML = body;
|
||||
} else {
|
||||
bodyEl.style.whiteSpace = 'pre-wrap';
|
||||
bodyEl.textContent = body;
|
||||
}
|
||||
};
|
||||
|
||||
window.syncComposeChannel = function () {
|
||||
var audience = (document.getElementById('id_audience') || {}).value || '';
|
||||
var isPostcard = audience === 'postcard_opt_in';
|
||||
var isSms = audience === 'sms_opt_in';
|
||||
var isEmail = audience === 'email_opt_in';
|
||||
var tmplField = document.getElementById('postcard-template-field');
|
||||
var emailWrap = document.getElementById('email-editor-wrap');
|
||||
var smsWrap = document.getElementById('sms-body-wrap');
|
||||
var subjectField = document.getElementById('subject-field');
|
||||
if (tmplField) tmplField.style.display = isPostcard ? '' : 'none';
|
||||
if (subjectField) subjectField.style.display = isEmail ? '' : 'none';
|
||||
if (emailWrap) emailWrap.hidden = !isEmail;
|
||||
if (smsWrap) smsWrap.hidden = isEmail;
|
||||
if (isEmail && quill) {
|
||||
syncBodyFromQuill();
|
||||
} else if (smsField && bodyField) {
|
||||
// Keep SMS/postcard plain body in hidden field
|
||||
if (!isEmail && smsField.value === '' && bodyField.value && bodyField.value.indexOf('<') === -1) {
|
||||
smsField.value = bodyField.value;
|
||||
}
|
||||
bodyField.value = smsField ? smsField.value : bodyField.value;
|
||||
bodyField.removeAttribute('required');
|
||||
}
|
||||
document.querySelectorAll('#compose-channel-tabs a[data-channel]').forEach(function (a) {
|
||||
var ch = a.getAttribute('data-channel');
|
||||
var active = (ch === 'email' && isEmail) || (ch === 'sms' && isSms) || (ch === 'postcard' && isPostcard);
|
||||
a.classList.toggle('active', active);
|
||||
});
|
||||
syncCampaignPreview();
|
||||
};
|
||||
|
||||
function initQuill() {
|
||||
var initial = (bodyField && bodyField.value) || '';
|
||||
quill = new Quill('#email-editor', {
|
||||
theme: 'snow',
|
||||
placeholder: 'Hi {first_name}, …',
|
||||
modules: {
|
||||
toolbar: {
|
||||
container: [
|
||||
[{ font: [] }, { size: ['small', false, 'large', 'huge'] }],
|
||||
['bold', 'italic', 'underline'],
|
||||
[{ color: [] }, { background: [] }],
|
||||
[{ list: 'ordered' }, { list: 'bullet' }],
|
||||
['link', 'image'],
|
||||
['clean']
|
||||
],
|
||||
handlers: {
|
||||
image: function () {
|
||||
var input = document.createElement('input');
|
||||
input.setAttribute('type', 'file');
|
||||
input.setAttribute('accept', 'image/png,image/jpeg,image/gif,image/webp');
|
||||
input.click();
|
||||
input.onchange = function () {
|
||||
var file = input.files && input.files[0];
|
||||
if (!file) return;
|
||||
var data = new FormData();
|
||||
data.append('image', file);
|
||||
fetch(uploadUrl, {
|
||||
method: 'POST',
|
||||
headers: csrfHeader(),
|
||||
body: data,
|
||||
credentials: 'same-origin'
|
||||
}).then(function (res) {
|
||||
return res.json().then(function (json) {
|
||||
if (!res.ok) throw new Error(json.error || 'Upload failed');
|
||||
return json;
|
||||
});
|
||||
}).then(function (json) {
|
||||
var range = quill.getSelection(true);
|
||||
quill.insertEmbed(range.index, 'image', json.url, 'user');
|
||||
quill.setSelection(range.index + 1);
|
||||
syncBodyFromQuill();
|
||||
}).catch(function (err) {
|
||||
alert(err.message || 'Image upload failed');
|
||||
});
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
if (initial && initial.indexOf('<') !== -1) {
|
||||
quill.root.innerHTML = initial;
|
||||
} else if (initial) {
|
||||
quill.setText(initial);
|
||||
}
|
||||
quill.on('text-change', syncBodyFromQuill);
|
||||
document.getElementById('campaign-compose').addEventListener('submit', function () {
|
||||
var audience = (document.getElementById('id_audience') || {}).value || '';
|
||||
if (audience === 'email_opt_in') syncBodyFromQuill();
|
||||
else if (smsField) bodyField.value = smsField.value;
|
||||
});
|
||||
}
|
||||
|
||||
document.querySelectorAll('#compose-channel-tabs a[data-channel="email"], #compose-channel-tabs a[data-channel="sms"]').forEach(function (a) {
|
||||
a.addEventListener('click', function (e) {
|
||||
e.preventDefault();
|
||||
var ch = a.getAttribute('data-channel');
|
||||
var audience = document.getElementById('id_audience');
|
||||
if (!audience) return;
|
||||
audience.value = ch === 'sms' ? 'sms_opt_in' : 'email_opt_in';
|
||||
syncComposeChannel();
|
||||
});
|
||||
});
|
||||
|
||||
initQuill();
|
||||
syncComposeChannel();
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -81,7 +81,8 @@
|
||||
<button class="btn btn-primary" type="submit">Save as postcard template</button>
|
||||
</form>
|
||||
<p class="library-hint" style="margin-top:12px">
|
||||
Saved templates appear when composing a postcard campaign.
|
||||
Designs listed above are selectable in Campaigns when Recipients is postcard.
|
||||
Saving as a template keeps a named local copy.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user