Template
Add PCM carrier-route postcard pick-and-send (#12)
## Summary Closes #11. Mirrors ai_ml_operations/monica_site#15. - Direct-mail compose can target the postcard mailing list **or** find and select one or two USPS carrier routes by ZIP (optional radius). - Estimated cost is shown per route and for the selection (PCM design pricing, else `PCM_POSTCARD_PRICE_*` fallback). - Selected routes order through PCM `POST /order/postcard/with-list-count` — occupant addresses are not imported into Contacts. - Not official EDDM postage; PCM FirstClass/Standard purchased occupant lists on carrier routes. ## Test plan - [ ] Campaign compose: mailing-list audience still quotes list size × per-piece cost. - [ ] Switch to USPS carrier routes, search a ZIP, pick 1–2 routes, confirm household count + estimated cost. - [ ] Saving without a selection is rejected; saving with a quote creates one contact-less Message and shows the route panel on the report. - [ ] Send places `/order/postcard/with-list-count` (not per-recipient `/order/postcard`). - [ ] `uv run python manage.py test directmail.tests.CarrierRoutePostcardTests directmail.tests.PostcardDesignPickTests` Reviewed-on: #12
This commit was merged in pull request #12.
This commit is contained in:
1 parent
f3e34f5099
commit
f2eb4b7152
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 & 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 || '';
|
||||
|
||||
Reference in new issue
Block a user