m's Q4 lock-in (2026-05-20): retrofit the richest existing modal —
broadcast.ts (bulk team-email compose) — onto the unified primitive to
demonstrate its generality on a real-world surface.
Changes:
- Body is built imperatively (renderBody + wireBody) and handed to
openModal as the body element. The submit logic reads form state
from that element on primary-handler invocation.
- Drops the per-modal ESC + close + backdrop + overlay-stack handlers
— the primitive owns them.
- Drops the bespoke .modal-broadcast { width / max-height / padding /
label / input / textarea } CSS overrides. The primitive's data-size
handles width; the existing .form-field rules handle inputs; only
the textarea's code-monospace font is kept as a broadcast-specific
override (placeholder syntax needs to read as code).
- Primary action is "Senden (N)" — clicks invoke the existing
onSubmit logic which POSTs to /api/team/broadcast and on success
shows the per-recipient report inline then closes via the
setTimeout(close, 2500) pattern.
The recipient-list toggle + template dropdown + markdown placeholder
hints are unchanged.
i18n + the .broadcast-recipient-* / .broadcast-recip-* / .broadcast-hint
/ .broadcast-error / .broadcast-success content classes are unchanged.
288 lines
11 KiB
TypeScript
288 lines
11 KiB
TypeScript
// broadcast.ts — bulk team-email compose modal (t-paliad-147 / issue #7,
|
|
// retrofitted onto the unified modal primitive in t-paliad-217 Slice D).
|
|
//
|
|
// Exposes openBroadcastModal({ recipients, projectIDs }) which the /team
|
|
// page calls when the "E-Mail an Auswahl" button is clicked. The modal
|
|
// collects subject + body + (optional) template and posts to
|
|
// /api/team/broadcast. On success it shows a per-recipient send report
|
|
// and closes after a short delay.
|
|
//
|
|
// Per-recipient privacy: each member receives their own envelope. The
|
|
// modal lists every addressee so the sender knows exactly who will be
|
|
// mailed; there is no surprise to-line.
|
|
//
|
|
// Migration notes (t-paliad-217 Slice D): the shell, ESC, backdrop,
|
|
// close button, and browser back-button are now owned by openModal().
|
|
// The body is built imperatively so the submit handler can read form
|
|
// state from the modal-body element it constructed.
|
|
|
|
import { t } from "./i18n";
|
|
import { openModal } from "./components/modal";
|
|
|
|
export interface BroadcastRecipient {
|
|
user_id: string;
|
|
email: string;
|
|
display_name: string;
|
|
first_name: string;
|
|
role_on_project: string;
|
|
}
|
|
|
|
export interface OpenBroadcastModalArgs {
|
|
recipients: BroadcastRecipient[];
|
|
projectID?: string | null;
|
|
projectIDs?: string[];
|
|
offices?: string[];
|
|
roles?: string[];
|
|
}
|
|
|
|
interface EmailTemplateOption {
|
|
key: string;
|
|
subject: string;
|
|
body: string;
|
|
is_default: boolean;
|
|
}
|
|
|
|
interface BroadcastResult {
|
|
sent: number;
|
|
failed: number;
|
|
total: number;
|
|
}
|
|
|
|
const RECIPIENT_CAP = 100;
|
|
|
|
function esc(s: string): string {
|
|
const d = document.createElement("div");
|
|
d.textContent = s;
|
|
return d.innerHTML;
|
|
}
|
|
|
|
// firstName extracts the first whitespace-separated token from a display
|
|
// name. "Anna von Beispiel" → "Anna". Empty input → "".
|
|
export function firstName(displayName: string): string {
|
|
return displayName.trim().split(/\s+/)[0] ?? "";
|
|
}
|
|
|
|
// buildMailtoHref produces a `mailto:` URL with every recipient queued
|
|
// in the To: field, comma-separated per RFC 6068. The `?` form is
|
|
// preserved as a future hook for default subject/body — kept empty here
|
|
// so users compose their own message in their mail client. Empty input
|
|
// returns "mailto:" so the button still renders without a JS error.
|
|
export function buildMailtoHref(recipients: BroadcastRecipient[]): string {
|
|
const addrs = recipients
|
|
.map((r) => r.email.trim())
|
|
.filter((e) => e.length > 0)
|
|
.map((e) => encodeURIComponent(e));
|
|
if (!addrs.length) return "mailto:";
|
|
return `mailto:${addrs.join(",")}`;
|
|
}
|
|
|
|
export function openBroadcastModal(args: OpenBroadcastModalArgs): void {
|
|
if (!args.recipients.length) {
|
|
alert(t("team.broadcast.error.no_recipients") || "Keine Empfänger ausgewählt.");
|
|
return;
|
|
}
|
|
if (args.recipients.length > RECIPIENT_CAP) {
|
|
alert(
|
|
(t("team.broadcast.error.too_many") || "Empfängerlimit ({cap}) überschritten.").replace(
|
|
"{cap}",
|
|
String(RECIPIENT_CAP),
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
|
|
const body = renderBody(args);
|
|
wireBody(body);
|
|
|
|
void openModal<BroadcastResult>({
|
|
title: t("team.broadcast.title") || "E-Mail an Auswahl",
|
|
body,
|
|
size: "lg",
|
|
primary: {
|
|
label: `${t("team.broadcast.send") || "Senden"} (${args.recipients.length})`,
|
|
handler: async (close) => {
|
|
await onSubmit(body, args, close);
|
|
},
|
|
},
|
|
secondary: { label: t("common.cancel") || "Abbrechen" },
|
|
});
|
|
}
|
|
|
|
function renderBody(args: OpenBroadcastModalArgs): HTMLElement {
|
|
const root = document.createElement("div");
|
|
root.className = "broadcast-body";
|
|
const count = args.recipients.length;
|
|
const previewItems = args.recipients
|
|
.slice(0, 5)
|
|
.map((r) => esc(r.display_name) + " <" + esc(r.email) + ">")
|
|
.join(", ");
|
|
const more = count > 5 ? ` +${count - 5}` : "";
|
|
const fullList = args.recipients
|
|
.map(
|
|
(r) =>
|
|
`<li><span class="broadcast-recip-name">${esc(r.display_name)}</span> <span class="broadcast-recip-email"><${esc(r.email)}></span>${
|
|
r.role_on_project ? ` <span class="broadcast-recip-role">${esc(r.role_on_project)}</span>` : ""
|
|
}</li>`,
|
|
)
|
|
.join("");
|
|
|
|
root.innerHTML = `
|
|
<div class="broadcast-recipient-summary">
|
|
<strong>${esc(t("team.broadcast.recipients") || "Empfänger")}: ${count}</strong>
|
|
<button type="button" class="link-button" data-broadcast-toggle-recipients>${esc(t("team.broadcast.show_all") || "Alle anzeigen")}</button>
|
|
<a class="link-button broadcast-mailto" href="${buildMailtoHref(args.recipients)}" data-broadcast-mailto title="${esc(t("team.broadcast.mailto.tooltip") || "Im lokalen Mail-Client öffnen")}">
|
|
${esc(t("team.broadcast.mailto.label") || "Im Mail-Client öffnen")}
|
|
</a>
|
|
<div class="broadcast-recipient-preview">${previewItems}${more}</div>
|
|
<div class="broadcast-recipient-list hidden" data-broadcast-recipient-list>
|
|
<ul>${fullList}</ul>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="form-field">
|
|
<label for="broadcast-template-select">${esc(t("team.broadcast.template") || "Vorlage")} <span class="muted">(${esc(t("team.broadcast.template_optional") || "optional")})</span></label>
|
|
<select id="broadcast-template-select" data-broadcast-template>
|
|
<option value="">${esc(t("team.broadcast.template_freeform") || "Freitext")}</option>
|
|
<option value="invitation">${esc(t("team.broadcast.template.invitation") || "Einladung")}</option>
|
|
<option value="deadline_digest">${esc(t("team.broadcast.template.deadline_digest") || "Frist-Digest")}</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div class="form-field">
|
|
<label for="broadcast-subject">${esc(t("team.broadcast.subject") || "Betreff")}</label>
|
|
<input type="text" id="broadcast-subject" data-broadcast-subject required maxlength="200" />
|
|
</div>
|
|
|
|
<div class="form-field">
|
|
<label for="broadcast-body">${esc(t("team.broadcast.body") || "Nachricht")}</label>
|
|
<textarea id="broadcast-body" data-broadcast-body required rows="12" placeholder="${esc(t("team.broadcast.body_placeholder") || "Hallo {{first_name}}, …")}"></textarea>
|
|
</div>
|
|
|
|
<p class="broadcast-hint muted">
|
|
${esc(t("team.broadcast.placeholders_hint") || "Platzhalter: {{name}}, {{first_name}}, {{role_on_project}}")}
|
|
</p>
|
|
<p class="broadcast-hint muted">
|
|
${esc(t("team.broadcast.markdown_hint") || "Markdown unterstützt: **fett**, *kursiv*, [Link](https://...), - Aufzählung.")}
|
|
</p>
|
|
|
|
<div class="broadcast-error hidden" data-broadcast-error></div>
|
|
<div class="broadcast-success hidden" data-broadcast-success></div>
|
|
`;
|
|
return root;
|
|
}
|
|
|
|
function wireBody(body: HTMLElement): void {
|
|
// Recipient list toggle.
|
|
body.querySelector("[data-broadcast-toggle-recipients]")?.addEventListener("click", () => {
|
|
const list = body.querySelector<HTMLDivElement>("[data-broadcast-recipient-list]");
|
|
if (!list) return;
|
|
list.classList.toggle("hidden");
|
|
});
|
|
|
|
// Template dropdown — populates subject/body from the selected template.
|
|
const templateSelect = body.querySelector<HTMLSelectElement>("[data-broadcast-template]");
|
|
templateSelect?.addEventListener("change", async () => {
|
|
const key = templateSelect.value;
|
|
if (!key) return;
|
|
const lang = (document.documentElement.lang || "de") as "de" | "en";
|
|
try {
|
|
const res = await fetch(`/api/admin/email-templates/${encodeURIComponent(key)}/${lang}`);
|
|
if (!res.ok) return;
|
|
const tpl = (await res.json()) as EmailTemplateOption;
|
|
const subjectInput = body.querySelector<HTMLInputElement>("[data-broadcast-subject]");
|
|
const bodyInput = body.querySelector<HTMLTextAreaElement>("[data-broadcast-body]");
|
|
if (subjectInput) subjectInput.value = stripGoTemplate(tpl.subject);
|
|
if (bodyInput) bodyInput.value = stripGoTemplate(tpl.body);
|
|
} catch {
|
|
/* template load failure is non-fatal — sender keeps freeform mode. */
|
|
}
|
|
});
|
|
}
|
|
|
|
async function onSubmit(
|
|
body: HTMLElement,
|
|
args: OpenBroadcastModalArgs,
|
|
close: (result: BroadcastResult) => void,
|
|
): Promise<void> {
|
|
const subject = (body.querySelector<HTMLInputElement>("[data-broadcast-subject]")?.value ?? "").trim();
|
|
const bodyText = (body.querySelector<HTMLTextAreaElement>("[data-broadcast-body]")?.value ?? "").trim();
|
|
const templateKey = body.querySelector<HTMLSelectElement>("[data-broadcast-template]")?.value ?? "";
|
|
const errEl = body.querySelector<HTMLDivElement>("[data-broadcast-error]");
|
|
const okEl = body.querySelector<HTMLDivElement>("[data-broadcast-success]");
|
|
errEl?.classList.add("hidden");
|
|
okEl?.classList.add("hidden");
|
|
|
|
if (!subject) {
|
|
showError(errEl, t("team.broadcast.error.subject_required") || "Betreff ist erforderlich.");
|
|
return;
|
|
}
|
|
if (!bodyText) {
|
|
showError(errEl, t("team.broadcast.error.body_required") || "Nachricht ist erforderlich.");
|
|
return;
|
|
}
|
|
|
|
// The modal primary button lives in the footer (owned by openModal),
|
|
// not in the body. We surface "sending..." feedback via the in-body
|
|
// success/error areas; the primary button stays clickable but the
|
|
// server-side idempotency + RECIPIENT_CAP make double-clicks safe.
|
|
const recipientFilter: Record<string, unknown> = {};
|
|
if (args.projectIDs?.length) recipientFilter.project_ids = args.projectIDs;
|
|
if (args.projectID) recipientFilter.project_id = args.projectID;
|
|
if (args.offices?.length) recipientFilter.offices = args.offices;
|
|
if (args.roles?.length) recipientFilter.roles = args.roles;
|
|
|
|
const lang = (document.documentElement.lang === "en" ? "en" : "de");
|
|
|
|
try {
|
|
const res = await fetch("/api/team/broadcast", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
project_id: args.projectID ?? null,
|
|
subject,
|
|
body: bodyText,
|
|
template_key: templateKey || undefined,
|
|
lang,
|
|
recipient_filter: recipientFilter,
|
|
recipients: args.recipients,
|
|
}),
|
|
});
|
|
if (!res.ok) {
|
|
const errBody = await res.json().catch(() => ({ error: "Send failed" }));
|
|
showError(errEl, (errBody as { error?: string }).error || "Send failed");
|
|
return;
|
|
}
|
|
const report = (await res.json()) as BroadcastResult;
|
|
if (okEl) {
|
|
okEl.classList.remove("hidden");
|
|
const tpl = t("team.broadcast.success") || "{sent} von {total} Mails versandt ({failed} fehlgeschlagen).";
|
|
okEl.textContent = tpl
|
|
.replace("{sent}", String(report.sent))
|
|
.replace("{total}", String(report.total))
|
|
.replace("{failed}", String(report.failed));
|
|
}
|
|
// Give the sender a moment to see the report, then close.
|
|
setTimeout(() => close(report), 2500);
|
|
} catch (e) {
|
|
showError(errEl, String(e));
|
|
}
|
|
}
|
|
|
|
function showError(el: HTMLDivElement | null | undefined, msg: string) {
|
|
if (!el) return;
|
|
el.textContent = msg;
|
|
el.classList.remove("hidden");
|
|
}
|
|
|
|
// stripGoTemplate is best-effort: existing email templates carry
|
|
// `{{define "content"}}` wrappers and Go-template branches the broadcast
|
|
// compose form can't honour. The bulk-send pipeline expects plain
|
|
// Markdown + the placeholder set documented in the modal, so we strip
|
|
// the template directives before populating the textarea. Senders can
|
|
// still edit further.
|
|
function stripGoTemplate(src: string): string {
|
|
return src
|
|
.replace(/\{\{\s*(define|end|block|if|else|range|with)\b[^}]*\}\}/g, "")
|
|
.trim();
|
|
}
|