generated from westfarn/web_django_template
Add campaign UTM links and piha.li shortener as a core helper.
Keep mint/UTM in always-on core so email_sms and directmail stay optional, and brand utm_source from SITE_NAME (or UTM_SOURCE) instead of a hardcoded client name. Closes westfarn/web_django_template#3
This commit is contained in:
@@ -690,3 +690,39 @@ body.portal {
|
||||
padding: 8px 10px; border: 1px solid var(--monica-border); font: inherit; font-size: 13px; background: #fff;
|
||||
}
|
||||
.empty-state { padding: 24px; color: var(--monica-muted); font-size: 14px; }
|
||||
|
||||
.utm-panel {
|
||||
margin-top: 4px;
|
||||
padding-top: 4px;
|
||||
}
|
||||
.utm-url {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
word-break: break-all;
|
||||
background: #f8fafc;
|
||||
border: 1px solid var(--monica-border);
|
||||
padding: 8px 10px;
|
||||
color: #0f172a;
|
||||
}
|
||||
.utm-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.utm-qr {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 12px;
|
||||
}
|
||||
.utm-qr canvas {
|
||||
display: block;
|
||||
border: 1px solid var(--monica-border);
|
||||
background: #fff;
|
||||
}
|
||||
.utm-qr-actions { min-width: 160px; }
|
||||
.utm-qr-actions .btn { margin: 0 8px 8px 0; }
|
||||
#preview-body a { color: var(--monica-primary); }
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
/**
|
||||
* Campaign UTM link helper (composer + report).
|
||||
* utm_source from data-utm-source (brand slug), utm_medium=email|sms|postcard,
|
||||
* utm_campaign=slug(name). Short URLs come from the portal JSON endpoint.
|
||||
*/
|
||||
(function (global) {
|
||||
"use strict";
|
||||
|
||||
var SLUG_MAX = 80;
|
||||
var SHORTEN_WAIT_MS = 400;
|
||||
var SHORT_URL_RE =
|
||||
/https?:\/\/(?:(?:www\.)?(?:beta\.)?piha\.li|(?:127\.0\.0\.1|localhost):\d+)\/[a-z0-9]{4,8}/i;
|
||||
|
||||
function slug(name) {
|
||||
var s = String(name || "")
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
return (s || "campaign").slice(0, SLUG_MAX);
|
||||
}
|
||||
|
||||
function sourceFrom(panel) {
|
||||
return slug((panel && panel.getAttribute("data-utm-source")) || "campaign");
|
||||
}
|
||||
|
||||
function utmUrlRe(source) {
|
||||
return new RegExp(
|
||||
"https?:\\/\\/[^\\s<>\"]+utm_source=" + source + "[^\\s<>\"]*",
|
||||
"i"
|
||||
);
|
||||
}
|
||||
|
||||
function buildUrl(base, medium, name, source) {
|
||||
var root = String(base || "").replace(/\/+$/, "");
|
||||
var ch = String(medium || "email").toLowerCase();
|
||||
if (ch !== "email" && ch !== "sms" && ch !== "postcard") ch = "email";
|
||||
var q =
|
||||
"utm_source=" +
|
||||
encodeURIComponent(source || "campaign") +
|
||||
"&utm_medium=" +
|
||||
encodeURIComponent(ch) +
|
||||
"&utm_campaign=" +
|
||||
encodeURIComponent(slug(name));
|
||||
return root + "/?" + q;
|
||||
}
|
||||
|
||||
function replaceTextUrl(text, url, source) {
|
||||
var raw = String(text || "");
|
||||
var utmRe = utmUrlRe(source || "campaign");
|
||||
if (utmRe.test(raw)) {
|
||||
return raw.replace(utmRe, url);
|
||||
}
|
||||
if (SHORT_URL_RE.test(raw)) {
|
||||
return raw.replace(SHORT_URL_RE, url);
|
||||
}
|
||||
var trimmed = raw.replace(/\s+$/, "");
|
||||
return trimmed ? trimmed + "\n\n" + url : url;
|
||||
}
|
||||
|
||||
function linkify(text) {
|
||||
var esc = String(text || "").replace(/[&<>"']/g, function (c) {
|
||||
return (
|
||||
{ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[
|
||||
c
|
||||
] || c
|
||||
);
|
||||
});
|
||||
return esc.replace(
|
||||
/(https?:\/\/[^\s]+)/g,
|
||||
'<a href="$1" rel="noopener noreferrer">$1</a>'
|
||||
);
|
||||
}
|
||||
|
||||
function ensureQuillLink(quill, url, label, source) {
|
||||
if (!quill || !quill.root) return;
|
||||
var src = source || "campaign";
|
||||
var existing = quill.root.querySelectorAll(
|
||||
'a[data-campaign-utm], a[href*="utm_source=' +
|
||||
src +
|
||||
'"], a[href*="piha.li/"]'
|
||||
);
|
||||
if (existing.length) {
|
||||
existing.forEach(function (a) {
|
||||
a.setAttribute("href", url);
|
||||
a.setAttribute("data-campaign-utm", "1");
|
||||
});
|
||||
return;
|
||||
}
|
||||
var labelText = label || "Website";
|
||||
var start = Math.max(0, quill.getLength() - 1);
|
||||
quill.insertText(start, "\n" + labelText, "silent");
|
||||
quill.formatText(start + 1, labelText.length, "link", url, "silent");
|
||||
var links = quill.root.querySelectorAll("a");
|
||||
var last = links[links.length - 1];
|
||||
if (last) last.setAttribute("data-campaign-utm", "1");
|
||||
}
|
||||
|
||||
function renderQr(canvas, url) {
|
||||
if (!canvas || !url || typeof QRCode === "undefined") return Promise.resolve();
|
||||
return QRCode.toCanvas(canvas, url, {
|
||||
width: 192,
|
||||
margin: 1,
|
||||
color: { dark: "#00626c", light: "#ffffff" },
|
||||
});
|
||||
}
|
||||
|
||||
function flash(el, okText, errText, ok) {
|
||||
if (!el) return;
|
||||
el.textContent = ok ? okText : errText;
|
||||
el.hidden = false;
|
||||
window.setTimeout(function () {
|
||||
el.hidden = true;
|
||||
}, 2200);
|
||||
}
|
||||
|
||||
function copyText(text) {
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
return navigator.clipboard.writeText(text);
|
||||
}
|
||||
return Promise.reject(new Error("clipboard unavailable"));
|
||||
}
|
||||
|
||||
function copyCanvas(canvas) {
|
||||
if (!canvas || !canvas.toBlob) {
|
||||
return Promise.reject(new Error("canvas unavailable"));
|
||||
}
|
||||
return new Promise(function (resolve, reject) {
|
||||
canvas.toBlob(function (blob) {
|
||||
if (!blob) {
|
||||
reject(new Error("qr blob failed"));
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!navigator.clipboard ||
|
||||
!navigator.clipboard.write ||
|
||||
typeof ClipboardItem === "undefined"
|
||||
) {
|
||||
reject(new Error("image clipboard unavailable"));
|
||||
return;
|
||||
}
|
||||
navigator.clipboard
|
||||
.write([new ClipboardItem({ "image/png": blob })])
|
||||
.then(resolve, reject);
|
||||
}, "image/png");
|
||||
});
|
||||
}
|
||||
|
||||
function downloadCanvas(canvas, filename) {
|
||||
if (!canvas) return;
|
||||
var a = document.createElement("a");
|
||||
a.href = canvas.toDataURL("image/png");
|
||||
a.download = filename || "campaign-qr.png";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
}
|
||||
|
||||
function bindPanel(panel, opts) {
|
||||
if (!panel) return;
|
||||
opts = opts || {};
|
||||
var urlEl = panel.querySelector("[data-utm-url]");
|
||||
var canvas = panel.querySelector("[data-utm-qr]");
|
||||
var qrWrap = panel.querySelector("[data-utm-qr-wrap]");
|
||||
var smsHint = panel.querySelector("[data-utm-sms-hint]");
|
||||
var emailHint = panel.querySelector("[data-utm-email-hint]");
|
||||
var postcardHint = panel.querySelector("[data-utm-postcard-hint]");
|
||||
var statusEl = panel.querySelector("[data-utm-status]");
|
||||
var base = panel.getAttribute("data-base-url") || "";
|
||||
var source = sourceFrom(panel);
|
||||
var label = panel.getAttribute("data-link-label") || "Website";
|
||||
var live = panel.getAttribute("data-live") === "1";
|
||||
var shortenUrl = panel.getAttribute("data-shorten-url") || "";
|
||||
var csrf = (opts.csrfToken || panel.getAttribute("data-csrf") || "").trim();
|
||||
var shortenTimer = null;
|
||||
var shortenSeq = 0;
|
||||
|
||||
function medium() {
|
||||
if (typeof opts.getMedium === "function") return opts.getMedium();
|
||||
return panel.getAttribute("data-medium") || "email";
|
||||
}
|
||||
|
||||
function name() {
|
||||
if (typeof opts.getName === "function") return opts.getName();
|
||||
return panel.getAttribute("data-campaign-name") || "";
|
||||
}
|
||||
|
||||
function campaignId() {
|
||||
return (
|
||||
(typeof opts.getCampaignId === "function" && opts.getCampaignId()) ||
|
||||
panel.getAttribute("data-campaign-id") ||
|
||||
""
|
||||
);
|
||||
}
|
||||
|
||||
function paint(url, inject) {
|
||||
var ch = medium();
|
||||
if (urlEl) urlEl.textContent = url;
|
||||
if (qrWrap) qrWrap.hidden = ch !== "postcard";
|
||||
if (smsHint) smsHint.hidden = ch !== "sms";
|
||||
if (emailHint) emailHint.hidden = ch !== "email";
|
||||
if (postcardHint) postcardHint.hidden = ch !== "postcard";
|
||||
if (ch === "postcard" && canvas) {
|
||||
renderQr(canvas, url).catch(function () {});
|
||||
}
|
||||
if (inject && typeof opts.onUrlChange === "function") {
|
||||
opts.onUrlChange(url, ch, label);
|
||||
}
|
||||
}
|
||||
|
||||
function apply() {
|
||||
var ch = medium();
|
||||
var longUrl = buildUrl(base, ch, name(), source);
|
||||
if (!shortenUrl) {
|
||||
paint(longUrl, true);
|
||||
return longUrl;
|
||||
}
|
||||
paint(longUrl, false);
|
||||
var seq = ++shortenSeq;
|
||||
window.clearTimeout(shortenTimer);
|
||||
shortenTimer = window.setTimeout(function () {
|
||||
fetch(shortenUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
"X-CSRFToken": csrf,
|
||||
},
|
||||
credentials: "same-origin",
|
||||
body: JSON.stringify({
|
||||
name: name(),
|
||||
medium: ch,
|
||||
campaign_id: campaignId(),
|
||||
}),
|
||||
})
|
||||
.then(function (r) {
|
||||
return r.ok ? r.json() : Promise.reject();
|
||||
})
|
||||
.then(function (data) {
|
||||
if (seq !== shortenSeq) return;
|
||||
paint((data && data.display_url) || longUrl, true);
|
||||
})
|
||||
.catch(function () {
|
||||
if (seq !== shortenSeq) return;
|
||||
paint(longUrl, true);
|
||||
});
|
||||
}, SHORTEN_WAIT_MS);
|
||||
return longUrl;
|
||||
}
|
||||
|
||||
panel.querySelectorAll("[data-utm-copy-url]").forEach(function (btn) {
|
||||
btn.addEventListener("click", function () {
|
||||
var url = (urlEl && urlEl.textContent) || "";
|
||||
copyText(url).then(
|
||||
function () {
|
||||
flash(statusEl, "Link copied.", "Could not copy.", true);
|
||||
},
|
||||
function () {
|
||||
flash(statusEl, "", "Could not copy link.", false);
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
panel.querySelectorAll("[data-utm-copy-qr]").forEach(function (btn) {
|
||||
btn.addEventListener("click", function () {
|
||||
copyCanvas(canvas).then(
|
||||
function () {
|
||||
flash(statusEl, "QR image copied.", "Could not copy QR.", true);
|
||||
},
|
||||
function () {
|
||||
flash(statusEl, "", "Copy failed — use Download PNG.", false);
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
panel.querySelectorAll("[data-utm-download-qr]").forEach(function (btn) {
|
||||
btn.addEventListener("click", function () {
|
||||
downloadCanvas(canvas, "qr-" + slug(name()) + ".png");
|
||||
flash(statusEl, "QR downloaded.", "", true);
|
||||
});
|
||||
});
|
||||
|
||||
if (live) {
|
||||
var nameField = document.getElementById("id_name");
|
||||
if (nameField) nameField.addEventListener("input", apply);
|
||||
}
|
||||
|
||||
panel._utmApply = apply;
|
||||
apply();
|
||||
return { apply: apply, buildUrl: buildUrl };
|
||||
}
|
||||
|
||||
global.CampaignUtm = {
|
||||
slug: slug,
|
||||
buildUrl: buildUrl,
|
||||
replaceTextUrl: replaceTextUrl,
|
||||
linkify: linkify,
|
||||
ensureQuillLink: ensureQuillLink,
|
||||
bindPanel: bindPanel,
|
||||
};
|
||||
})(window);
|
||||
Reference in New Issue
Block a user