Add PCM carrier-route postcard pick-and-send (#11)

## Summary
Closes #11.
- Mirror Monica: compose can use the postcard mailing list or 1–2 USPS carrier routes, with estimated cost shown.
- Place selected routes through PCM list-count orders instead of importing occupant addresses.
This commit is contained in:
westfarn committed 2026-09-21 15:27:06 -05:00
1 parent 9cdce7a897
commit 59bc0c3151
17 files changed
+1192 -27

No files matched your search

@@ -0,0 +1,50 @@
<div class="field" id="postcard-list-cost" hidden></div>
<div id="postcard-route-picker" hidden>
<p class="hint-block">
PCM mails a purchased occupant list on the USPS carrier routes you pick
(First Class / Standard — not official EDDM postage). Cost is per piece
times households.
</p>
<div class="form-grid cols-2">
<div class="field">
<label for="route-zip">ZIP code</label>
<input id="route-zip" type="text" inputmode="numeric" maxlength="10"
placeholder="60540" autocomplete="postal-code">
</div>
<div class="field">
<label for="route-radius">Radius miles <span class="muted">(optional)</span></label>
<input id="route-radius" type="number" min="0" step="0.5" placeholder="1">
</div>
</div>
<div class="form-grid cols-2">
<div class="field">
<label for="route-address">Street <span class="muted">(optional, with radius)</span></label>
<input id="route-address" type="text" placeholder="1245 Butterfield Rd">
</div>
<div class="field">
<label for="route-city">City</label>
<input id="route-city" type="text" placeholder="Wheaton">
</div>
</div>
<div class="field" style="max-width:120px">
<label for="route-state">State</label>
<input id="route-state" type="text" maxlength="2" placeholder="IL">
</div>
<button class="btn btn-ghost btn-sm" type="button" id="route-find-btn">Find routes</button>
<p class="hint" id="route-status" style="margin-top:8px"></p>
<div id="route-table-wrap" hidden style="margin-top:12px">
<table class="table">
<thead>
<tr>
<th>Route</th>
<th>Area</th>
<th>Households</th>
<th>Est. cost</th>
</tr>
</thead>
<tbody id="route-table-body"></tbody>
</table>
</div>
<p class="hint-block" id="route-selected-cost" hidden style="margin-top:12px"></p>
</div>
<input type="hidden" name="route_targeting" id="id_route_targeting" value="{{ form_data.route_targeting }}">
@@ -36,11 +36,13 @@
{% 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?');">
onsubmit="return confirm('{% if campaign.audience == "carrier_route" %}Place this PCM carrier-route postcard order now?{% else %}Send this campaign to all remaining recipients now?{% endif %}');">
{% csrf_token %}
<button class="btn btn-primary" type="submit">Send now to recipients</button>
<button class="btn btn-primary" type="submit">{% if campaign.audience == "carrier_route" %}Place route order{% else %}Send now to recipients{% endif %}</button>
<p class="hint-block" style="margin-top:8px">
{% if campaign.channel == "postcard" %}
{% if campaign.audience == "carrier_route" %}
Places one PCM list-count order for the selected USPS routes (not per contact).
{% elif campaign.channel == "postcard" %}
Enqueues draft / scheduled / failed messages via PCM Integrations.
{% else %}
Enqueues draft / scheduled / failed messages via SMTP2GO (dev ImmediateBackend runs inline).
@@ -52,6 +54,35 @@
</div>
{% endif %}
{% if campaign.audience == "carrier_route" %}
<div class="panel" style="margin-bottom:16px">
<div class="panel-h"><h2>Carrier routes</h2></div>
<div class="panel-b">
{% with t=campaign.route_targeting %}
<p style="margin-top:0">
{{ t.record_count|default:0 }} household{{ t.record_count|default:0|pluralize }}
· {{ t.mail_class|default:"Standard" }}
{% if t.estimated.total != None %}
· est. ${{ t.estimated.total }}
{% if t.estimated.per_piece %} (${{ t.estimated.per_piece }} / piece){% endif %}
{% endif %}
</p>
<p class="muted" style="margin:0">
Routes:
{% for code in t.selected_codes %}
<code>{{ code }}</code>{% if not forloop.last %}, {% endif %}
{% empty %}
—
{% endfor %}
</p>
{% if t.estimated.source == "fallback" %}
<p class="hint-block">Cost uses env fallback rates (PCM did not return design pricing).</p>
{% endif %}
{% endwith %}
</div>
</div>
{% endif %}
<div class="panel" style="margin-bottom:16px">
<div class="panel-h"><h2>Tracked site link</h2></div>
<div class="panel-b">
@@ -129,6 +129,7 @@
<div class="hint">Date &amp; time · leave blank to keep as unscheduled draft</div>
</div>
</div>
{% include "directmail/_postcard_route_picker.html" %}
<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>
{% include "core/_utm_link_panel.html" with utm_live=True utm_medium="postcard" %}
<button class="btn btn-primary" type="submit">Save draft</button>
@@ -184,6 +185,7 @@
<script src="https://cdn.jsdelivr.net/npm/quill@2.0.3/dist/quill.js"></script>
<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 src="{% static 'js/postcard-routes.js' %}"></script>
<script>
(function () {
var uploadUrl = "{{ image_upload_url|escapejs }}";
@@ -219,7 +221,7 @@
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 isPostcard = audience === 'postcard_opt_in' || audience === 'carrier_route';
var body = '';
if (isEmail && quill) {
body = quill.root.innerHTML;
@@ -257,7 +259,7 @@
window.syncComposeChannel = function () {
var audience = (document.getElementById('id_audience') || {}).value || '';
var isPostcard = audience === 'postcard_opt_in';
var isPostcard = audience === 'postcard_opt_in' || audience === 'carrier_route';
var isSms = audience === 'sms_opt_in';
var isEmail = audience === 'email_opt_in';
var tmplField = document.getElementById('postcard-template-field');
@@ -290,6 +292,9 @@
});
var panel = document.getElementById('utm-link-panel');
if (panel && panel._utmApply) panel._utmApply();
if (window.MonicaPostcardRoutes && window.MonicaPostcardRoutes.syncVisibility) {
window.MonicaPostcardRoutes.syncVisibility();
}
syncCampaignPreview();
};
@@ -353,7 +358,7 @@
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') {
else if (audience === 'postcard_opt_in' || audience === 'carrier_route') {
var pc = document.getElementById('id_body_pc');
bodyField.value = pc ? pc.value : '';
}
@@ -377,6 +382,10 @@
if (tmplSelect) tmplSelect.addEventListener('change', syncCampaignPreview);
initQuill();
window.initPostcardRoutes({
quoteUrl: "{{ postcard_quote_url|escapejs }}",
csrfToken: csrfToken
});
window.CampaignUtm.bindPanel(document.getElementById('utm-link-panel'), {
getName: function () {
return (document.getElementById('id_name') || {}).value || '';