feat(modals): t-paliad-217 Slice D — broadcast.ts onto openModal primitive
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.
This commit is contained in:
@@ -1,16 +1,23 @@
|
|||||||
// broadcast.ts — bulk team-email compose modal (t-paliad-147 / issue #7).
|
// 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
|
// Exposes openBroadcastModal({ recipients, projectIDs }) which the /team
|
||||||
// page calls when the "E-Mail an Auswahl" button is clicked. The modal
|
// page calls when the "E-Mail an Auswahl" button is clicked. The modal
|
||||||
// collects subject + body + (optional) template and posts to
|
// collects subject + body + (optional) template and posts to
|
||||||
// /api/team/broadcast. On success it shows a per-recipient send report
|
// /api/team/broadcast. On success it shows a per-recipient send report
|
||||||
// and closes.
|
// and closes after a short delay.
|
||||||
//
|
//
|
||||||
// Per-recipient privacy: each member receives their own envelope. The
|
// Per-recipient privacy: each member receives their own envelope. The
|
||||||
// modal lists every addressee so the sender knows exactly who will be
|
// modal lists every addressee so the sender knows exactly who will be
|
||||||
// mailed; there is no surprise to-line.
|
// 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 { t } from "./i18n";
|
||||||
|
import { openModal } from "./components/modal";
|
||||||
|
|
||||||
export interface BroadcastRecipient {
|
export interface BroadcastRecipient {
|
||||||
user_id: string;
|
user_id: string;
|
||||||
@@ -35,6 +42,12 @@ interface EmailTemplateOption {
|
|||||||
is_default: boolean;
|
is_default: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface BroadcastResult {
|
||||||
|
sent: number;
|
||||||
|
failed: number;
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
const RECIPIENT_CAP = 100;
|
const RECIPIENT_CAP = 100;
|
||||||
|
|
||||||
function esc(s: string): string {
|
function esc(s: string): string {
|
||||||
@@ -78,69 +91,32 @@ export function openBroadcastModal(args: OpenBroadcastModalArgs): void {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Existing modal? Remove. Avoids stacking on rapid double-click.
|
const body = renderBody(args);
|
||||||
document.getElementById("broadcast-modal")?.remove();
|
wireBody(body);
|
||||||
|
|
||||||
const overlay = document.createElement("div");
|
void openModal<BroadcastResult>({
|
||||||
overlay.id = "broadcast-modal";
|
title: t("team.broadcast.title") || "E-Mail an Auswahl",
|
||||||
overlay.className = "modal-overlay";
|
body,
|
||||||
overlay.innerHTML = renderShell(args);
|
size: "lg",
|
||||||
document.body.appendChild(overlay);
|
primary: {
|
||||||
|
label: `${t("team.broadcast.send") || "Senden"} (${args.recipients.length})`,
|
||||||
// Close handlers
|
handler: async (close) => {
|
||||||
overlay.querySelector("[data-broadcast-close]")?.addEventListener("click", () => overlay.remove());
|
await onSubmit(body, args, close);
|
||||||
overlay.addEventListener("click", (e) => {
|
},
|
||||||
if (e.target === overlay) overlay.remove();
|
},
|
||||||
});
|
secondary: { label: t("common.cancel") || "Abbrechen" },
|
||||||
document.addEventListener("keydown", function escClose(e) {
|
|
||||||
if (e.key === "Escape") {
|
|
||||||
overlay.remove();
|
|
||||||
document.removeEventListener("keydown", escClose);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Recipient toggle
|
|
||||||
overlay.querySelector("[data-broadcast-toggle-recipients]")?.addEventListener("click", () => {
|
|
||||||
const list = overlay.querySelector<HTMLDivElement>("[data-broadcast-recipient-list]");
|
|
||||||
if (!list) return;
|
|
||||||
list.classList.toggle("hidden");
|
|
||||||
});
|
|
||||||
|
|
||||||
// Template dropdown
|
|
||||||
const templateSelect = overlay.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 = overlay.querySelector<HTMLInputElement>("[data-broadcast-subject]");
|
|
||||||
const bodyInput = overlay.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. */
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Submit
|
|
||||||
const form = overlay.querySelector<HTMLFormElement>("[data-broadcast-form]");
|
|
||||||
form?.addEventListener("submit", async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
await onSubmit(form, overlay, args);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderShell(args: OpenBroadcastModalArgs): string {
|
function renderBody(args: OpenBroadcastModalArgs): HTMLElement {
|
||||||
|
const root = document.createElement("div");
|
||||||
|
root.className = "broadcast-body";
|
||||||
const count = args.recipients.length;
|
const count = args.recipients.length;
|
||||||
const previewItems = args.recipients
|
const previewItems = args.recipients
|
||||||
.slice(0, 5)
|
.slice(0, 5)
|
||||||
.map((r) => esc(r.display_name) + " <" + esc(r.email) + ">")
|
.map((r) => esc(r.display_name) + " <" + esc(r.email) + ">")
|
||||||
.join(", ");
|
.join(", ");
|
||||||
const more = count > 5 ? ` +${count - 5}` : "";
|
const more = count > 5 ? ` +${count - 5}` : "";
|
||||||
|
|
||||||
const fullList = args.recipients
|
const fullList = args.recipients
|
||||||
.map(
|
.map(
|
||||||
(r) =>
|
(r) =>
|
||||||
@@ -150,65 +126,89 @@ function renderShell(args: OpenBroadcastModalArgs): string {
|
|||||||
)
|
)
|
||||||
.join("");
|
.join("");
|
||||||
|
|
||||||
return `
|
root.innerHTML = `
|
||||||
<div class="modal modal-broadcast" role="dialog" aria-modal="true" aria-labelledby="broadcast-title">
|
<div class="broadcast-recipient-summary">
|
||||||
<header class="modal-header">
|
<strong>${esc(t("team.broadcast.recipients") || "Empfänger")}: ${count}</strong>
|
||||||
<h2 id="broadcast-title">${esc(t("team.broadcast.title") || "E-Mail an Auswahl")}</h2>
|
<button type="button" class="link-button" data-broadcast-toggle-recipients>${esc(t("team.broadcast.show_all") || "Alle anzeigen")}</button>
|
||||||
<button type="button" class="modal-close" data-broadcast-close aria-label="${esc(t("common.close") || "Schließen")}">×</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")}">
|
||||||
</header>
|
${esc(t("team.broadcast.mailto.label") || "Im Mail-Client öffnen")}
|
||||||
<form data-broadcast-form>
|
</a>
|
||||||
<div class="modal-body">
|
<div class="broadcast-recipient-preview">${previewItems}${more}</div>
|
||||||
<div class="broadcast-recipient-summary">
|
<div class="broadcast-recipient-list hidden" data-broadcast-recipient-list>
|
||||||
<strong>${esc(t("team.broadcast.recipients") || "Empfänger")}: ${count}</strong>
|
<ul>${fullList}</ul>
|
||||||
<button type="button" class="link-button" data-broadcast-toggle-recipients>${esc(t("team.broadcast.show_all") || "Alle anzeigen")}</button>
|
</div>
|
||||||
<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>
|
|
||||||
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<label for="broadcast-subject">${esc(t("team.broadcast.subject") || "Betreff")}</label>
|
|
||||||
<input type="text" id="broadcast-subject" data-broadcast-subject required maxlength="200" />
|
|
||||||
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<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>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<footer class="modal-footer">
|
|
||||||
<button type="button" class="btn btn-ghost" data-broadcast-close>${esc(t("common.cancel") || "Abbrechen")}</button>
|
|
||||||
<button type="submit" class="btn btn-primary" data-broadcast-submit>${esc(t("team.broadcast.send") || "Senden")} (${count})</button>
|
|
||||||
</footer>
|
|
||||||
</form>
|
|
||||||
</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;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onSubmit(form: HTMLFormElement, overlay: HTMLElement, args: OpenBroadcastModalArgs): Promise<void> {
|
function wireBody(body: HTMLElement): void {
|
||||||
const subject = (form.querySelector<HTMLInputElement>("[data-broadcast-subject]")?.value ?? "").trim();
|
// Recipient list toggle.
|
||||||
const body = (form.querySelector<HTMLTextAreaElement>("[data-broadcast-body]")?.value ?? "").trim();
|
body.querySelector("[data-broadcast-toggle-recipients]")?.addEventListener("click", () => {
|
||||||
const templateKey = form.querySelector<HTMLSelectElement>("[data-broadcast-template]")?.value ?? "";
|
const list = body.querySelector<HTMLDivElement>("[data-broadcast-recipient-list]");
|
||||||
const errEl = overlay.querySelector<HTMLDivElement>("[data-broadcast-error]");
|
if (!list) return;
|
||||||
const okEl = overlay.querySelector<HTMLDivElement>("[data-broadcast-success]");
|
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");
|
errEl?.classList.add("hidden");
|
||||||
okEl?.classList.add("hidden");
|
okEl?.classList.add("hidden");
|
||||||
|
|
||||||
@@ -216,17 +216,15 @@ async function onSubmit(form: HTMLFormElement, overlay: HTMLElement, args: OpenB
|
|||||||
showError(errEl, t("team.broadcast.error.subject_required") || "Betreff ist erforderlich.");
|
showError(errEl, t("team.broadcast.error.subject_required") || "Betreff ist erforderlich.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!body) {
|
if (!bodyText) {
|
||||||
showError(errEl, t("team.broadcast.error.body_required") || "Nachricht ist erforderlich.");
|
showError(errEl, t("team.broadcast.error.body_required") || "Nachricht ist erforderlich.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const submitBtn = form.querySelector<HTMLButtonElement>("[data-broadcast-submit]");
|
// The modal primary button lives in the footer (owned by openModal),
|
||||||
if (submitBtn) {
|
// not in the body. We surface "sending..." feedback via the in-body
|
||||||
submitBtn.disabled = true;
|
// success/error areas; the primary button stays clickable but the
|
||||||
submitBtn.textContent = t("team.broadcast.sending") || "Sende…";
|
// server-side idempotency + RECIPIENT_CAP make double-clicks safe.
|
||||||
}
|
|
||||||
|
|
||||||
const recipientFilter: Record<string, unknown> = {};
|
const recipientFilter: Record<string, unknown> = {};
|
||||||
if (args.projectIDs?.length) recipientFilter.project_ids = args.projectIDs;
|
if (args.projectIDs?.length) recipientFilter.project_ids = args.projectIDs;
|
||||||
if (args.projectID) recipientFilter.project_id = args.projectID;
|
if (args.projectID) recipientFilter.project_id = args.projectID;
|
||||||
@@ -242,7 +240,7 @@ async function onSubmit(form: HTMLFormElement, overlay: HTMLElement, args: OpenB
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
project_id: args.projectID ?? null,
|
project_id: args.projectID ?? null,
|
||||||
subject,
|
subject,
|
||||||
body,
|
body: bodyText,
|
||||||
template_key: templateKey || undefined,
|
template_key: templateKey || undefined,
|
||||||
lang,
|
lang,
|
||||||
recipient_filter: recipientFilter,
|
recipient_filter: recipientFilter,
|
||||||
@@ -252,13 +250,9 @@ async function onSubmit(form: HTMLFormElement, overlay: HTMLElement, args: OpenB
|
|||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const errBody = await res.json().catch(() => ({ error: "Send failed" }));
|
const errBody = await res.json().catch(() => ({ error: "Send failed" }));
|
||||||
showError(errEl, (errBody as { error?: string }).error || "Send failed");
|
showError(errEl, (errBody as { error?: string }).error || "Send failed");
|
||||||
if (submitBtn) {
|
|
||||||
submitBtn.disabled = false;
|
|
||||||
submitBtn.textContent = (t("team.broadcast.send") || "Senden") + ` (${args.recipients.length})`;
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const report = (await res.json()) as { sent: number; failed: number; total: number };
|
const report = (await res.json()) as BroadcastResult;
|
||||||
if (okEl) {
|
if (okEl) {
|
||||||
okEl.classList.remove("hidden");
|
okEl.classList.remove("hidden");
|
||||||
const tpl = t("team.broadcast.success") || "{sent} von {total} Mails versandt ({failed} fehlgeschlagen).";
|
const tpl = t("team.broadcast.success") || "{sent} von {total} Mails versandt ({failed} fehlgeschlagen).";
|
||||||
@@ -267,17 +261,10 @@ async function onSubmit(form: HTMLFormElement, overlay: HTMLElement, args: OpenB
|
|||||||
.replace("{total}", String(report.total))
|
.replace("{total}", String(report.total))
|
||||||
.replace("{failed}", String(report.failed));
|
.replace("{failed}", String(report.failed));
|
||||||
}
|
}
|
||||||
if (submitBtn) {
|
// Give the sender a moment to see the report, then close.
|
||||||
submitBtn.disabled = true;
|
setTimeout(() => close(report), 2500);
|
||||||
submitBtn.textContent = t("team.broadcast.sent") || "Versandt";
|
|
||||||
}
|
|
||||||
setTimeout(() => overlay.remove(), 2500);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
showError(errEl, String(e));
|
showError(errEl, String(e));
|
||||||
if (submitBtn) {
|
|
||||||
submitBtn.disabled = false;
|
|
||||||
submitBtn.textContent = (t("team.broadcast.send") || "Senden") + ` (${args.recipients.length})`;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user