Files
feedc0de bfac12a5bb
Publish and deploy birthday invitation / Check JavaScript (push) Successful in 4s
Publish and deploy birthday invitation / Build and push (push) Successful in 1m5s
Publish and deploy birthday invitation / Deploy to Kubernetes (push) Successful in 1m12s
Allow admins to manage guest RSVPs
2026-10-14 21:19:04 +02:00

282 lines
10 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const loginPanel = document.querySelector("#login-panel");
const loginForm = document.querySelector("#login-form");
const keyInput = document.querySelector("#admin-key");
const loginError = document.querySelector("#login-error");
const dashboard = document.querySelector("#dashboard");
const headerActions = document.querySelector("#header-actions");
const createForm = document.querySelector("#create-form");
const namesInput = document.querySelector("#guest-names");
const createError = document.querySelector("#create-error");
const createButton = document.querySelector("#create-button");
const createdEmpty = document.querySelector("#created-empty");
const createdLinks = document.querySelector("#created-links");
const createdList = document.querySelector("#created-list");
const responseBody = document.querySelector("#response-body");
const tableEmpty = document.querySelector("#table-empty");
const responseDialog = document.querySelector("#response-dialog");
const responseEditorForm = document.querySelector("#response-editor-form");
const responseDialogTitle = document.querySelector("#response-dialog-title");
const adminAnswer = document.querySelector("#admin-answer");
const adminCompanionWrap = document.querySelector("#admin-companion-wrap");
const adminCompanionName = document.querySelector("#admin-companion-name");
const responseEditorError = document.querySelector("#response-editor-error");
const responseSaveButton = document.querySelector("#response-save-button");
let adminKey = sessionStorage.getItem("inviteAdminKey") || "";
let lastCreated = [];
let editingToken = "";
const answerLabels = {
not_coming: "Kommt nicht",
alone: "Kommt alleine",
with_someone: "Mit Begleitung"
};
function authHeaders(extra = {}) {
return { ...extra, Authorization: `Bearer ${adminKey}` };
}
function formatDate(value) {
if (!value) return "—";
return new Intl.DateTimeFormat("de-AT", {
dateStyle: "short",
timeStyle: "short",
timeZone: "Europe/Vienna"
}).format(new Date(value));
}
async function copyText(text, button) {
await navigator.clipboard.writeText(text);
const old = button.textContent;
button.textContent = "Kopiert!";
setTimeout(() => { button.textContent = old; }, 1200);
}
function cell(row, text) {
const td = document.createElement("td");
td.textContent = text;
row.append(td);
return td;
}
function toggleAdminCompanion() {
const needsCompanion = adminAnswer.value === "with_someone";
adminCompanionWrap.hidden = !needsCompanion;
adminCompanionName.required = needsCompanion;
}
function openResponseEditor(invite) {
editingToken = invite.token;
responseDialogTitle.textContent = invite.guestName;
adminAnswer.value = invite.answer || "";
adminCompanionName.value = invite.companionName || "";
responseEditorError.hidden = true;
toggleAdminCompanion();
responseDialog.showModal();
adminAnswer.focus();
}
function renderTable(invites) {
responseBody.replaceChildren();
tableEmpty.hidden = invites.length > 0;
for (const invite of [...invites].reverse()) {
const row = document.createElement("tr");
cell(row, invite.guestName);
const statusCell = document.createElement("td");
const status = document.createElement("span");
status.className = `status ${invite.answer || ""}`;
status.textContent = answerLabels[invite.answer] || (invite.firstOpenedAt ? "Geöffnet" : "Noch offen");
statusCell.append(status);
row.append(statusCell);
cell(row, invite.companionName || "—");
cell(row, formatDate(invite.firstOpenedAt));
cell(row, invite.lastOpenedAt ? `${formatDate(invite.lastOpenedAt)} (${invite.openCount}×)` : "—");
cell(row, formatDate(invite.lastSavedAt));
const linkCell = document.createElement("td");
const copyButton = document.createElement("button");
copyButton.type = "button";
copyButton.className = "link-button";
copyButton.textContent = "Link kopieren";
copyButton.addEventListener("click", () => copyText(invite.link, copyButton));
linkCell.append(copyButton);
row.append(linkCell);
const actionCell = document.createElement("td");
const editButton = document.createElement("button");
editButton.type = "button";
editButton.className = "link-button secondary";
editButton.textContent = invite.answer ? "Antwort ändern" : "Antwort setzen";
editButton.addEventListener("click", () => openResponseEditor(invite));
actionCell.append(editButton);
row.append(actionCell);
responseBody.append(row);
}
}
function renderCreated(invites) {
lastCreated = invites;
createdList.replaceChildren();
createdEmpty.hidden = true;
createdLinks.hidden = false;
for (const invite of invites) {
const item = document.createElement("li");
const name = document.createElement("strong");
name.textContent = invite.name;
const link = document.createElement("a");
link.href = invite.link;
link.target = "_blank";
link.rel = "noopener";
link.textContent = invite.link;
const button = document.createElement("button");
button.type = "button";
button.textContent = "Kopieren";
button.addEventListener("click", () => copyText(invite.link, button));
item.append(name, link, button);
createdList.append(item);
}
}
async function loadDashboard() {
const response = await fetch("/api/admin/invites", { headers: authHeaders() });
if (!response.ok) throw new Error("Der Admin-Schlüssel ist nicht gültig.");
const data = await response.json();
document.querySelector("#stat-total").textContent = data.summary.total;
document.querySelector("#stat-opened").textContent = data.summary.opened;
document.querySelector("#stat-responded").textContent = data.summary.responded;
document.querySelector("#stat-attending").textContent = data.summary.attending;
renderTable(data.invites);
loginPanel.hidden = true;
dashboard.hidden = false;
headerActions.hidden = false;
}
loginForm.addEventListener("submit", async event => {
event.preventDefault();
loginError.hidden = true;
adminKey = keyInput.value;
try {
await loadDashboard();
sessionStorage.setItem("inviteAdminKey", adminKey);
} catch (error) {
loginError.textContent = error.message;
loginError.hidden = false;
}
});
createForm.addEventListener("submit", async event => {
event.preventDefault();
createError.hidden = true;
const names = namesInput.value.split(/\r?\n/).map(name => name.trim()).filter(Boolean);
createButton.disabled = true;
createButton.textContent = "Links werden erstellt …";
try {
const response = await fetch("/api/admin/invites", {
method: "POST",
headers: authHeaders({ "Content-Type": "application/json" }),
body: JSON.stringify({ names })
});
const data = await response.json();
if (!response.ok) throw new Error(data.error || "Links konnten nicht erstellt werden.");
renderCreated(data.invites);
namesInput.value = "";
await loadDashboard();
} catch (error) {
createError.textContent = error.message;
createError.hidden = false;
} finally {
createButton.disabled = false;
createButton.textContent = "Persönliche Links erstellen";
}
});
document.querySelector("#copy-all-button").addEventListener("click", event => {
const text = lastCreated.map(invite => `${invite.name}: ${invite.link}`).join("\n");
copyText(text, event.currentTarget);
});
document.querySelector("#refresh-button").addEventListener("click", async event => {
event.currentTarget.disabled = true;
try { await loadDashboard(); } finally { event.currentTarget.disabled = false; }
});
document.querySelector("#logout-button").addEventListener("click", () => {
sessionStorage.removeItem("inviteAdminKey");
window.location.reload();
});
adminAnswer.addEventListener("change", () => {
responseEditorError.hidden = true;
toggleAdminCompanion();
if (!adminCompanionWrap.hidden) adminCompanionName.focus();
});
function closeResponseDialog() {
responseDialog.close();
editingToken = "";
}
document.querySelector("#response-dialog-close").addEventListener("click", closeResponseDialog);
document.querySelector("#response-dialog-cancel").addEventListener("click", closeResponseDialog);
responseEditorForm.addEventListener("submit", async event => {
event.preventDefault();
responseEditorError.hidden = true;
const answer = adminAnswer.value;
const companionName = adminCompanionName.value.trim();
if (!answer) {
responseEditorError.textContent = "Bitte wähle eine Antwort aus.";
responseEditorError.hidden = false;
return;
}
if (answer === "with_someone" && !companionName) {
responseEditorError.textContent = "Bitte gib den Namen der Begleitung an.";
responseEditorError.hidden = false;
adminCompanionName.focus();
return;
}
responseSaveButton.disabled = true;
responseSaveButton.textContent = "Wird gespeichert …";
try {
const response = await fetch(`/api/admin/invites/${encodeURIComponent(editingToken)}/rsvp`, {
method: "PATCH",
headers: authHeaders({ "Content-Type": "application/json" }),
body: JSON.stringify({ answer, companionName })
});
const data = await response.json();
if (!response.ok) throw new Error(data.error || "Antwort konnte nicht gespeichert werden.");
closeResponseDialog();
await loadDashboard();
} catch (error) {
responseEditorError.textContent = error.message;
responseEditorError.hidden = false;
} finally {
responseSaveButton.disabled = false;
responseSaveButton.textContent = "Antwort speichern";
}
});
document.querySelector("#csv-button").addEventListener("click", async event => {
event.currentTarget.disabled = true;
try {
const response = await fetch("/api/admin/responses.csv", { headers: authHeaders() });
if (!response.ok) throw new Error("CSV konnte nicht geladen werden.");
const blob = await response.blob();
const link = document.createElement("a");
link.href = URL.createObjectURL(blob);
link.download = "zusagen.csv";
link.click();
URL.revokeObjectURL(link.href);
} finally {
event.currentTarget.disabled = false;
}
});
loadDashboard().catch(() => {
sessionStorage.removeItem("inviteAdminKey");
adminKey = "";
loginPanel.hidden = false;
dashboard.hidden = true;
headerActions.hidden = true;
});