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,250 @@
|
||||
(function () {
|
||||
function money(n) {
|
||||
if (n === null || n === undefined || n === "") return "—";
|
||||
var v = Number(n);
|
||||
if (Number.isNaN(v)) return "—";
|
||||
return "$" + v.toFixed(2);
|
||||
}
|
||||
|
||||
function initPostcardRoutes(opts) {
|
||||
var form = document.getElementById("campaign-compose");
|
||||
if (!form) return;
|
||||
var audience = document.getElementById("id_audience");
|
||||
var picker = document.getElementById("postcard-route-picker");
|
||||
var listCost = document.getElementById("postcard-list-cost");
|
||||
var hidden = document.getElementById("id_route_targeting");
|
||||
var findBtn = document.getElementById("route-find-btn");
|
||||
var status = document.getElementById("route-status");
|
||||
var tableWrap = document.getElementById("route-table-wrap");
|
||||
var tbody = document.getElementById("route-table-body");
|
||||
var selectedBox = document.getElementById("route-selected-cost");
|
||||
var quoteUrl = opts.quoteUrl;
|
||||
var csrfToken = opts.csrfToken;
|
||||
var maxRoutes = 2;
|
||||
var routes = [];
|
||||
|
||||
function isPostcardAudience() {
|
||||
var v = (audience && audience.value) || "";
|
||||
return v === "postcard_opt_in" || v === "carrier_route";
|
||||
}
|
||||
|
||||
function isRouteAudience() {
|
||||
return ((audience && audience.value) || "") === "carrier_route";
|
||||
}
|
||||
|
||||
function templateId() {
|
||||
return (document.getElementById("id_template_id") || {}).value || "";
|
||||
}
|
||||
|
||||
function setStatus(text, isError) {
|
||||
if (!status) return;
|
||||
status.textContent = text || "";
|
||||
status.classList.toggle("error", !!isError);
|
||||
}
|
||||
|
||||
function selectedCodes() {
|
||||
return Array.prototype.map.call(
|
||||
form.querySelectorAll('input[name="route_code"]:checked'),
|
||||
function (el) { return el.value; }
|
||||
);
|
||||
}
|
||||
|
||||
function postQuote(body) {
|
||||
return fetch(quoteUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-CSRFToken": csrfToken,
|
||||
Accept: "application/json"
|
||||
},
|
||||
credentials: "same-origin",
|
||||
body: JSON.stringify(body)
|
||||
}).then(function (res) {
|
||||
return res.json().then(function (json) {
|
||||
if (!res.ok) throw new Error(json.detail || "Quote failed");
|
||||
return json;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function renderListCost(data) {
|
||||
if (!listCost) return;
|
||||
if (!isPostcardAudience() || isRouteAudience()) {
|
||||
listCost.hidden = true;
|
||||
return;
|
||||
}
|
||||
listCost.hidden = false;
|
||||
var src = data.price_source === "fallback" ? " (env fallback rate)" : "";
|
||||
if (data.per_piece == null) {
|
||||
listCost.innerHTML =
|
||||
"<strong>Estimated cost:</strong> " + data.count +
|
||||
" household" + (data.count === 1 ? "" : "s") +
|
||||
" · per-piece price unavailable. Set PCM_POSTCARD_PRICE_STANDARD / FIRSTCLASS.";
|
||||
return;
|
||||
}
|
||||
listCost.innerHTML =
|
||||
"<strong>Estimated cost:</strong> " + data.count +
|
||||
" × " + money(data.per_piece) + " = " + money(data.total) +
|
||||
" (" + data.mail_class + src + ")";
|
||||
}
|
||||
|
||||
function refreshListCost() {
|
||||
if (!isPostcardAudience() || isRouteAudience()) {
|
||||
if (listCost) listCost.hidden = true;
|
||||
return;
|
||||
}
|
||||
postQuote({ action: "mailing_list", template_id: templateId() })
|
||||
.then(renderListCost)
|
||||
.catch(function (err) {
|
||||
if (listCost) {
|
||||
listCost.hidden = false;
|
||||
listCost.textContent = err.message || "Could not load mailing-list cost.";
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function writeHidden(payload) {
|
||||
if (hidden) hidden.value = payload ? JSON.stringify(payload) : "";
|
||||
}
|
||||
|
||||
function renderSelected(data) {
|
||||
if (!selectedBox) return;
|
||||
if (!data) {
|
||||
selectedBox.hidden = true;
|
||||
writeHidden("");
|
||||
return;
|
||||
}
|
||||
selectedBox.hidden = false;
|
||||
var src = data.price_source === "fallback" ? " using env fallback rate" : "";
|
||||
var missing = data.per_piece == null
|
||||
? " Per-piece price unavailable — set PCM_POSTCARD_PRICE_* before sending."
|
||||
: "";
|
||||
selectedBox.innerHTML =
|
||||
"<strong>Selected:</strong> " + (data.record_count || 0) +
|
||||
" household" + ((data.record_count || 0) === 1 ? "" : "s") +
|
||||
" on " + (data.selected_codes || []).join(", ") +
|
||||
" · " + (data.mail_class || "") +
|
||||
" · est. " + money(data.total) + src + "." + missing;
|
||||
writeHidden({
|
||||
selected_codes: data.selected_codes,
|
||||
list_count_id: data.list_count_id,
|
||||
record_count: data.record_count,
|
||||
mail_class: data.mail_class,
|
||||
estimated: data.estimated,
|
||||
route_rows: data.route_rows || data.routes
|
||||
});
|
||||
}
|
||||
|
||||
function recount() {
|
||||
var codes = selectedCodes();
|
||||
if (!codes.length) {
|
||||
renderSelected(null);
|
||||
setStatus("Select one or two routes.");
|
||||
return;
|
||||
}
|
||||
setStatus("Updating quote…");
|
||||
postQuote({
|
||||
action: "select",
|
||||
template_id: templateId(),
|
||||
routes: codes
|
||||
}).then(function (data) {
|
||||
setStatus("");
|
||||
renderSelected(data);
|
||||
}).catch(function (err) {
|
||||
renderSelected(null);
|
||||
setStatus(err.message || "Could not quote selected routes.", true);
|
||||
});
|
||||
}
|
||||
|
||||
function renderRoutes(list) {
|
||||
routes = list || [];
|
||||
if (!tbody || !tableWrap) return;
|
||||
if (!routes.length) {
|
||||
tableWrap.hidden = true;
|
||||
tbody.innerHTML = "";
|
||||
return;
|
||||
}
|
||||
tableWrap.hidden = false;
|
||||
tbody.innerHTML = routes.map(function (row) {
|
||||
return "<tr>" +
|
||||
"<td><label class=\"check-row\" style=\"margin:0\">" +
|
||||
"<input type=\"checkbox\" name=\"route_code\" value=\"" +
|
||||
String(row.code).replace(/"/g, """) + "\"> " +
|
||||
String(row.code) + "</label></td>" +
|
||||
"<td>" + String(row.text || "") + "</td>" +
|
||||
"<td>" + String(row.total) + "</td>" +
|
||||
"<td>" + money(row.cost) + "</td></tr>";
|
||||
}).join("");
|
||||
tbody.querySelectorAll('input[name="route_code"]').forEach(function (box) {
|
||||
box.addEventListener("change", function () {
|
||||
var checked = selectedCodes();
|
||||
if (checked.length > maxRoutes) {
|
||||
box.checked = false;
|
||||
setStatus("Pick at most two routes.", true);
|
||||
return;
|
||||
}
|
||||
recount();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function findRoutes() {
|
||||
var zip = (document.getElementById("route-zip") || {}).value || "";
|
||||
var address = (document.getElementById("route-address") || {}).value || "";
|
||||
var city = (document.getElementById("route-city") || {}).value || "";
|
||||
var state = (document.getElementById("route-state") || {}).value || "";
|
||||
var radius = (document.getElementById("route-radius") || {}).value || "";
|
||||
setStatus("Looking up routes…");
|
||||
tableWrap.hidden = true;
|
||||
renderSelected(null);
|
||||
postQuote({
|
||||
action: "search",
|
||||
template_id: templateId(),
|
||||
zip: zip,
|
||||
address: address,
|
||||
city: city,
|
||||
state: state,
|
||||
radius: radius
|
||||
}).then(function (data) {
|
||||
renderRoutes(data.routes || []);
|
||||
if (!(data.routes || []).length) {
|
||||
setStatus("No carrier routes for that search.", true);
|
||||
} else {
|
||||
setStatus("Select one or two routes. Cost uses " + (data.mail_class || "Standard") + ".");
|
||||
}
|
||||
}).catch(function (err) {
|
||||
renderRoutes([]);
|
||||
setStatus(err.message || "Route lookup failed.", true);
|
||||
});
|
||||
}
|
||||
|
||||
function syncVisibility() {
|
||||
if (picker) picker.hidden = !isRouteAudience();
|
||||
if (!isRouteAudience()) {
|
||||
writeHidden("");
|
||||
if (selectedBox) selectedBox.hidden = true;
|
||||
}
|
||||
refreshListCost();
|
||||
}
|
||||
|
||||
if (findBtn) findBtn.addEventListener("click", function (e) {
|
||||
e.preventDefault();
|
||||
findRoutes();
|
||||
});
|
||||
var tmpl = document.getElementById("id_template_id");
|
||||
if (tmpl) tmpl.addEventListener("change", function () {
|
||||
syncVisibility();
|
||||
if (isRouteAudience() && selectedCodes().length) recount();
|
||||
});
|
||||
if (audience) audience.addEventListener("change", syncVisibility);
|
||||
syncVisibility();
|
||||
|
||||
window.MonicaPostcardRoutes = {
|
||||
isPostcardAudience: isPostcardAudience,
|
||||
isRouteAudience: isRouteAudience,
|
||||
syncVisibility: syncVisibility
|
||||
};
|
||||
}
|
||||
|
||||
window.initPostcardRoutes = initPostcardRoutes;
|
||||
})();
|
||||
Reference in new issue
Block a user