Files
web_django_template/site/client_site/static/js/postcard-routes.js
T
westfarn 59bc0c3151 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.
2026-09-21 15:27:06 -05:00

251 lines
8.2 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
(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, "&quot;") + "\"> " +
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;
})();