generated from westfarn/web_django_template
Initial commit
This commit is contained in:
@@ -0,0 +1,299 @@
|
||||
{% extends "portal_base.html" %}
|
||||
{% block title %}{{ campaign.name }} · Campaign{% endblock %}
|
||||
{% block topbar_title %}{{ campaign.name }}{% endblock %}
|
||||
{% block portal_content %}
|
||||
<div class="toolbar">
|
||||
<div>
|
||||
<span class="badge badge-{{ campaign.status }}" id="campaign-status-badge">{{ campaign.get_status_display }}</span>
|
||||
<span class="muted" style="margin-left:8px">{{ campaign.get_channel_display }}</span>
|
||||
{% if campaign.scheduled_for %}
|
||||
<span class="muted" style="margin-left:8px">Scheduled {{ campaign.scheduled_for|date:"M j, g:i A" }}</span>
|
||||
{% endif %}
|
||||
<span class="muted" style="margin-left:8px" id="live-hint">Live · updates every 10s</span>
|
||||
</div>
|
||||
<a class="btn btn-sm btn-ghost" href="{% url 'directmail:campaign_list' %}">← Campaigns</a>
|
||||
</div>
|
||||
|
||||
{% if campaign.channel == "email" or can_send %}
|
||||
<div class="panel" style="margin-bottom:16px">
|
||||
<div class="panel-h"><h2>Send</h2></div>
|
||||
<div class="panel-b form-grid">
|
||||
{% if campaign.channel == "email" %}
|
||||
<form method="post" action="{% url 'directmail:campaign_test_send' campaign.pk %}" class="form-grid cols-2" style="align-items:end">
|
||||
{% csrf_token %}
|
||||
<div class="field">
|
||||
<label for="id_test_email">Test send (your inbox)</label>
|
||||
<input id="id_test_email" name="test_email" type="email" required
|
||||
placeholder="you@example.com"
|
||||
value="{{ request.user.email }}">
|
||||
<div class="hint">Sends one [TEST] copy. Does not notify the recipient list.</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<button class="btn btn-ghost" type="submit">Send test email</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% if can_send %}
|
||||
<form method="post" action="{% url 'directmail:campaign_send' campaign.pk %}"
|
||||
onsubmit="return confirm('Send this campaign to all remaining recipients now?');">
|
||||
{% csrf_token %}
|
||||
<button class="btn btn-primary" type="submit">Send now to recipients</button>
|
||||
<p class="hint-block" style="margin-top:8px">
|
||||
{% if campaign.channel == "postcard" %}
|
||||
Enqueues draft / scheduled / failed messages via PCM Integrations.
|
||||
{% else %}
|
||||
Enqueues draft / scheduled / failed messages via SMTP2GO (dev ImmediateBackend runs inline).
|
||||
{% endif %}
|
||||
</p>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="stat-row" id="campaign-stats">
|
||||
<div class="stat-card">
|
||||
<div class="label">Messages</div>
|
||||
<div class="value" data-stat="total">{{ stats.total }}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="label">Sent</div>
|
||||
<div class="value" data-stat="sent">{{ stats.sent }}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="label">Delivered</div>
|
||||
<div class="value" data-stat="delivered">{{ stats.delivered }}</div>
|
||||
</div>
|
||||
{% if campaign.channel == "email" %}
|
||||
<div class="stat-card">
|
||||
<div class="label">Opens</div>
|
||||
<div class="value" data-stat="opens">{{ stats.opens }}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="label">Clicks</div>
|
||||
<div class="value" data-stat="clicks">{{ stats.clicks }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="stat-card">
|
||||
<div class="label">Bounced / failed</div>
|
||||
<div class="value" data-stat="failed">{{ stats.failed }}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="label">Suppressed</div>
|
||||
<div class="value" data-stat="suppressed">{{ stats.suppressed }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="split">
|
||||
<div class="panel">
|
||||
<div class="panel-h"><h2>Engagement</h2></div>
|
||||
<div class="panel-b">
|
||||
{% if campaign.channel == "email" %}
|
||||
<p class="hint-block" style="margin-top:0">
|
||||
Unique recipients: <strong data-stat="opens">{{ stats.opens }}</strong> opened ·
|
||||
<strong data-stat="clicks">{{ stats.clicks }}</strong> clicked
|
||||
(<span data-stat="open_events">{{ stats.open_events }}</span> open events /
|
||||
<span data-stat="click_events">{{ stats.click_events }}</span> click events from SMTP2GO).
|
||||
</p>
|
||||
{% elif campaign.channel == "sms" %}
|
||||
<p class="hint-block" style="margin-top:0">
|
||||
Delivery status updates from SMTP2GO SMS webhooks.
|
||||
</p>
|
||||
{% else %}
|
||||
<p class="hint-block" style="margin-top:0">
|
||||
Postcard status updates from PCM Integrations webhooks.
|
||||
</p>
|
||||
{% endif %}
|
||||
<div class="chart-placeholder" id="engagement-chart" role="img"
|
||||
aria-label="Campaign engagement chart">
|
||||
{% for bar in stats.chart_bars %}
|
||||
<div class="chart-bar-col">
|
||||
<div class="bar" style="height:{{ bar.pct }}%"
|
||||
title="{{ bar.label }}: {{ bar.value }}"
|
||||
data-bar-label="{{ bar.label }}"></div>
|
||||
<div class="chart-bar-meta">
|
||||
<span class="chart-bar-value" data-bar-value="{{ bar.label }}">{{ bar.value }}</span>
|
||||
<span class="chart-bar-label">{{ bar.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
{% empty %}
|
||||
<div class="chart-bar-col">
|
||||
<div class="bar" style="height:12%"></div>
|
||||
<div class="chart-bar-meta"><span class="chart-bar-label">—</span></div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<div class="panel-h"><h2>{{ events_title }}</h2></div>
|
||||
<div class="panel-b" style="padding:0">
|
||||
<table class="table">
|
||||
<thead><tr><th>When</th><th>Event</th><th>Contact</th></tr></thead>
|
||||
<tbody id="events-body">
|
||||
{% for event in recent_events %}
|
||||
<tr>
|
||||
<td class="muted">{{ event.created_at|date:"M j, g:i A" }}</td>
|
||||
<td><span class="badge">{{ event.event_type }}</span></td>
|
||||
<td>{% if event.message %}{{ event.message.contact }}{% else %}—{% endif %}</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr><td colspan="3" class="empty-state">{{ events_empty|safe }}</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel" id="recipients-panel" data-page="{{ page_obj.number }}">
|
||||
<div class="panel-h" style="display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap">
|
||||
<h2 style="margin:0">Recipients</h2>
|
||||
<span class="muted" style="font-size:13px">
|
||||
{{ page_obj.paginator.count }} total
|
||||
{% if page_obj.paginator.num_pages > 1 %}
|
||||
· page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }}
|
||||
{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
<div class="panel-b" style="padding:0">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr><th>Contact</th><th>Status</th><th>Provider id</th><th>Error</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody id="recipients-body">
|
||||
{% for message in recipient_messages %}
|
||||
<tr data-message-id="{{ message.pk }}">
|
||||
<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>
|
||||
<td style="white-space:nowrap;text-align:right">
|
||||
{% if message.can_remove %}
|
||||
<form method="post"
|
||||
action="{% url 'directmail:campaign_message_remove' campaign.pk message.pk %}"
|
||||
style="display:inline"
|
||||
onsubmit="return confirm('Remove this recipient from the campaign?');">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="page" value="{{ page_obj.number }}">
|
||||
<button class="btn btn-ghost btn-sm" type="submit">Remove</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<span class="muted">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr><td colspan="5" class="empty-state">No messages on this campaign.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% if page_obj.paginator.num_pages > 1 %}
|
||||
<div class="panel-b" style="display:flex;gap:8px;align-items:center;justify-content:flex-end;border-top:1px solid var(--monica-border)">
|
||||
{% if page_obj.has_previous %}
|
||||
<a class="btn btn-ghost btn-sm" href="?page={{ page_obj.previous_page_number }}">← Prev</a>
|
||||
{% endif %}
|
||||
<span class="muted" style="font-size:13px">Page {{ page_obj.number }} / {{ page_obj.paginator.num_pages }}</span>
|
||||
{% if page_obj.has_next %}
|
||||
<a class="btn btn-ghost btn-sm" href="?page={{ page_obj.next_page_number }}">Next →</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
(function () {
|
||||
var panel = document.getElementById("recipients-panel");
|
||||
var page = (panel && panel.getAttribute("data-page")) || "1";
|
||||
var removeBase = "{% url 'directmail:campaign_message_remove' campaign.pk '00000000-0000-0000-0000-000000000000' %}";
|
||||
var csrfToken = "{{ csrf_token }}";
|
||||
var url = "{% url 'directmail:campaign_status_json' campaign.pk %}?page=" + encodeURIComponent(page);
|
||||
function esc(s) {
|
||||
return String(s || "").replace(/[&<>"']/g, function (c) {
|
||||
return ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c];
|
||||
});
|
||||
}
|
||||
function removeUrl(id) {
|
||||
return removeBase.replace("00000000-0000-0000-0000-000000000000", id);
|
||||
}
|
||||
function apply(data) {
|
||||
var badge = document.getElementById("campaign-status-badge");
|
||||
if (badge) {
|
||||
badge.textContent = data.status_display;
|
||||
badge.className = "badge badge-" + data.status;
|
||||
}
|
||||
Object.keys(data.stats || {}).forEach(function (key) {
|
||||
if (key === "chart_bars") return;
|
||||
document.querySelectorAll('[data-stat="' + key + '"]').forEach(function (el) {
|
||||
el.textContent = data.stats[key];
|
||||
});
|
||||
});
|
||||
var chart = document.getElementById("engagement-chart");
|
||||
if (chart && Array.isArray(data.stats && data.stats.chart_bars)) {
|
||||
chart.innerHTML = data.stats.chart_bars.map(function (bar) {
|
||||
return '<div class="chart-bar-col">' +
|
||||
'<div class="bar" style="height:' + esc(bar.pct) + '%" title="' +
|
||||
esc(bar.label) + ': ' + esc(bar.value) + '" data-bar-label="' +
|
||||
esc(bar.label) + '"></div>' +
|
||||
'<div class="chart-bar-meta">' +
|
||||
'<span class="chart-bar-value">' + esc(bar.value) + '</span>' +
|
||||
'<span class="chart-bar-label">' + esc(bar.label) + '</span>' +
|
||||
'</div></div>';
|
||||
}).join("");
|
||||
}
|
||||
var body = document.getElementById("recipients-body");
|
||||
if (body && data.messages) {
|
||||
if (!data.messages.length) {
|
||||
body.innerHTML = '<tr><td colspan="5" 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>'
|
||||
: '';
|
||||
var action = m.can_remove
|
||||
? '<form method="post" action="' + esc(removeUrl(m.id)) + '" style="display:inline" ' +
|
||||
'onsubmit="return confirm(\'Remove this recipient from the campaign?\');">' +
|
||||
'<input type="hidden" name="csrfmiddlewaretoken" value="' + esc(csrfToken) + '">' +
|
||||
'<input type="hidden" name="page" value="' + esc(page) + '">' +
|
||||
'<button class="btn btn-ghost btn-sm" type="submit">Remove</button></form>'
|
||||
: '<span class="muted">—</span>';
|
||||
return "<tr data-message-id=\"" + esc(m.id) + "\">" +
|
||||
"<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>" +
|
||||
"<td style=\"white-space:nowrap;text-align:right\">" + action + "</td></tr>";
|
||||
}).join("");
|
||||
}
|
||||
}
|
||||
var eventsBody = document.getElementById("events-body");
|
||||
if (eventsBody && data.events) {
|
||||
if (!data.events.length) {
|
||||
eventsBody.innerHTML = '<tr><td colspan="3" class="empty-state">No provider events yet.</td></tr>';
|
||||
} else {
|
||||
eventsBody.innerHTML = data.events.map(function (e) {
|
||||
var when = e.created_at ? new Date(e.created_at).toLocaleString() : "—";
|
||||
return "<tr><td class=\"muted\">" + esc(when) + "</td>" +
|
||||
"<td><span class=\"badge\">" + esc(e.event_type) + "</span></td>" +
|
||||
"<td>" + esc(e.contact) + "</td></tr>";
|
||||
}).join("");
|
||||
}
|
||||
}
|
||||
}
|
||||
function tick() {
|
||||
fetch(url, { headers: { "Accept": "application/json" }, credentials: "same-origin" })
|
||||
.then(function (r) { return r.ok ? r.json() : Promise.reject(); })
|
||||
.then(apply)
|
||||
.catch(function () {});
|
||||
}
|
||||
setInterval(tick, 10000);
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,376 @@
|
||||
{% 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>
|
||||
#email-editor-wrap {
|
||||
position: relative;
|
||||
overflow: visible;
|
||||
z-index: 1;
|
||||
}
|
||||
#email-editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 200px;
|
||||
}
|
||||
.ql-toolbar.ql-snow {
|
||||
border-color: var(--monica-border);
|
||||
border-radius: 4px 4px 0 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.ql-container.ql-snow {
|
||||
border-color: var(--monica-border);
|
||||
border-radius: 0 0 4px 4px;
|
||||
background: #fff;
|
||||
height: auto !important;
|
||||
min-height: 160px;
|
||||
flex: 1;
|
||||
overflow: visible;
|
||||
}
|
||||
.ql-editor {
|
||||
min-height: 160px;
|
||||
font-family: Georgia, "Times New Roman", serif;
|
||||
font-size: 15px;
|
||||
}
|
||||
#preview-body img { max-width: 100%; height: auto; }
|
||||
#preview-body { line-height: 1.55; color: #212121; }
|
||||
#email-editor-wrap[hidden],
|
||||
#sms-body-wrap[hidden],
|
||||
#postcard-body-hint[hidden] { display: none !important; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
{% block portal_content %}
|
||||
<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="#compose-postcard" data-channel="postcard">Postcard</a>
|
||||
</div>
|
||||
|
||||
<div class="split">
|
||||
<div class="panel">
|
||||
<div class="panel-h"><h2>Compose</h2></div>
|
||||
<div class="panel-b">
|
||||
{% if form_errors %}
|
||||
<ul class="portal-flash" style="margin:0 0 8px">
|
||||
{% for err in form_errors %}
|
||||
<li class="error">{{ err }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
<form method="post" action="{% url 'directmail:campaign_list' %}" class="form-grid" id="campaign-compose">
|
||||
{% csrf_token %}
|
||||
<div class="field">
|
||||
<label for="id_name">Campaign name</label>
|
||||
<input id="id_name" name="name" type="text" required
|
||||
placeholder="Spring seller tips" value="{{ form_data.name }}">
|
||||
</div>
|
||||
<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"
|
||||
value="{{ form_data.subject }}"
|
||||
oninput="syncCampaignPreview()">
|
||||
<div class="hint">Email only — ignored for SMS / postcard</div>
|
||||
</div>
|
||||
<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: <code>{% templatetag openvariable %}first_name{% templatetag closevariable %}</code>, <code>{% templatetag openvariable %}last_name{% templatetag closevariable %}</code></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 {% templatetag openvariable %}first_name{% templatetag closevariable %}, …"
|
||||
oninput="syncSmsBody()">{{ form_data.body }}</textarea>
|
||||
<div class="hint">Plain text for SMS · keep it short · merge tags: <code>{% templatetag openvariable %}first_name{% templatetag closevariable %}</code>, <code>{% templatetag openvariable %}last_name{% templatetag closevariable %}</code></div>
|
||||
</div>
|
||||
<div class="field" id="postcard-body-hint" hidden>
|
||||
<p class="hint-block" style="margin:0">
|
||||
Postcard campaigns use the selected PCM design. Optional note below is stored on the draft only.
|
||||
</p>
|
||||
<label for="id_body_pc" style="margin-top:8px">Internal note <span class="muted">(optional)</span></label>
|
||||
<textarea id="id_body_pc" style="min-height:72px"
|
||||
placeholder="Optional internal note…"
|
||||
oninput="syncPostcardBody()">{{ form_data.body }}</textarea>
|
||||
</div>
|
||||
<div class="field" id="postcard-template-field">
|
||||
<label for="id_template_id">Postcard design</label>
|
||||
<select id="id_template_id" name="template_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 designs yet — create one first</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<div class="hint" style="display:flex;gap:12px;flex-wrap:wrap;margin-top:8px">
|
||||
<a class="btn btn-ghost btn-sm" href="{% url 'directmail:postcard_designer' %}">Create / update postcard design</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-grid cols-2">
|
||||
<div class="field">
|
||||
<label for="id_audience">Recipients</label>
|
||||
<select id="id_audience" name="audience" required onchange="syncComposeChannel()">
|
||||
{% for value, label in audience_choices %}
|
||||
<option value="{{ value }}"{% if form_data.audience == value %} selected{% endif %}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="id_scheduled_for">Schedule <span class="muted">(optional)</span></label>
|
||||
<input id="id_scheduled_for" name="scheduled_for" type="datetime-local" step="60"
|
||||
value="{{ form_data.scheduled_for }}">
|
||||
<div class="hint">Date & time · leave blank to keep as unscheduled draft</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="hint-block">Saves a draft campaign and recipient stubs. Send from the campaign report when ready. You’ll get an email when the send finishes.</p>
|
||||
<button class="btn btn-primary" type="submit">Save draft</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<div class="panel-h"><h2>Preview</h2></div>
|
||||
<div class="panel-b">
|
||||
<div class="preview-pane" id="campaign-preview">
|
||||
<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="margin-top:8px"></div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="hint-block">After send → open the campaign report for delivery & engagement.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<div class="panel-h"><h2>Recent campaigns</h2></div>
|
||||
<div class="panel-b" style="padding:0">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Channel</th>
|
||||
<th>Status</th>
|
||||
<th>Scheduled</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for campaign in campaigns %}
|
||||
<tr>
|
||||
<td><a href="{% url 'directmail:campaign_detail' campaign.pk %}">{{ campaign.name }}</a></td>
|
||||
<td>{{ campaign.get_channel_display }}</td>
|
||||
<td><span class="badge badge-{{ campaign.status }}">{{ campaign.get_status_display }}</span></td>
|
||||
<td>{% if campaign.scheduled_for %}{{ campaign.scheduled_for|date:"M j, g:i A" }}{% else %}—{% endif %}</td>
|
||||
<td><a href="{% url 'directmail:campaign_detail' campaign.pk %}">Report</a></td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr><td colspan="5" class="empty-state">No campaigns yet.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block extra_js %}
|
||||
<script src="https://cdn.jsdelivr.net/npm/quill@2.0.3/dist/quill.js"></script>
|
||||
<script>
|
||||
(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 };
|
||||
}
|
||||
|
||||
function syncBodyFromQuill() {
|
||||
if (!quill || !bodyField) return;
|
||||
var html = quill.root.innerHTML;
|
||||
if (html === '<p><br></p>' || html === '<p></p>') html = '';
|
||||
bodyField.value = html;
|
||||
syncCampaignPreview();
|
||||
}
|
||||
|
||||
window.syncSmsBody = function () {
|
||||
if (smsField && bodyField) bodyField.value = smsField.value;
|
||||
syncCampaignPreview();
|
||||
};
|
||||
|
||||
window.syncPostcardBody = function () {
|
||||
var pc = document.getElementById('id_body_pc');
|
||||
if (pc && bodyField) bodyField.value = pc.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 isPostcard = audience === 'postcard_opt_in';
|
||||
var body = '';
|
||||
if (isEmail && quill) {
|
||||
body = quill.root.innerHTML;
|
||||
if (body === '<p><br></p>' || body === '<p></p>') body = '';
|
||||
} else if (isPostcard) {
|
||||
var tmpl = document.getElementById('id_template_id');
|
||||
var label = tmpl && tmpl.selectedIndex >= 0 ? tmpl.options[tmpl.selectedIndex].text : '';
|
||||
body = label && tmpl.value ? ('Postcard design: ' + label) : '';
|
||||
var note = (document.getElementById('id_body_pc') || {}).value || '';
|
||||
if (note) body = (body ? body + '\n\n' : '') + note;
|
||||
} 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) : (isPostcard ? 'Postcard mailing' : '');
|
||||
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 pcHint = document.getElementById('postcard-body-hint');
|
||||
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 = !isSms;
|
||||
if (pcHint) pcHint.hidden = !isPostcard;
|
||||
if (isEmail && quill) {
|
||||
syncBodyFromQuill();
|
||||
} else if (isSms && smsField && bodyField) {
|
||||
if (smsField.value === '' && bodyField.value && bodyField.value.indexOf('<') === -1) {
|
||||
smsField.value = bodyField.value;
|
||||
}
|
||||
bodyField.value = smsField.value;
|
||||
bodyField.removeAttribute('required');
|
||||
} else if (isPostcard && bodyField) {
|
||||
var pc = document.getElementById('id_body_pc');
|
||||
bodyField.value = pc ? pc.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 (audience === 'sms_opt_in' && smsField) bodyField.value = smsField.value;
|
||||
else if (audience === 'postcard_opt_in') {
|
||||
var pc = document.getElementById('id_body_pc');
|
||||
bodyField.value = pc ? pc.value : '';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
document.querySelectorAll('#compose-channel-tabs a[data-channel]').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;
|
||||
if (ch === 'sms') audience.value = 'sms_opt_in';
|
||||
else if (ch === 'postcard') audience.value = 'postcard_opt_in';
|
||||
else audience.value = 'email_opt_in';
|
||||
syncComposeChannel();
|
||||
});
|
||||
});
|
||||
|
||||
var tmplSelect = document.getElementById('id_template_id');
|
||||
if (tmplSelect) tmplSelect.addEventListener('change', syncCampaignPreview);
|
||||
|
||||
initQuill();
|
||||
syncComposeChannel();
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,135 @@
|
||||
{% extends "portal_base.html" %}
|
||||
{% block title %}Postcard designer · Portal{% endblock %}
|
||||
{% block topbar_title %}Postcard designer{% endblock %}
|
||||
{% block portal_content %}
|
||||
{% if api_error %}
|
||||
<ul class="portal-flash" style="margin:0 0 16px">
|
||||
<li class="error">{{ api_error }}</li>
|
||||
</ul>
|
||||
{% endif %}
|
||||
|
||||
<div class="designer-layout pcm-designer">
|
||||
<div class="designer-controls">
|
||||
<div class="panel">
|
||||
<div class="panel-h"><h2>New design</h2></div>
|
||||
<div class="panel-b">
|
||||
<form method="post" action="{% url 'directmail:postcard_design_create' %}" class="form-grid">
|
||||
{% csrf_token %}
|
||||
<div class="field">
|
||||
<label for="id_design_name">Name</label>
|
||||
<input id="id_design_name" name="name" type="text" required
|
||||
placeholder="March just-listed" value="{{ new_name }}">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="id_design_size">Size</label>
|
||||
<select id="id_design_size" name="size" required>
|
||||
{% for code, label in size_choices %}
|
||||
<option value="{{ code }}"{% if code == new_size %} selected{% endif %}>{{ label }} ({{ code }})</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<button class="btn btn-primary" type="submit">Create in PCM designer</button>
|
||||
</form>
|
||||
<p class="library-hint" style="margin-top:12px">
|
||||
Opens PCM Integrations editor in the frame. Artwork stays on their side;
|
||||
we store the design id for campaigns.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<div class="panel-h"><h2>Your designs</h2></div>
|
||||
<div class="panel-b" style="padding:0">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>ID</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for d in designs %}
|
||||
<tr{% if d.design_id == active_design_id %} class="is-active"{% endif %}>
|
||||
<td>{{ d.name }}</td>
|
||||
<td class="muted">{{ d.design_id }}</td>
|
||||
<td>
|
||||
<a href="{% url 'directmail:postcard_designer' %}?design_id={{ d.design_id }}">Edit</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr><td colspan="3" class="empty-state">No designs yet — create one above.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if active_design_id %}
|
||||
<div class="panel">
|
||||
<div class="panel-h"><h2>Save for campaigns</h2></div>
|
||||
<div class="panel-b">
|
||||
<form method="post" action="{% url 'directmail:postcard_design_save' %}" class="form-grid">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="design_id" value="{{ active_design_id }}">
|
||||
<input type="hidden" name="size" value="{{ active_size }}">
|
||||
<div class="field">
|
||||
<label for="id_template_name">Template name</label>
|
||||
<input id="id_template_name" name="template_name" type="text" required
|
||||
value="{{ active_name }}">
|
||||
</div>
|
||||
<button class="btn btn-primary" type="submit">Save as postcard template</button>
|
||||
</form>
|
||||
<p class="library-hint" style="margin-top:12px">
|
||||
Designs listed above are selectable in Campaigns when Recipients is postcard.
|
||||
Saving as a template keeps a named local copy.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if saved_templates %}
|
||||
<div class="panel">
|
||||
<div class="panel-h"><h2>Saved templates</h2></div>
|
||||
<div class="panel-b" style="padding:0">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr><th>Name</th><th>Design</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for t in saved_templates %}
|
||||
<tr>
|
||||
<td>{{ t.name }}</td>
|
||||
<td class="muted">{{ t.postcard_front.design_id }}</td>
|
||||
<td>
|
||||
<a href="{% url 'directmail:postcard_designer' %}?design_id={{ t.postcard_front.design_id }}">Open</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="designer-preview-col panel">
|
||||
<div class="panel-h">
|
||||
<h2>{% if active_design_id %}PCM editor · design {{ active_design_id }}{% else %}Editor{% endif %}</h2>
|
||||
</div>
|
||||
<div class="panel-b pcm-iframe-wrap">
|
||||
{% if embed_url %}
|
||||
<iframe
|
||||
title="PCM postcard designer"
|
||||
src="{{ embed_url }}"
|
||||
allow="clipboard-write"
|
||||
></iframe>
|
||||
{% else %}
|
||||
<div class="empty-state" style="padding:48px 24px;text-align:center">
|
||||
Create a design or pick one from the list to open the PCM editor here.
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user