Mint campaign UTM links through the piha.li shortener.
CI / test (pull_request) Successful in 21s

Closes #9.

Auto-insert tracked homepage links in campaign compose, register them with
url_shortening_service, and put short piha.li URLs in SMS, email hrefs, and
postcard QR codes when SHORTENER_* is configured.
This commit is contained in:
2026-08-30 15:19:57 -05:00
parent dc0f3b2908
commit ef0655e8d5
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_BASE_URL=http://10.0.0.128:11434
OLLAMA_MODEL=llama3.2 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 (LAN) — address autocomplete via Django /api/address-suggest/
# Nominatim has no built-in API keys; optional NOMINATIM_API_KEY only if you # Nominatim has no built-in API keys; optional NOMINATIM_API_KEY only if you
# put a gateway in front that checks X-API-Key. # 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_MODEL=llama3.2
OLLAMA_TIMEOUT_SECONDS=120 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 address suggest (server-side proxy only; not called from browser)
NOMINATIM_BASE_URL=http://10.0.0.128:8089 NOMINATIM_BASE_URL=http://10.0.0.128:8089
NOMINATIM_TIMEOUT_SECONDS=8 NOMINATIM_TIMEOUT_SECONDS=8
@@ -123,3 +130,5 @@ GUNICORN_BIND=0.0.0.0:8000
# OLLAMA_BASE_URL=http://10.0.0.128:11434 # OLLAMA_BASE_URL=http://10.0.0.128:11434
# NOMINATIM_BASE_URL=http://10.0.0.128:8089 # NOMINATIM_BASE_URL=http://10.0.0.128:8089
# NOMINATIM_COUNTRY_CODES=us # 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 ./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 ## Deploy
CI on merge to `main` → tests → `server-infra/scripts/deploy.sh --app monica_site --env prod --ref <sha>`. 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_ID: ${META_APP_ID:-}
META_APP_SECRET: ${META_APP_SECRET:-} META_APP_SECRET: ${META_APP_SECRET:-}
SOCIAL_TOKEN_ENCRYPTION_KEY: ${SOCIAL_TOKEN_ENCRYPTION_KEY:-} SOCIAL_TOKEN_ENCRYPTION_KEY: ${SOCIAL_TOKEN_ENCRYPTION_KEY:-}
SHORTENER_BASE_URL: ${SHORTENER_BASE_URL:-}
SHORTENER_API_TOKEN: ${SHORTENER_API_TOKEN:-}
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy
@@ -102,6 +104,8 @@ services:
META_APP_ID: ${META_APP_ID:-} META_APP_ID: ${META_APP_ID:-}
META_APP_SECRET: ${META_APP_SECRET:-} META_APP_SECRET: ${META_APP_SECRET:-}
SOCIAL_TOKEN_ENCRYPTION_KEY: ${SOCIAL_TOKEN_ENCRYPTION_KEY:-} SOCIAL_TOKEN_ENCRYPTION_KEY: ${SOCIAL_TOKEN_ENCRYPTION_KEY:-}
SHORTENER_BASE_URL: ${SHORTENER_BASE_URL:-}
SHORTENER_API_TOKEN: ${SHORTENER_API_TOKEN:-}
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy
+2
View File
@@ -28,6 +28,8 @@ if [[ "$DJANGO_ENV" == "prod" || "$DJANGO_ENV" == "beta" ]]; then
RECAPTCHA_PRIVATE_KEY RECAPTCHA_PRIVATE_KEY
EMAIL_HOST_USER EMAIL_HOST_USER
EMAIL_HOST_PASSWORD EMAIL_HOST_PASSWORD
SHORTENER_BASE_URL
SHORTENER_API_TOKEN
) )
fi 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 no longer 500 the endpoint (SMTP2GO “Test this webhook” often sends a sample
non-UUID). 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 SMS correlation uses `message_id` (SMS id), then `destination_number` phone
fallback. Do **not** treat webhook `id` as the SMS id. fallback. Do **not** treat webhook `id` as the SMS id.
+173
View File
@@ -2,10 +2,13 @@
from __future__ import annotations from __future__ import annotations
import html
import re import re
from datetime import datetime from datetime import datetime
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from urllib.parse import urlencode
from django.conf import settings
from django.core import signing from django.core import signing
from django.db.models import QuerySet from django.db.models import QuerySet
from django.urls import reverse from django.urls import reverse
@@ -13,6 +16,7 @@ from django.utils import timezone
from contacts.models import Channel, ConsentRecord, Contact, Suppression from contacts.models import Channel, ConsentRecord, Contact, Suppression
from messaging.models import Campaign, Message, MessageTemplate from messaging.models import Campaign, Message, MessageTemplate
from messaging.shortener import resolve_display_url
if TYPE_CHECKING: if TYPE_CHECKING:
from django.contrib.auth.models import AbstractBaseUser from django.contrib.auth.models import AbstractBaseUser
@@ -33,6 +37,21 @@ _MERGE_TAG_RE = re.compile(
re.IGNORECASE, 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( REMOVABLE_MESSAGE_STATUSES = frozenset(
{ {
Message.Status.DRAFT, Message.Status.DRAFT,
@@ -66,6 +85,149 @@ def render_merge_tags(text: str, contact: Contact | None) -> str:
return _MERGE_TAG_RE.sub(_replace, text) 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: def message_is_removable(message: Message) -> bool:
return message.status in REMOVABLE_MESSAGE_STATUSES return message.status in REMOVABLE_MESSAGE_STATUSES
@@ -287,6 +449,17 @@ def create_campaign_draft(
created_by=created_by, created_by=created_by,
template=template, 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)) contacts = list(opted_in_contacts(channel))
Message.objects.bulk_create( 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" %} {% extends "portal_base.html" %}
{% load static %}
{% block title %}{{ campaign.name }} · Campaign{% endblock %} {% block title %}{{ campaign.name }} · Campaign{% endblock %}
{% block topbar_title %}{{ campaign.name }}{% endblock %} {% block topbar_title %}{{ campaign.name }}{% endblock %}
{% block portal_content %} {% block portal_content %}
@@ -51,6 +52,13 @@
</div> </div>
{% endif %} {% 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-row" id="campaign-stats">
<div class="stat-card"> <div class="stat-card">
<div class="label">Messages</div> <div class="label">Messages</div>
@@ -208,7 +216,10 @@
</div> </div>
{% endblock %} {% endblock %}
{% block extra_js %} {% 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> <script>
window.MonicaUtm.bindPanel(document.getElementById("utm-link-panel"));
(function () { (function () {
var panel = document.getElementById("recipients-panel"); var panel = document.getElementById("recipients-panel");
var page = (panel && panel.getAttribute("data-page")) || "1"; var page = (panel && panel.getAttribute("data-page")) || "1";
@@ -1,4 +1,5 @@
{% extends "portal_base.html" %} {% extends "portal_base.html" %}
{% load static %}
{% block title %}Campaigns · Portal{% endblock %} {% block title %}Campaigns · Portal{% endblock %}
{% block topbar_title %}Campaign composer{% endblock %} {% block topbar_title %}Campaign composer{% endblock %}
{% block extra_head %} {% block extra_head %}
@@ -64,6 +65,7 @@
<label for="id_name">Campaign name</label> <label for="id_name">Campaign name</label>
<input id="id_name" name="name" type="text" required <input id="id_name" name="name" type="text" required
placeholder="Spring seller tips" value="{{ form_data.name }}"> 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>
<div class="field" id="subject-field"> <div class="field" id="subject-field">
<label for="id_subject">Subject</label> <label for="id_subject">Subject</label>
@@ -84,7 +86,7 @@
<textarea id="id_body_sms" style="min-height:140px" <textarea id="id_body_sms" style="min-height:140px"
placeholder="Hi {% templatetag openvariable %}first_name{% templatetag closevariable %}, …" placeholder="Hi {% templatetag openvariable %}first_name{% templatetag closevariable %}, …"
oninput="syncSmsBody()">{{ form_data.body }}</textarea> 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>
<div class="field" id="postcard-body-hint" hidden> <div class="field" id="postcard-body-hint" hidden>
<p class="hint-block" style="margin:0"> <p class="hint-block" style="margin:0">
@@ -128,6 +130,7 @@
</div> </div>
</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> <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> <button class="btn btn-primary" type="submit">Save draft</button>
</form> </form>
</div> </div>
@@ -179,6 +182,8 @@
{% endblock %} {% endblock %}
{% block extra_js %} {% 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/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> <script>
(function () { (function () {
var uploadUrl = "{{ image_upload_url|escapejs }}"; var uploadUrl = "{{ image_upload_url|escapejs }}";
@@ -186,11 +191,37 @@
var bodyField = document.getElementById('id_body'); var bodyField = document.getElementById('id_body');
var smsField = document.getElementById('id_body_sms'); var smsField = document.getElementById('id_body_sms');
var quill = null; var quill = null;
var utmLock = false;
var utmLinkLabel = "{{ utm_link_label|escapejs }}";
function csrfHeader() { function csrfHeader() {
return { 'X-CSRFToken': csrfToken }; 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() { function syncBodyFromQuill() {
if (!quill || !bodyField) return; if (!quill || !bodyField) return;
var html = quill.root.innerHTML; var html = quill.root.innerHTML;
@@ -214,6 +245,7 @@
var subject = (document.getElementById('id_subject') || {}).value || ''; var subject = (document.getElementById('id_subject') || {}).value || '';
var audience = (document.getElementById('id_audience') || {}).value || ''; var audience = (document.getElementById('id_audience') || {}).value || '';
var isEmail = audience === 'email_opt_in'; var isEmail = audience === 'email_opt_in';
var isSms = audience === 'sms_opt_in';
var isPostcard = audience === 'postcard_opt_in'; var isPostcard = audience === 'postcard_opt_in';
var body = ''; var body = '';
if (isEmail && quill) { if (isEmail && quill) {
@@ -244,6 +276,9 @@
if (isEmail) { if (isEmail) {
bodyEl.style.whiteSpace = 'normal'; bodyEl.style.whiteSpace = 'normal';
bodyEl.innerHTML = body; bodyEl.innerHTML = body;
} else if (isSms) {
bodyEl.style.whiteSpace = 'pre-wrap';
bodyEl.innerHTML = window.MonicaUtm.linkify(body);
} else { } else {
bodyEl.style.whiteSpace = 'pre-wrap'; bodyEl.style.whiteSpace = 'pre-wrap';
bodyEl.textContent = body; bodyEl.textContent = body;
@@ -283,6 +318,8 @@
var active = (ch === 'email' && isEmail) || (ch === 'sms' && isSms) || (ch === 'postcard' && isPostcard); var active = (ch === 'email' && isEmail) || (ch === 'sms' && isSms) || (ch === 'postcard' && isPostcard);
a.classList.toggle('active', active); a.classList.toggle('active', active);
}); });
var panel = document.getElementById('utm-link-panel');
if (panel && panel._utmApply) panel._utmApply();
syncCampaignPreview(); syncCampaignPreview();
}; };
@@ -341,7 +378,10 @@
} else if (initial) { } else if (initial) {
quill.setText(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 () { document.getElementById('campaign-compose').addEventListener('submit', function () {
var audience = (document.getElementById('id_audience') || {}).value || ''; var audience = (document.getElementById('id_audience') || {}).value || '';
if (audience === 'email_opt_in') syncBodyFromQuill(); if (audience === 'email_opt_in') syncBodyFromQuill();
@@ -370,6 +410,14 @@
if (tmplSelect) tmplSelect.addEventListener('change', syncCampaignPreview); if (tmplSelect) tmplSelect.addEventListener('change', syncCampaignPreview);
initQuill(); initQuill();
window.MonicaUtm.bindPanel(document.getElementById('utm-link-panel'), {
getName: function () {
return (document.getElementById('id_name') || {}).value || '';
},
getMedium: audienceChannel,
csrfToken: csrfToken,
onUrlChange: applyUtmToBodies
});
syncComposeChannel(); syncComposeChannel();
})(); })();
</script> </script>
+253 -2
View File
@@ -1,5 +1,5 @@
from django.contrib.auth import get_user_model 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 django.urls import reverse
from contacts.models import Channel, ConsentRecord, Contact, Suppression from contacts.models import Channel, ConsentRecord, Contact, Suppression
@@ -1359,6 +1359,7 @@ class CampaignRecipientTableTests(TestCase):
self.assertNotContains(response, "Recent SMTP2GO events") self.assertNotContains(response, "Recent SMTP2GO events")
@override_settings(SHORTENER_BASE_URL="", SHORTENER_API_TOKEN="")
class Smtp2goSmsSendTests(TestCase): class Smtp2goSmsSendTests(TestCase):
def setUp(self): def setUp(self):
self.contact = Contact.objects.create( self.contact = Contact.objects.create(
@@ -1416,7 +1417,9 @@ class Smtp2goSmsSendTests(TestCase):
payload = post.call_args.kwargs["json"] payload = post.call_args.kwargs["json"]
self.assertEqual(payload["api_key"], "api-test-key") self.assertEqual(payload["api_key"], "api-test-key")
self.assertEqual(payload["destination"], ["+13304022675"]) 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("to", payload)
self.assertNotIn("text", payload) self.assertNotIn("text", payload)
@@ -1450,3 +1453,251 @@ class Smtp2goSmsSendTests(TestCase):
self.assertIn("Missing required field", str(ctx.exception)) self.assertIn("Missing required field", str(ctx.exception))
self.assertIn("INVALID_REQUEST", 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 = [ urlpatterns = [
path("campaigns/", views.campaign_list, name="campaign_list"), 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>/", views.campaign_detail, name="campaign_detail"),
path( path(
"campaigns/<uuid:pk>/status.json", "campaigns/<uuid:pk>/status.json",
+78
View File
@@ -1,6 +1,7 @@
import hashlib import hashlib
import hmac import hmac
import io import io
import json
import logging import logging
from django.conf import settings from django.conf import settings
@@ -26,12 +27,17 @@ from messaging.providers.postcard.pcm import (
list_designs, list_designs,
) )
from messaging.services import ( from messaging.services import (
channel_for_audience,
create_campaign_draft, create_campaign_draft,
enqueue_campaign_send, enqueue_campaign_send,
ensure_campaign_utm_link,
message_is_removable, message_is_removable,
opted_in_contacts, opted_in_contacts,
parse_scheduled_for, parse_scheduled_for,
public_site_base_url,
public_site_link_label,
record_sms_stop, record_sms_stop,
resolve_campaign_tracked_url,
send_campaign_test_email, send_campaign_test_email,
) )
from messaging.webhooks import ( from messaging.webhooks import (
@@ -462,6 +468,16 @@ def campaign_list(request):
if not body: if not body:
body = "Postcard mailing" body = "Postcard mailing"
else: 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: if not body:
form_errors.append("Body is required.") form_errors.append("Body is required.")
if ( if (
@@ -506,10 +522,62 @@ def campaign_list(request):
"form_data": form_data, "form_data": form_data,
"form_errors": form_errors, "form_errors": form_errors,
"image_upload_url": reverse("messaging:campaign_image_upload"), "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 @login_required
def campaign_detail(request, pk): def campaign_detail(request, pk):
campaign = get_object_or_404(Campaign, pk=pk) campaign = get_object_or_404(Campaign, pk=pk)
@@ -518,6 +586,12 @@ def campaign_detail(request, pk):
except (TypeError, ValueError): except (TypeError, ValueError):
page = 1 page = 1
ctx = _campaign_report(campaign, page=page) 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( return render(
request, request,
"messaging/campaign_detail.html", "messaging/campaign_detail.html",
@@ -529,6 +603,10 @@ def campaign_detail(request, pk):
"recent_events": ctx["recent_events"], "recent_events": ctx["recent_events"],
"events_title": ctx["events_title"], "events_title": ctx["events_title"],
"events_empty": ctx["events_empty"], "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 "can_send": campaign.status
in { in {
Campaign.Status.DRAFT, Campaign.Status.DRAFT,
+7
View File
@@ -332,6 +332,13 @@ NOMINATIM_USER_AGENT = env(
) )
NOMINATIM_API_KEY = env("NOMINATIM_API_KEY", "") 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 analytics (pageviews + events; notice banner, not opt-in) ---
TIANJI_ENABLED = env_bool("TIANJI_ENABLED", not DEBUG) TIANJI_ENABLED = env_bool("TIANJI_ENABLED", not DEBUG)
TIANJI_TRACKER_URL = env( 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; 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; } .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);