Mint campaign UTM links through the piha.li shortener (#10)
Deploy Beta / unit-tests (push) Successful in 21s
Deploy Beta / docker (push) Successful in 26s
Deploy Beta / deploy-beta (push) Successful in 5m20s

## Summary
Closes #9.

- Campaign composer auto-inserts a tracked homepage link (`utm_source=monica`, `utm_medium` = channel, `utm_campaign` = slug of the name) for email, SMS, and postcard QR — no manual UTM paste.
- When `SHORTENER_BASE_URL` + `SHORTENER_API_TOKEN` are set, the app mints that long HTTPS URL via `POST /api/links/` on the shortener **API host** and puts the returned `piha.li` / `beta.piha.li` short URL in SMS, email hrefs, and QR codes. Empty env (local) falls back to the long UTM URL.
- Live composer resolves shorts through a portal JSON endpoint (login + CSRF). Browser never calls the shortener.

## Secrets (control node, not git)

**monica_site** (`~/Documents/secrets/monica_site/`):
```
# prod
SHORTENER_BASE_URL=https://shortener.aimloperations.com
SHORTENER_API_TOKEN=monica:<secret>

# beta
SHORTENER_BASE_URL=https://shortener-beta.aimloperations.com
SHORTENER_API_TOKEN=monica:<beta-secret>
```

**url_shortening_service** (same secret, named token):
```
SHORTENER_API_TOKENS=monica:<secret>
SHORT_ALLOWED_HOSTS=mkdrealtor.com,aimloperations.com
```
Prod public short host: `piha.li`. Beta: `beta.piha.li`. Generate with `python -c "import secrets; print(secrets.token_urlsafe(32))"`.

Template port: westfarn/web_django_template#3

## Test plan
- [ ] `cd site && uv run python manage.py test messaging.tests.CampaignUtmLinkTests`
- [ ] Composer: type a campaign name — email gets an HTML link, SMS gets a URL, postcard shows a QR
- [ ] With shortener env set: SMS/QR show `piha.li` (or `beta.piha.li`); without it, long UTM URL still works
- [ ] Copy/download QR into postcard designer
- [ ] Secret files have `SHORTENER_*` on both caller and operator sides before beta/prod deploy

Reviewed-on: #10
This commit was merged in pull request #10.
This commit is contained in:
2026-08-30 13:24:40 -07:00
parent dc0f3b2908
commit 2b3f10e784
17 changed files with 1082 additions and 4 deletions
+8
View File
@@ -79,6 +79,14 @@ SOCIAL_TOKEN_ENCRYPTION_KEY=
OLLAMA_BASE_URL=http://10.0.0.128:11434
OLLAMA_MODEL=llama3.2
# URL shortener (piha.li). Empty locally = long UTM URLs in campaigns.
# Call the API host, never https://piha.li (that host only 302s).
SHORTENER_BASE_URL=
SHORTENER_API_TOKEN=
# Local shortener compose:
# SHORTENER_BASE_URL=http://127.0.0.1:8005
# SHORTENER_API_TOKEN=monica:dev-only-token
# Nominatim (LAN) — address autocomplete via Django /api/address-suggest/
# Nominatim has no built-in API keys; optional NOMINATIM_API_KEY only if you
# put a gateway in front that checks X-API-Key.
+9
View File
@@ -87,6 +87,13 @@ OLLAMA_BASE_URL=http://10.0.0.128:11434
OLLAMA_MODEL=llama3.2
OLLAMA_TIMEOUT_SECONDS=120
# URL shortener — mint short links for campaign SMS / email / postcard QR.
# POST /api/links/ on the API host. Recipients hit https://piha.li/<code>.
# Token must match SHORTENER_API_TOKENS on url_shortening_service (name:secret).
# Generate: python -c "import secrets; print(secrets.token_urlsafe(32))"
SHORTENER_BASE_URL=https://shortener.aimloperations.com
SHORTENER_API_TOKEN=monica:replace-me
# Nominatim address suggest (server-side proxy only; not called from browser)
NOMINATIM_BASE_URL=http://10.0.0.128:8089
NOMINATIM_TIMEOUT_SECONDS=8
@@ -123,3 +130,5 @@ GUNICORN_BIND=0.0.0.0:8000
# OLLAMA_BASE_URL=http://10.0.0.128:11434
# NOMINATIM_BASE_URL=http://10.0.0.128:8089
# NOMINATIM_COUNTRY_CODES=us
# SHORTENER_BASE_URL=https://shortener-beta.aimloperations.com
# SHORTENER_API_TOKEN=monica:replace-with-a-different-token
+11
View File
@@ -74,6 +74,17 @@ Templates: `.env.prod.example` (full var list). Validate with:
./scripts/validate-env.sh ~/Documents/secrets/monica_site/monica_site_prod.env
```
Campaign UTM short links (piha.li) need **both** sides:
| File | Vars |
|------|------|
| `monica_site_prod.env` | `SHORTENER_BASE_URL=https://shortener.aimloperations.com` · `SHORTENER_API_TOKEN=monica:<secret>` |
| `monica_site_beta.env` | `SHORTENER_BASE_URL=https://shortener-beta.aimloperations.com` · distinct `SHORTENER_API_TOKEN` |
| `url_shortening_service_prod.env` | `SHORTENER_API_TOKENS=monica:<same-secret>` · `SHORT_ALLOWED_HOSTS` includes `mkdrealtor.com` · `PUBLIC_SHORT_URL=https://piha.li` |
| `url_shortening_service_beta.env` | matching beta token · `PUBLIC_SHORT_URL=https://beta.piha.li` |
Generate the secret with `python -c "import secrets; print(secrets.token_urlsafe(32))"`. Do not put it in git. Call the API host, never `piha.li`, to mint links.
## Deploy
CI on merge to `main` → tests → `server-infra/scripts/deploy.sh --app monica_site --env prod --ref <sha>`.
+4
View File
@@ -61,6 +61,8 @@ services:
META_APP_ID: ${META_APP_ID:-}
META_APP_SECRET: ${META_APP_SECRET:-}
SOCIAL_TOKEN_ENCRYPTION_KEY: ${SOCIAL_TOKEN_ENCRYPTION_KEY:-}
SHORTENER_BASE_URL: ${SHORTENER_BASE_URL:-}
SHORTENER_API_TOKEN: ${SHORTENER_API_TOKEN:-}
depends_on:
db:
condition: service_healthy
@@ -102,6 +104,8 @@ services:
META_APP_ID: ${META_APP_ID:-}
META_APP_SECRET: ${META_APP_SECRET:-}
SOCIAL_TOKEN_ENCRYPTION_KEY: ${SOCIAL_TOKEN_ENCRYPTION_KEY:-}
SHORTENER_BASE_URL: ${SHORTENER_BASE_URL:-}
SHORTENER_API_TOKEN: ${SHORTENER_API_TOKEN:-}
depends_on:
db:
condition: service_healthy
+2
View File
@@ -28,6 +28,8 @@ if [[ "$DJANGO_ENV" == "prod" || "$DJANGO_ENV" == "beta" ]]; then
RECAPTCHA_PRIVATE_KEY
EMAIL_HOST_USER
EMAIL_HOST_PASSWORD
SHORTENER_BASE_URL
SHORTENER_API_TOKEN
)
fi
+17
View File
@@ -35,6 +35,23 @@ webhook events match the correct recipient row. Invalid / missing header values
no longer 500 the endpoint (SMTP2GO “Test this webhook” often sends a sample
non-UUID).
## Campaign tracked links (UTM + piha.li)
Composer auto-inserts a homepage link:
| Param | Value |
|-------|--------|
| `utm_source` | `monica` |
| `utm_medium` | `email` / `sms` / `postcard` |
| `utm_campaign` | hyphenated campaign name |
When `SHORTENER_BASE_URL` + `SHORTENER_API_TOKEN` are set, the app POSTs that
long HTTPS URL to `url_shortening_service` (`POST /api/links/`, Bearer
`monica:<secret>`) and puts `short_url` (`https://piha.li/<code>`) in SMS,
email hrefs, and postcard QR codes. Empty env → long UTM URL (local default).
Mint against the **API host**, not `piha.li`. See `.env.example` / `.env.prod.example`.
SMS correlation uses `message_id` (SMS id), then `destination_number` phone
fallback. Do **not** treat webhook `id` as the SMS id.
+173
View File
@@ -2,10 +2,13 @@
from __future__ import annotations
import html
import re
from datetime import datetime
from typing import TYPE_CHECKING
from urllib.parse import urlencode
from django.conf import settings
from django.core import signing
from django.db.models import QuerySet
from django.urls import reverse
@@ -13,6 +16,7 @@ from django.utils import timezone
from contacts.models import Channel, ConsentRecord, Contact, Suppression
from messaging.models import Campaign, Message, MessageTemplate
from messaging.shortener import resolve_display_url
if TYPE_CHECKING:
from django.contrib.auth.models import AbstractBaseUser
@@ -33,6 +37,21 @@ _MERGE_TAG_RE = re.compile(
re.IGNORECASE,
)
# Campaign tracked links: utm_source is always the brand; medium = channel.
UTM_SOURCE = "monica"
UTM_CAMPAIGN_SLUG_MAX = 80
_UTM_ANCHOR_RE = re.compile(r"(<a\b[^>]*>)(.*?)(</a>)", re.IGNORECASE | re.DOTALL)
_UTM_TEXT_URL_RE = re.compile(
r"https?://[^\s<>\"]+utm_source=" + re.escape(UTM_SOURCE) + r"[^\s<>\"]*",
re.IGNORECASE,
)
# Minted short links in SMS (piha.li prod, beta.piha.li, local compose).
_SHORT_TEXT_URL_RE = re.compile(
r"https?://(?:(?:www\.)?(?:beta\.)?piha\.li|(?:127\.0\.0\.1|localhost):\d+)"
r"/[a-z0-9]{4,8}",
re.IGNORECASE,
)
REMOVABLE_MESSAGE_STATUSES = frozenset(
{
Message.Status.DRAFT,
@@ -66,6 +85,149 @@ def render_merge_tags(text: str, contact: Contact | None) -> str:
return _MERGE_TAG_RE.sub(_replace, text)
def public_site_base_url() -> str:
"""Public homepage used as the UTM landing URL."""
base = (getattr(settings, "PUBLIC_SITE_URL", None) or "").rstrip("/")
if not base:
base = "https://mkdrealtor.com"
return base
def public_site_link_label() -> str:
"""Visible email-link text (host), with a stable label in local/dev."""
host = (
public_site_base_url()
.replace("https://", "")
.replace("http://", "")
.split("/")[0]
)
if host.startswith("127.") or host.startswith("localhost") or host.startswith("0.0.0.0"):
return "MKDRealtor.com"
return host or "MKDRealtor.com"
def campaign_utm_slug(name: str) -> str:
"""Lowercase hyphenated utm_campaign from the campaign name."""
slug = re.sub(r"[^a-z0-9]+", "-", (name or "").strip().lower())
slug = re.sub(r"-{2,}", "-", slug).strip("-")
return (slug or "campaign")[:UTM_CAMPAIGN_SLUG_MAX]
def build_campaign_utm_url(
*,
name: str,
medium: str,
base_url: str = "",
) -> str:
"""Homepage URL with utm_source / utm_medium / utm_campaign."""
base = (base_url or public_site_base_url()).rstrip("/")
channel = (medium or "").strip().lower()
if channel not in Channel.values:
channel = Channel.EMAIL
query = urlencode(
{
"utm_source": UTM_SOURCE,
"utm_medium": channel,
"utm_campaign": campaign_utm_slug(name),
}
)
return f"{base}/?{query}"
def campaign_shortener_ref(*, campaign_id=None, user_id=None, medium: str = "", name: str = "") -> str:
"""external_ref for the shortener (max 64). Prefer campaign UUID."""
if campaign_id:
return str(campaign_id)[:64]
slug = campaign_utm_slug(name)[:40]
ch = (medium or "email").strip().lower()[:8]
uid = "" if user_id is None else str(user_id)
return f"p{uid}-{ch}-{slug}"[:64]
def resolve_campaign_tracked_url(
*,
name: str,
medium: str,
campaign_id=None,
user_id=None,
shorten: bool = True,
) -> tuple[str, str]:
"""Return (long UTM URL, display URL). Display is short when mint works."""
target = build_campaign_utm_url(name=name, medium=medium)
if not shorten:
return target, target
display = resolve_display_url(
target,
title=f"{(name or 'campaign').strip()} ({medium})"[:200],
external_ref=campaign_shortener_ref(
campaign_id=campaign_id, user_id=user_id, medium=medium, name=name
),
)
return target, display
def _is_our_utm_anchor(attrs: str) -> bool:
lower = attrs.lower()
return "data-monica-utm" in lower or f"utm_source={UTM_SOURCE}" in lower
def ensure_campaign_utm_in_html(body: str, url: str, label: str) -> str:
"""Insert or refresh the tracked <a> in an HTML (email) body."""
href = html.escape(url, quote=True)
label_html = html.escape(label)
replaced = False
def _repl(match: re.Match[str]) -> str:
nonlocal replaced
if replaced or not _is_our_utm_anchor(match.group(1)):
return match.group(0)
replaced = True
inner = match.group(2) if (match.group(2) or "").strip() else label_html
return f'<a href="{href}" data-monica-utm="1">{inner}</a>'
out = _UTM_ANCHOR_RE.sub(_repl, body or "")
if replaced:
return out
anchor = f'<a href="{href}" data-monica-utm="1">{label_html}</a>'
text = (body or "").rstrip()
if text:
return f"{text}\n<p>{anchor}</p>"
return f"<p>{anchor}</p>"
def ensure_campaign_utm_in_text(body: str, url: str) -> str:
"""Insert or refresh the tracked URL in a plain-text (SMS) body."""
raw = body or ""
if _UTM_TEXT_URL_RE.search(raw):
return _UTM_TEXT_URL_RE.sub(url, raw, count=1)
if _SHORT_TEXT_URL_RE.search(raw):
return _SHORT_TEXT_URL_RE.sub(url, raw, count=1)
text = raw.rstrip()
return f"{text}\n\n{url}" if text else url
def ensure_campaign_utm_link(
body: str,
*,
name: str,
medium: str,
html: bool,
campaign_id=None,
shorten: bool | None = None,
) -> str:
"""Keep a tracked site link in the body, matching campaign name + channel."""
do_shorten = bool(campaign_id) if shorten is None else shorten
_target, url = resolve_campaign_tracked_url(
name=name,
medium=medium,
campaign_id=campaign_id,
shorten=do_shorten,
)
if html:
return ensure_campaign_utm_in_html(body, url, public_site_link_label())
return ensure_campaign_utm_in_text(body, url)
def message_is_removable(message: Message) -> bool:
return message.status in REMOVABLE_MESSAGE_STATUSES
@@ -287,6 +449,17 @@ def create_campaign_draft(
created_by=created_by,
template=template,
)
if channel in (Channel.EMAIL, Channel.SMS):
body = ensure_campaign_utm_link(
body,
name=name,
medium=channel,
html=(channel == Channel.EMAIL),
campaign_id=campaign.pk,
)
if body != campaign.body_override:
campaign.body_override = body
campaign.save(update_fields=["body_override", "updated_at"])
contacts = list(opted_in_contacts(channel))
Message.objects.bulk_create(
[
+87
View File
@@ -0,0 +1,87 @@
"""Server-to-server client for url_shortening_service (piha.li).
Call the API host (`SHORTENER_BASE_URL`), never the public short domain.
Auth: Authorization: Bearer <SHORTENER_API_TOKEN> (name:secret).
"""
from __future__ import annotations
import logging
import requests
from django.conf import settings
logger = logging.getLogger(__name__)
def is_configured() -> bool:
base = (getattr(settings, "SHORTENER_BASE_URL", None) or "").strip()
token = (getattr(settings, "SHORTENER_API_TOKEN", None) or "").strip()
return bool(base and token)
def mint_short_url(
target_url: str,
*,
title: str = "",
external_ref: str = "",
) -> str | None:
"""POST /api/links/. Return short_url, or None if unconfigured / failed.
Does not raise. Compose must still work when the shortener is down or unset.
"""
if not is_configured():
return None
target = (target_url or "").strip()
if not target.lower().startswith("https://"):
logger.info("shortener skip: target is not https")
return None
base = str(settings.SHORTENER_BASE_URL).rstrip("/")
token = str(settings.SHORTENER_API_TOKEN).strip()
timeout = int(getattr(settings, "SHORTENER_TIMEOUT_SECONDS", 10) or 10)
try:
response = requests.post(
f"{base}/api/links/",
headers={
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
},
json={
"target_url": target,
"title": (title or "")[:200],
"external_ref": (external_ref or "")[:64],
},
timeout=timeout,
)
except requests.RequestException:
logger.exception("shortener request failed")
return None
if response.status_code not in (200, 201):
logger.warning(
"shortener mint failed status=%s",
response.status_code,
)
return None
try:
data = response.json()
except ValueError:
logger.warning("shortener mint returned non-json")
return None
if not isinstance(data, dict):
return None
short = (data.get("short_url") or "").strip()
return short or None
def resolve_display_url(
target_url: str,
*,
title: str = "",
external_ref: str = "",
) -> str:
"""Short URL when mint succeeds, otherwise the original target."""
return mint_short_url(
target_url, title=title, external_ref=external_ref
) or target_url
@@ -0,0 +1,42 @@
<div class="utm-panel" id="utm-link-panel"
data-base-url="{{ utm_base_url }}"
data-link-label="{{ utm_link_label }}"
data-medium="{{ utm_medium|default:'' }}"
data-campaign-name="{{ utm_campaign_name|default:'' }}"
data-shorten-url="{{ utm_shorten_url|default:'' }}"
data-campaign-id="{% if campaign %}{{ campaign.pk }}{% endif %}"
data-csrf="{{ csrf_token }}"
data-live="{% if utm_live %}1{% else %}0{% endif %}">
<div class="field" style="margin:0">
<label>Tracked site link</label>
<p class="hint" data-utm-email-hint hidden>
Auto-added to the email as a styled link. Updates as you type the campaign name.
<code>utm_medium=email</code> · <code>utm_campaign</code> matches the name.
Recipients tap a short <code>piha.li</code> link that redirects to this URL.
</p>
<p class="hint" data-utm-sms-hint hidden>
SMS is plain text — phones cannot show HTML links. A short <code>piha.li</code>
URL is in the message and phones make it tappable. Preview below shows it as a link.
</p>
<p class="hint" data-utm-postcard-hint hidden>
Copy the QR image into the postcard designer. Recipients scan it to open the
short tracked link (<code>piha.li</code>).
</p>
<code class="utm-url" data-utm-url>{{ utm_url|default:"" }}</code>
<div class="utm-actions">
<button type="button" class="btn btn-ghost btn-sm" data-utm-copy-url>Copy link</button>
</div>
<div class="utm-qr" data-utm-qr-wrap hidden>
<canvas data-utm-qr width="192" height="192" aria-label="Campaign QR code"></canvas>
<div class="utm-qr-actions">
<button type="button" class="btn btn-ghost btn-sm" data-utm-copy-qr>Copy QR image</button>
<button type="button" class="btn btn-ghost btn-sm" data-utm-download-qr>Download PNG</button>
<p class="hint" style="margin-top:8px">
Paste or upload the PNG in
<a href="{% url 'messaging:postcard_designer' %}">Postcard design</a>.
</p>
</div>
</div>
<p class="hint" data-utm-status hidden></p>
</div>
</div>
@@ -1,4 +1,5 @@
{% extends "portal_base.html" %}
{% load static %}
{% block title %}{{ campaign.name }} · Campaign{% endblock %}
{% block topbar_title %}{{ campaign.name }}{% endblock %}
{% block portal_content %}
@@ -51,6 +52,13 @@
</div>
{% endif %}
<div class="panel" style="margin-bottom:16px">
<div class="panel-h"><h2>Tracked site link</h2></div>
<div class="panel-b">
{% include "messaging/_utm_link_panel.html" with utm_live=False utm_medium=campaign.channel utm_campaign_name=campaign.name %}
</div>
</div>
<div class="stat-row" id="campaign-stats">
<div class="stat-card">
<div class="label">Messages</div>
@@ -208,7 +216,10 @@
</div>
{% endblock %}
{% block extra_js %}
<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>
window.MonicaUtm.bindPanel(document.getElementById("utm-link-panel"));
(function () {
var panel = document.getElementById("recipients-panel");
var page = (panel && panel.getAttribute("data-page")) || "1";
@@ -1,4 +1,5 @@
{% extends "portal_base.html" %}
{% load static %}
{% block title %}Campaigns · Portal{% endblock %}
{% block topbar_title %}Campaign composer{% endblock %}
{% block extra_head %}
@@ -64,6 +65,7 @@
<label for="id_name">Campaign name</label>
<input id="id_name" name="name" type="text" required
placeholder="Spring seller tips" value="{{ form_data.name }}">
<div class="hint">Used as <code>utm_campaign</code> on the tracked site link.</div>
</div>
<div class="field" id="subject-field">
<label for="id_subject">Subject</label>
@@ -84,7 +86,7 @@
<textarea id="id_body_sms" style="min-height:140px"
placeholder="Hi {% templatetag openvariable %}first_name{% templatetag closevariable %}, …"
oninput="syncSmsBody()">{{ form_data.body }}</textarea>
<div class="hint">Plain text for SMS · keep it short · merge tags: <code>{% templatetag openvariable %}first_name{% templatetag closevariable %}</code>, <code>{% templatetag openvariable %}last_name{% templatetag closevariable %}</code></div>
<div class="hint">Plain text for SMS · keep it short · merge tags: <code>{% templatetag openvariable %}first_name{% templatetag closevariable %}</code>, <code>{% templatetag openvariable %}last_name{% templatetag closevariable %}</code>. Tracked site URL is added automatically (SMS cannot use HTML links).</div>
</div>
<div class="field" id="postcard-body-hint" hidden>
<p class="hint-block" style="margin:0">
@@ -128,6 +130,7 @@
</div>
</div>
<p class="hint-block">Saves a draft campaign and recipient stubs. Send from the campaign report when ready. Youll get an email when the send finishes.</p>
{% include "messaging/_utm_link_panel.html" with utm_live=True %}
<button class="btn btn-primary" type="submit">Save draft</button>
</form>
</div>
@@ -179,6 +182,8 @@
{% endblock %}
{% block extra_js %}
<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>
(function () {
var uploadUrl = "{{ image_upload_url|escapejs }}";
@@ -186,11 +191,37 @@
var bodyField = document.getElementById('id_body');
var smsField = document.getElementById('id_body_sms');
var quill = null;
var utmLock = false;
var utmLinkLabel = "{{ utm_link_label|escapejs }}";
function csrfHeader() {
return { 'X-CSRFToken': csrfToken };
}
function audienceChannel() {
var audience = (document.getElementById('id_audience') || {}).value || '';
if (audience === 'sms_opt_in') return 'sms';
if (audience === 'postcard_opt_in') return 'postcard';
return 'email';
}
function applyUtmToBodies(url, channel, label) {
if (utmLock) return;
utmLock = true;
try {
if (channel === 'email' && quill) {
window.MonicaUtm.ensureQuillLink(quill, url, label || utmLinkLabel);
syncBodyFromQuill();
} else if (channel === 'sms' && smsField) {
smsField.value = window.MonicaUtm.replaceTextUrl(smsField.value, url);
if (bodyField) bodyField.value = smsField.value;
syncCampaignPreview();
}
} finally {
utmLock = false;
}
}
function syncBodyFromQuill() {
if (!quill || !bodyField) return;
var html = quill.root.innerHTML;
@@ -214,6 +245,7 @@
var subject = (document.getElementById('id_subject') || {}).value || '';
var audience = (document.getElementById('id_audience') || {}).value || '';
var isEmail = audience === 'email_opt_in';
var isSms = audience === 'sms_opt_in';
var isPostcard = audience === 'postcard_opt_in';
var body = '';
if (isEmail && quill) {
@@ -244,6 +276,9 @@
if (isEmail) {
bodyEl.style.whiteSpace = 'normal';
bodyEl.innerHTML = body;
} else if (isSms) {
bodyEl.style.whiteSpace = 'pre-wrap';
bodyEl.innerHTML = window.MonicaUtm.linkify(body);
} else {
bodyEl.style.whiteSpace = 'pre-wrap';
bodyEl.textContent = body;
@@ -283,6 +318,8 @@
var active = (ch === 'email' && isEmail) || (ch === 'sms' && isSms) || (ch === 'postcard' && isPostcard);
a.classList.toggle('active', active);
});
var panel = document.getElementById('utm-link-panel');
if (panel && panel._utmApply) panel._utmApply();
syncCampaignPreview();
};
@@ -341,7 +378,10 @@
} else if (initial) {
quill.setText(initial);
}
quill.on('text-change', syncBodyFromQuill);
quill.on('text-change', function () {
if (utmLock) return;
syncBodyFromQuill();
});
document.getElementById('campaign-compose').addEventListener('submit', function () {
var audience = (document.getElementById('id_audience') || {}).value || '';
if (audience === 'email_opt_in') syncBodyFromQuill();
@@ -370,6 +410,14 @@
if (tmplSelect) tmplSelect.addEventListener('change', syncCampaignPreview);
initQuill();
window.MonicaUtm.bindPanel(document.getElementById('utm-link-panel'), {
getName: function () {
return (document.getElementById('id_name') || {}).value || '';
},
getMedium: audienceChannel,
csrfToken: csrfToken,
onUrlChange: applyUtmToBodies
});
syncComposeChannel();
})();
</script>
+253 -2
View File
@@ -1,5 +1,5 @@
from django.contrib.auth import get_user_model
from django.test import Client, TestCase
from django.test import Client, TestCase, override_settings
from django.urls import reverse
from contacts.models import Channel, ConsentRecord, Contact, Suppression
@@ -1359,6 +1359,7 @@ class CampaignRecipientTableTests(TestCase):
self.assertNotContains(response, "Recent SMTP2GO events")
@override_settings(SHORTENER_BASE_URL="", SHORTENER_API_TOKEN="")
class Smtp2goSmsSendTests(TestCase):
def setUp(self):
self.contact = Contact.objects.create(
@@ -1416,7 +1417,9 @@ class Smtp2goSmsSendTests(TestCase):
payload = post.call_args.kwargs["json"]
self.assertEqual(payload["api_key"], "api-test-key")
self.assertEqual(payload["destination"], ["+13304022675"])
self.assertEqual(payload["content"], "Hello Rufus")
self.assertTrue(payload["content"].startswith("Hello Rufus"))
self.assertIn("utm_source=monica", payload["content"])
self.assertIn("utm_medium=sms", payload["content"])
self.assertNotIn("to", payload)
self.assertNotIn("text", payload)
@@ -1450,3 +1453,251 @@ class Smtp2goSmsSendTests(TestCase):
self.assertIn("Missing required field", str(ctx.exception))
self.assertIn("INVALID_REQUEST", str(ctx.exception))
@override_settings(SHORTENER_BASE_URL="", SHORTENER_API_TOKEN="")
class CampaignUtmLinkTests(TestCase):
def setUp(self):
User = get_user_model()
self.user = User.objects.create_user(
username="utm-composer", password="test-pass-123"
)
self.client = Client()
self.client.login(username="utm-composer", password="test-pass-123")
self.contact = Contact.objects.create(
email="pat@example.com",
phone="5550100199",
first_name="Pat",
)
set_channel_consent(
self.contact, Channel.EMAIL, opted_in=True, reason="test"
)
set_channel_consent(
self.contact, Channel.SMS, opted_in=True, reason="test"
)
def test_slug_and_url_shape(self):
from messaging.services import (
build_campaign_utm_url,
campaign_utm_slug,
)
self.assertEqual(campaign_utm_slug("Spring seller tips"), "spring-seller-tips")
with self.settings(PUBLIC_SITE_URL="https://mkdrealtor.com"):
url = build_campaign_utm_url(
name="Spring seller tips", medium="email"
)
self.assertEqual(
url,
"https://mkdrealtor.com/?utm_source=monica"
"&utm_medium=email&utm_campaign=spring-seller-tips",
)
def test_email_draft_inserts_html_link(self):
with self.settings(PUBLIC_SITE_URL="https://mkdrealtor.com"):
campaign = create_campaign_draft(
name="Spring seller tips",
audience=Campaign.Audience.EMAIL_OPT_IN,
subject="Hello",
body="<p>Hi there</p>",
created_by=self.user,
)
self.assertIn('data-monica-utm="1"', campaign.body_override)
self.assertIn("utm_medium=email", campaign.body_override)
self.assertIn("utm_campaign=spring-seller-tips", campaign.body_override)
self.assertIn("Hi there", campaign.body_override)
def test_sms_draft_inserts_plain_url(self):
with self.settings(PUBLIC_SITE_URL="https://mkdrealtor.com"):
campaign = create_campaign_draft(
name="Open house",
audience=Campaign.Audience.SMS_OPT_IN,
body="See you Saturday",
created_by=self.user,
)
self.assertIn("See you Saturday", campaign.body_override)
self.assertIn(
"https://mkdrealtor.com/?utm_source=monica&utm_medium=sms"
"&utm_campaign=open-house",
campaign.body_override,
)
self.assertNotIn("<a ", campaign.body_override)
def test_sms_replaces_existing_tracked_url(self):
from messaging.services import ensure_campaign_utm_link
with self.settings(PUBLIC_SITE_URL="https://mkdrealtor.com"):
old = (
"Hi\n\nhttps://mkdrealtor.com/?utm_source=monica"
"&utm_medium=sms&utm_campaign=old-name"
)
updated = ensure_campaign_utm_link(
old, name="New name", medium="sms", html=False
)
self.assertIn("utm_campaign=new-name", updated)
self.assertNotIn("old-name", updated)
self.assertEqual(updated.count("https://"), 1)
def test_postcard_does_not_inject_into_body(self):
campaign = create_campaign_draft(
name="March mailer",
audience=Campaign.Audience.POSTCARD_OPT_IN,
body="Internal note only",
created_by=self.user,
)
self.assertEqual(campaign.body_override, "Internal note only")
self.assertNotIn("utm_source", campaign.body_override)
def test_composer_shows_tracked_link_panel(self):
url = reverse("messaging:campaign_list")
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Tracked site link")
self.assertContains(response, "campaign-utm.js")
self.assertContains(response, "utm_campaign")
self.assertContains(response, reverse("messaging:campaign_short_link"))
def test_detail_shows_tracked_url(self):
with self.settings(PUBLIC_SITE_URL="https://mkdrealtor.com"):
campaign = create_campaign_draft(
name="Spring seller tips",
audience=Campaign.Audience.EMAIL_OPT_IN,
subject="Hello",
body="Hi",
created_by=self.user,
)
url = reverse(
"messaging:campaign_detail", kwargs={"pk": campaign.pk}
)
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "utm_campaign=spring-seller-tips")
self.assertContains(response, "utm_medium=email")
def test_sms_replaces_existing_short_url(self):
from messaging.services import ensure_campaign_utm_in_text
updated = ensure_campaign_utm_in_text(
"Hi\n\nhttps://piha.li/a3k9xm",
"https://piha.li/zzzzzz",
)
self.assertEqual(updated, "Hi\n\nhttps://piha.li/zzzzzz")
def test_shortener_mints_into_sms_body(self):
from unittest.mock import MagicMock, patch
mock = MagicMock()
mock.status_code = 201
mock.json.return_value = {
"code": "a3k9xm",
"short_url": "https://piha.li/a3k9xm",
"target_url": (
"https://mkdrealtor.com/?utm_source=monica"
"&utm_medium=sms&utm_campaign=open-house"
),
}
with self.settings(
PUBLIC_SITE_URL="https://mkdrealtor.com",
SHORTENER_BASE_URL="https://shortener.aimloperations.com",
SHORTENER_API_TOKEN="monica:test-token",
):
with patch(
"messaging.shortener.requests.post", return_value=mock
) as post:
campaign = create_campaign_draft(
name="Open house",
audience=Campaign.Audience.SMS_OPT_IN,
body="See you Saturday",
created_by=self.user,
)
self.assertIn("https://piha.li/a3k9xm", campaign.body_override)
self.assertIn("See you Saturday", campaign.body_override)
self.assertNotIn("utm_medium=sms", campaign.body_override)
post.assert_called_once()
headers = post.call_args.kwargs["headers"]
self.assertEqual(headers["Authorization"], "Bearer monica:test-token")
payload = post.call_args.kwargs["json"]
self.assertIn("utm_medium=sms", payload["target_url"])
self.assertIn("utm_campaign=open-house", payload["target_url"])
self.assertEqual(payload["external_ref"], str(campaign.pk))
self.assertEqual(campaign.messages.get().body_snapshot, campaign.body_override)
def test_shortener_failure_falls_back_to_long_url(self):
from unittest.mock import MagicMock, patch
mock = MagicMock()
mock.status_code = 401
mock.text = '{"detail":"Unauthorized"}'
with self.settings(
PUBLIC_SITE_URL="https://mkdrealtor.com",
SHORTENER_BASE_URL="https://shortener.aimloperations.com",
SHORTENER_API_TOKEN="monica:bad",
):
with patch("messaging.shortener.requests.post", return_value=mock):
campaign = create_campaign_draft(
name="Open house",
audience=Campaign.Audience.SMS_OPT_IN,
body="See you Saturday",
created_by=self.user,
)
self.assertIn(
"https://mkdrealtor.com/?utm_source=monica&utm_medium=sms"
"&utm_campaign=open-house",
campaign.body_override,
)
def test_short_link_endpoint_requires_login(self):
client = Client()
url = reverse("messaging:campaign_short_link")
response = client.post(
url,
data='{"name":"Open house","medium":"sms"}',
content_type="application/json",
)
self.assertEqual(response.status_code, 302)
def test_short_link_endpoint_unconfigured_returns_long_url(self):
url = reverse("messaging:campaign_short_link")
with self.settings(
PUBLIC_SITE_URL="https://mkdrealtor.com",
SHORTENER_BASE_URL="",
SHORTENER_API_TOKEN="",
):
response = self.client.post(
url,
data='{"name":"Open house","medium":"sms"}',
content_type="application/json",
)
self.assertEqual(response.status_code, 200)
data = response.json()
self.assertFalse(data["shortened"])
self.assertIn("utm_medium=sms", data["display_url"])
self.assertIn("utm_campaign=open-house", data["display_url"])
def test_short_link_endpoint_mints(self):
from unittest.mock import MagicMock, patch
mock = MagicMock()
mock.status_code = 201
mock.json.return_value = {
"code": "a3k9xm",
"short_url": "https://piha.li/a3k9xm",
"target_url": "https://mkdrealtor.com/?utm_source=monica",
}
url = reverse("messaging:campaign_short_link")
with self.settings(
PUBLIC_SITE_URL="https://mkdrealtor.com",
SHORTENER_BASE_URL="https://shortener.aimloperations.com",
SHORTENER_API_TOKEN="monica:test-token",
):
with patch("messaging.shortener.requests.post", return_value=mock):
response = self.client.post(
url,
data='{"name":"Open house","medium":"sms"}',
content_type="application/json",
)
self.assertEqual(response.status_code, 200)
data = response.json()
self.assertTrue(data["shortened"])
self.assertEqual(data["display_url"], "https://piha.li/a3k9xm")
self.assertIn("utm_medium=sms", data["target_url"])
+5
View File
@@ -6,6 +6,11 @@ app_name = "messaging"
urlpatterns = [
path("campaigns/", views.campaign_list, name="campaign_list"),
path(
"campaigns/short-link/",
views.campaign_short_link,
name="campaign_short_link",
),
path("campaigns/<uuid:pk>/", views.campaign_detail, name="campaign_detail"),
path(
"campaigns/<uuid:pk>/status.json",
+78
View File
@@ -1,6 +1,7 @@
import hashlib
import hmac
import io
import json
import logging
from django.conf import settings
@@ -26,12 +27,17 @@ from messaging.providers.postcard.pcm import (
list_designs,
)
from messaging.services import (
channel_for_audience,
create_campaign_draft,
enqueue_campaign_send,
ensure_campaign_utm_link,
message_is_removable,
opted_in_contacts,
parse_scheduled_for,
public_site_base_url,
public_site_link_label,
record_sms_stop,
resolve_campaign_tracked_url,
send_campaign_test_email,
)
from messaging.webhooks import (
@@ -462,6 +468,16 @@ def campaign_list(request):
if not body:
body = "Postcard mailing"
else:
if audience in Campaign.Audience.values:
channel = channel_for_audience(audience)
if channel in (Channel.EMAIL, Channel.SMS):
body = ensure_campaign_utm_link(
body,
name=name or "campaign",
medium=channel,
html=(channel == Channel.EMAIL),
)
form_data["body"] = body
if not body:
form_errors.append("Body is required.")
if (
@@ -506,10 +522,62 @@ def campaign_list(request):
"form_data": form_data,
"form_errors": form_errors,
"image_upload_url": reverse("messaging:campaign_image_upload"),
"utm_base_url": public_site_base_url(),
"utm_link_label": public_site_link_label(),
"utm_shorten_url": reverse("messaging:campaign_short_link"),
"utm_url": "",
},
)
@login_required
@require_POST
def campaign_short_link(request):
"""Mint (or reuse) a short URL for the campaign tracked landing link.
Browser talks to this portal endpoint only. The shortener is server-to-server.
"""
try:
payload = json.loads(request.body.decode() or "{}")
except (json.JSONDecodeError, UnicodeDecodeError):
payload = {}
if not isinstance(payload, dict):
payload = {}
name = (payload.get("name") or request.POST.get("name") or "").strip()
medium = (payload.get("medium") or request.POST.get("medium") or "").strip()
campaign_id = (
payload.get("campaign_id") or request.POST.get("campaign_id") or ""
)
campaign_id = str(campaign_id).strip()
if campaign_id:
try:
campaign = Campaign.objects.filter(pk=campaign_id).first()
except (ValidationError, ValueError):
campaign = None
if campaign is None:
return JsonResponse({"detail": "Campaign not found."}, status=404)
name = name or campaign.name
medium = medium or campaign.channel
campaign_id = str(campaign.pk)
else:
campaign_id = None
target, display = resolve_campaign_tracked_url(
name=name or "campaign",
medium=medium,
campaign_id=campaign_id,
user_id=getattr(request.user, "pk", None),
shorten=True,
)
return JsonResponse(
{
"target_url": target,
"short_url": display if display != target else "",
"display_url": display,
"shortened": display != target,
}
)
@login_required
def campaign_detail(request, pk):
campaign = get_object_or_404(Campaign, pk=pk)
@@ -518,6 +586,12 @@ def campaign_detail(request, pk):
except (TypeError, ValueError):
page = 1
ctx = _campaign_report(campaign, page=page)
_target, display_url = resolve_campaign_tracked_url(
name=campaign.name,
medium=campaign.channel,
campaign_id=campaign.pk,
shorten=True,
)
return render(
request,
"messaging/campaign_detail.html",
@@ -529,6 +603,10 @@ def campaign_detail(request, pk):
"recent_events": ctx["recent_events"],
"events_title": ctx["events_title"],
"events_empty": ctx["events_empty"],
"utm_url": display_url,
"utm_base_url": public_site_base_url(),
"utm_link_label": public_site_link_label(),
"utm_shorten_url": reverse("messaging:campaign_short_link"),
"can_send": campaign.status
in {
Campaign.Status.DRAFT,
+7
View File
@@ -332,6 +332,13 @@ NOMINATIM_USER_AGENT = env(
)
NOMINATIM_API_KEY = env("NOMINATIM_API_KEY", "")
# --- URL shortener (piha.li) ---
# Call the API host, never the public short domain. Empty = skip minting
# (composer falls back to the long UTM URL). Token format: name:secret.
SHORTENER_BASE_URL = env("SHORTENER_BASE_URL", "")
SHORTENER_API_TOKEN = env("SHORTENER_API_TOKEN", "")
SHORTENER_TIMEOUT_SECONDS = int(env("SHORTENER_TIMEOUT_SECONDS", "10") or "10")
# --- Tianji analytics (pageviews + events; notice banner, not opt-in) ---
TIANJI_ENABLED = env_bool("TIANJI_ENABLED", not DEBUG)
TIANJI_TRACKER_URL = env(
+36
View File
@@ -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); }
+289
View File
@@ -0,0 +1,289 @@
/**
* Campaign UTM link helper (composer + report).
* utm_source=monica, utm_medium=email|sms|postcard, utm_campaign=slug(name).
* Short URLs come from the portal JSON endpoint (server mints via piha.li).
*/
(function (global) {
"use strict";
var SOURCE = "monica";
var SLUG_MAX = 80;
var SHORTEN_WAIT_MS = 400;
var UTM_URL_RE = new RegExp(
"https?:\\/\\/[^\\s<>\"]+utm_source=" + SOURCE + "[^\\s<>\"]*",
"i"
);
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 buildUrl(base, medium, name) {
var root = String(base || "").replace(/\/+$/, "") || "https://mkdrealtor.com";
var ch = String(medium || "email").toLowerCase();
if (ch !== "email" && ch !== "sms" && ch !== "postcard") ch = "email";
var q =
"utm_source=" +
encodeURIComponent(SOURCE) +
"&utm_medium=" +
encodeURIComponent(ch) +
"&utm_campaign=" +
encodeURIComponent(slug(name));
return root + "/?" + q;
}
function replaceTextUrl(text, url) {
var raw = String(text || "");
if (UTM_URL_RE.test(raw)) {
return raw.replace(UTM_URL_RE, 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 (
{ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[
c
] || c
);
});
return esc.replace(
/(https?:\/\/[^\s]+)/g,
'<a href="$1" rel="noopener noreferrer">$1</a>'
);
}
function ensureQuillLink(quill, url, label) {
if (!quill || !quill.root) return;
var existing = quill.root.querySelectorAll(
'a[data-monica-utm], a[href*="utm_source=' + SOURCE + '"], a[href*="piha.li/"]'
);
if (existing.length) {
existing.forEach(function (a) {
a.setAttribute("href", url);
a.setAttribute("data-monica-utm", "1");
});
return;
}
var labelText = label || "MKDRealtor.com";
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-monica-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 label = panel.getAttribute("data-link-label") || "MKDRealtor.com";
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());
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 () {
var slugName = slug(name());
downloadCanvas(canvas, "qr-" + slugName + ".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.MonicaUtm = {
SOURCE: SOURCE,
slug: slug,
buildUrl: buildUrl,
replaceTextUrl: replaceTextUrl,
linkify: linkify,
ensureQuillLink: ensureQuillLink,
bindPanel: bindPanel,
};
})(window);