CI / test (pull_request) Successful in 21s
Closes #9. Auto-insert tracked homepage links in campaign compose, register them with url_shortening_service, and put short piha.li URLs in SMS, email hrefs, and postcard QR codes when SHORTENER_* is configured.
311 lines
13 KiB
HTML
311 lines
13 KiB
HTML
{% extends "portal_base.html" %}
|
|
{% load static %}
|
|
{% 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 'messaging: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 'messaging: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 'messaging: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="panel" style="margin-bottom:16px">
|
|
<div class="panel-h"><h2>Tracked site link</h2></div>
|
|
<div class="panel-b">
|
|
{% include "messaging/_utm_link_panel.html" with utm_live=False utm_medium=campaign.channel utm_campaign_name=campaign.name %}
|
|
</div>
|
|
</div>
|
|
|
|
<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 'messaging: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 src="https://cdn.jsdelivr.net/npm/qrcode@1.5.4/build/qrcode.min.js"></script>
|
|
<script src="{% static 'js/campaign-utm.js' %}"></script>
|
|
<script>
|
|
window.MonicaUtm.bindPanel(document.getElementById("utm-link-panel"));
|
|
(function () {
|
|
var panel = document.getElementById("recipients-panel");
|
|
var page = (panel && panel.getAttribute("data-page")) || "1";
|
|
var removeBase = "{% url 'messaging:campaign_message_remove' campaign.pk '00000000-0000-0000-0000-000000000000' %}";
|
|
var csrfToken = "{{ csrf_token }}";
|
|
var url = "{% url 'messaging: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 %}
|