// Sidebar client-side behavior // Hover expand with delay, pin toggle, mobile hamburger import { initGlobalSearch } from "./search"; import { getChangelogSeen } from "./changelog-seen"; import { cycleTheme, getThemePref, onThemeChange, type ThemePref } from "./theme"; import { t } from "./i18n"; const PIN_KEY = "paliad-sidebar-pinned"; const LEGACY_PIN_KEY = "patholo-sidebar-pinned"; const WIDTH_KEY = "paliad-sidebar-width"; const SIDEBAR_WIDTH_MIN = 180; const SIDEBAR_WIDTH_MAX = 480; const SIDEBAR_WIDTH_DEFAULT = 240; // Per-tab scroll position of the .sidebar-nav scroll container. Persisted // on every scroll event, restored on initSidebar() so a full-page nav // click doesn't bounce the user back to the top of a long sidebar // (Werkzeuge + projects + user views can easily overflow). sessionStorage // scopes it to the tab — opening a sidebar link in a new tab (Cmd-click) // starts that tab fresh at the top, which matches user expectation. const SCROLL_KEY = "paliad.sidebar.scroll"; // toggleMobileSidebar opens or closes the slide-out drawer. Exposed so the // BottomNav menu slot can call it without duplicating the open/close // machinery (overlay, body-scroll lock, etc.). export function toggleMobileSidebar(): void { const sidebar = document.querySelector(".sidebar"); const overlay = document.querySelector(".sidebar-overlay"); if (!sidebar) return; const isOpen = sidebar.classList.contains("mobile-open"); if (isOpen) { sidebar.classList.remove("mobile-open"); overlay?.classList.remove("visible"); document.body.classList.remove("no-scroll"); } else { sidebar.classList.add("mobile-open"); overlay?.classList.add("visible"); document.body.classList.add("no-scroll"); } } // readStoredWidth returns the user's persisted sidebar width in px, falling // back to SIDEBAR_WIDTH_DEFAULT when missing, malformed, or outside the // supported clamp range. Stored values are written by initSidebarResize on // dragend; the read happens on every page load (initial CSS-var apply). function readStoredWidth(): number { const raw = localStorage.getItem(WIDTH_KEY); if (raw === null) return SIDEBAR_WIDTH_DEFAULT; const n = parseInt(raw, 10); if (!Number.isFinite(n) || n < SIDEBAR_WIDTH_MIN || n > SIDEBAR_WIDTH_MAX) { return SIDEBAR_WIDTH_DEFAULT; } return n; } function applySidebarWidth(px: number): void { document.documentElement.style.setProperty("--sidebar-width", `${px}px`); } // readStoredScroll returns the persisted scrollTop or 0 when missing / // malformed. Bounds are checked at apply time against the actual // scrollHeight, so a stale value pointing past the current scroll range // is harmless (the browser clamps assignments to [0, max]). function readStoredScroll(): number { const raw = sessionStorage.getItem(SCROLL_KEY); if (raw === null) return 0; const n = parseInt(raw, 10); if (!Number.isFinite(n) || n < 0) return 0; return n; } function applySidebarScroll(nav: HTMLElement, px: number): void { if (px <= 0) return; nav.scrollTop = px; } // migrateLegacyPinKey copies the pre-rebrand pin state into the new key on // first load and removes the stale entry. Drop this fallback once the rename // grace period is over. function migrateLegacyPinKey(): void { const legacy = localStorage.getItem(LEGACY_PIN_KEY); if (legacy === null) return; if (localStorage.getItem(PIN_KEY) === null) { localStorage.setItem(PIN_KEY, legacy); } localStorage.removeItem(LEGACY_PIN_KEY); } export function initSidebar() { migrateLegacyPinKey(); // Apply the persisted width before any sidebar paint so the layout is // stable from frame 1. Safe to call before the .sidebar element exists — // the CSS var lives on document.documentElement. applySidebarWidth(readStoredWidth()); initInviteModal(); initGlobalSearch(); initChangelogBadge(); initInboxBadge(); initAdminGroup(); initPaliadinLinks(); initProjectContextChartLink(); initUserViewsGroup(); initThemeToggle(); const sidebar = document.querySelector(".sidebar"); if (!sidebar) return; initSidebarResize(sidebar); initSidebarScrollRestore(sidebar); const pinBtn = sidebar.querySelector(".sidebar-pin"); const hamburger = document.querySelector(".sidebar-hamburger"); const overlay = document.querySelector(".sidebar-overlay"); let hoverTimer: ReturnType | null = null; function isMobile(): boolean { return window.innerWidth < 1024; } // Restore pin state on desktop. We mirror the `sidebar-pinned` class on // both and : the runtime path historically set it on body, // but the pre-paint FOUC script in PWAHead.tsx can only reach // (body doesn't exist yet at that point). Keeping both in sync from // every site that toggles pin state means the CSS selectors // `.has-sidebar.sidebar-pinned` (body-rooted) and // `:root.sidebar-pinned .has-sidebar` (html-rooted) never disagree. if (localStorage.getItem(PIN_KEY) === "true" && !isMobile()) { sidebar.classList.add("pinned"); document.body.classList.add("sidebar-pinned"); document.documentElement.classList.add("sidebar-pinned"); } else { // Pre-paint script may have added it (e.g. last visit was wide enough // and pinned) but current viewport is mobile — clear so the CSS rule // doesn't fight the responsive @media block. document.documentElement.classList.remove("sidebar-pinned"); } // Desktop: hover expand with 150ms delay sidebar.addEventListener("mouseenter", () => { if (isMobile() || sidebar.classList.contains("pinned")) return; hoverTimer = setTimeout(() => { sidebar.classList.add("expanded"); }, 150); }); sidebar.addEventListener("mouseleave", () => { if (hoverTimer) { clearTimeout(hoverTimer); hoverTimer = null; } // Mid-resize drags briefly pull the cursor off the sidebar — keep // the sidebar expanded so the user can finish the drag. The resizing // class is cleared on dragend; the next mouseleave will collapse. if (sidebar.classList.contains("resizing")) return; sidebar.classList.remove("expanded"); }); // Pin toggle if (pinBtn) { pinBtn.addEventListener("click", (e) => { e.preventDefault(); const wasPinned = sidebar.classList.contains("pinned"); if (wasPinned) { sidebar.classList.remove("pinned"); document.body.classList.remove("sidebar-pinned"); document.documentElement.classList.remove("sidebar-pinned"); localStorage.setItem(PIN_KEY, "false"); } else { sidebar.classList.add("pinned"); sidebar.classList.remove("expanded"); document.body.classList.add("sidebar-pinned"); document.documentElement.classList.add("sidebar-pinned"); localStorage.setItem(PIN_KEY, "true"); } }); } // Mobile: hamburger toggle function closeMobile() { sidebar.classList.remove("mobile-open"); overlay?.classList.remove("visible"); document.body.classList.remove("no-scroll"); } if (hamburger) { hamburger.addEventListener("click", () => { toggleMobileSidebar(); }); } if (overlay) { overlay.addEventListener("click", closeMobile); } document.addEventListener("keydown", (e) => { if (e.key === "Escape" && sidebar.classList.contains("mobile-open")) { closeMobile(); } }); // Close mobile sidebar on nav click sidebar.querySelectorAll("a[href]").forEach((link) => { link.addEventListener("click", () => { if (isMobile()) closeMobile(); }); }); // Handle resize: clean up states when crossing breakpoint let wasMobile = isMobile(); window.addEventListener("resize", () => { const nowMobile = isMobile(); if (wasMobile === nowMobile) return; wasMobile = nowMobile; if (nowMobile) { sidebar.classList.remove("expanded", "pinned"); document.body.classList.remove("sidebar-pinned"); document.documentElement.classList.remove("sidebar-pinned"); } else { closeMobile(); if (localStorage.getItem(PIN_KEY) === "true") { sidebar.classList.add("pinned"); document.body.classList.add("sidebar-pinned"); document.documentElement.classList.add("sidebar-pinned"); } } }); } // initSidebarResize wires the right-edge drag handle: mousedown/touchstart // captures the starting cursor position and current width, mousemove/touchmove // applies the delta as --sidebar-width (clamped), mouseup/touchend persists // the final value to localStorage. Double-click on the handle resets to the // default width. The .resizing class on the sidebar suppresses both the // width transition (avoid jitter while dragging) and the hover-collapse // (the cursor leaves the sidebar mid-drag). function initSidebarResize(sidebar: HTMLElement): void { const handle = sidebar.querySelector(".sidebar-resize-handle"); if (!handle) return; let dragging = false; let startX = 0; let startWidth = 0; function applyDelta(clientX: number): void { const delta = clientX - startX; let next = startWidth + delta; if (next < SIDEBAR_WIDTH_MIN) next = SIDEBAR_WIDTH_MIN; if (next > SIDEBAR_WIDTH_MAX) next = SIDEBAR_WIDTH_MAX; applySidebarWidth(next); } function readCurrentWidth(): number { const raw = getComputedStyle(document.documentElement) .getPropertyValue("--sidebar-width") .trim(); const n = parseInt(raw, 10); return Number.isFinite(n) ? n : SIDEBAR_WIDTH_DEFAULT; } function endDrag(): void { if (!dragging) return; dragging = false; sidebar.classList.remove("resizing"); document.body.classList.remove("sidebar-resizing"); document.removeEventListener("mousemove", onMouseMove); document.removeEventListener("mouseup", endDrag); document.removeEventListener("touchmove", onTouchMove); document.removeEventListener("touchend", endDrag); document.removeEventListener("touchcancel", endDrag); localStorage.setItem(WIDTH_KEY, String(readCurrentWidth())); } function onMouseMove(e: MouseEvent): void { applyDelta(e.clientX); } function onTouchMove(e: TouchEvent): void { if (e.touches.length === 0) return; applyDelta(e.touches[0].clientX); // preventDefault stops the browser from interpreting the drag as a // page scroll. touch-action: none on the handle covers most browsers, // but Safari still needs the explicit call. if (e.cancelable) e.preventDefault(); } function startDrag(clientX: number): void { dragging = true; startX = clientX; // Use the rendered sidebar width — robust against any future state // (pinned vs hover-expanded) where the CSS var might not be the // single source of truth. startWidth = sidebar.getBoundingClientRect().width; sidebar.classList.add("resizing"); document.body.classList.add("sidebar-resizing"); } handle.addEventListener("mousedown", (e) => { // Only primary button. Right/middle clicks would otherwise stick the // sidebar in resizing state until a real mouseup arrives. if (e.button !== 0) return; e.preventDefault(); startDrag(e.clientX); document.addEventListener("mousemove", onMouseMove); document.addEventListener("mouseup", endDrag); }); handle.addEventListener("touchstart", (e) => { if (e.touches.length === 0) return; startDrag(e.touches[0].clientX); document.addEventListener("touchmove", onTouchMove, { passive: false }); document.addEventListener("touchend", endDrag); document.addEventListener("touchcancel", endDrag); }, { passive: true }); handle.addEventListener("dblclick", (e) => { e.preventDefault(); applySidebarWidth(SIDEBAR_WIDTH_DEFAULT); localStorage.setItem(WIDTH_KEY, String(SIDEBAR_WIDTH_DEFAULT)); }); } // initSidebarScrollRestore wires the .sidebar-nav scroll container to // sessionStorage so the user's scroll position survives a full-page // navigation (every sidebar link click is a real reload — see m/paliad#85). // Restore is synchronous on init so the first paint is already at the // right offset; the passive scroll listener persists subsequent moves. // reapplySidebarScroll() exists so callers that mutate sidebar content // async (initUserViewsGroup appending /api/user-views into the Ansichten // group) can nudge the scroll back to where it was after the layout shift. function initSidebarScrollRestore(sidebar: HTMLElement): void { const nav = sidebar.querySelector(".sidebar-nav"); if (!nav) return; applySidebarScroll(nav, readStoredScroll()); nav.addEventListener("scroll", () => { sessionStorage.setItem(SCROLL_KEY, String(nav.scrollTop)); }, { passive: true }); } function reapplySidebarScroll(): void { const nav = document.querySelector(".sidebar .sidebar-nav"); if (!nav) return; applySidebarScroll(nav, readStoredScroll()); } // Changelog badge — fetches the count of entries newer than the locally // stored "last seen" stamp and renders a dot + number on the Neuigkeiten // link. Skipped on the changelog page itself because changelog.ts stamps // the visit on load and we don't want a flash of badge before that runs. function initChangelogBadge(): void { const badge = document.getElementById("sidebar-changelog-badge") as HTMLElement | null; if (!badge) return; if (window.location.pathname === "/changelog") return; const since = getChangelogSeen(); const url = since ? `/api/changelog/unseen-count?since=${encodeURIComponent(since)}` : "/api/changelog/unseen-count"; fetch(url, { credentials: "same-origin" }) .then((r) => (r.ok ? r.json() : null)) .then((data: { count?: number } | null) => { if (!data || typeof data.count !== "number" || data.count <= 0) return; badge.textContent = data.count > 9 ? "9+" : String(data.count); badge.style.display = ""; }) .catch(() => { // silent: the badge is optional and must never break the page. }); } // Inbox badge (t-paliad-138) — count of approval requests where the // current user is qualified to approve. Polls every 60s while the page // is open. Silently swallows errors (badge is optional). function initInboxBadge(): void { const badge = document.getElementById("sidebar-inbox-badge") as HTMLElement | null; if (!badge) return; const refresh = () => { fetch("/api/inbox/count", { credentials: "same-origin" }) .then((r) => (r.ok ? r.json() : null)) .then((data: { count?: number } | null) => { if (!data || typeof data.count !== "number" || data.count <= 0) { badge.style.display = "none"; return; } badge.textContent = data.count > 9 ? "9+" : String(data.count); badge.style.display = ""; }) .catch(() => { /* silent */ }); }; refresh(); setInterval(refresh, 60_000); } // initThemeToggle wires the sun/moon button at the bottom of the sidebar // (m/paliad#2). The pre-paint inline script in PWAHead.tsx already set // the data-theme attribute on ; this function only owns the post- // hydration UI: cycling on click, swapping the icon/label to match the // current preference, and re-rendering when the OS-level scheme flips // while the user is on "auto". const THEME_ICONS: Record = { auto: '', light: '', dark: '', }; const THEME_LABEL_KEYS: Record = { auto: "theme.toggle.auto", light: "theme.toggle.light", dark: "theme.toggle.dark", }; function initThemeToggle(): void { const btn = document.getElementById("sidebar-theme-toggle") as HTMLButtonElement | null; const icon = document.getElementById("sidebar-theme-icon") as HTMLElement | null; const label = document.getElementById("sidebar-theme-label") as HTMLElement | null; if (!btn || !icon || !label) return; function render(): void { const pref = getThemePref(); if (icon) icon.innerHTML = THEME_ICONS[pref]; if (label) { const key = THEME_LABEL_KEYS[pref]; label.setAttribute("data-i18n", key); label.textContent = t(key); } if (btn) { btn.setAttribute("aria-label", t(key_for_aria(pref))); btn.setAttribute("title", t(key_for_aria(pref))); } } function key_for_aria(pref: ThemePref): "theme.toggle.cycle.auto" | "theme.toggle.cycle.light" | "theme.toggle.cycle.dark" { // The aria-label describes WHAT happens on click, not the current // state — assistive tech reads "Switch to dark theme" rather than // just "Auto". Cycle order: auto → light → dark → auto. if (pref === "auto") return "theme.toggle.cycle.auto"; // → light if (pref === "light") return "theme.toggle.cycle.light"; // → dark return "theme.toggle.cycle.dark"; // → auto } btn.addEventListener("click", () => { cycleTheme(); }); // theme.ts notifies on user-cycle AND on OS-level prefers-color-scheme // changes while the user is on "auto" — a single subscription covers // both, so the icon/label always tracks the live preference. onThemeChange(render); render(); } // t-paliad-144 Phase A2 — Meine Sichten group hydration. Fetches the // caller's saved views and renders one nav item per view between the // group label and the "+ Neue Sicht" trailing entry. Optional count // badge per view (when show_count=true on the row). The "+ Neue Sicht" // entry stays in the DOM unconditionally so the group has something // to show even for first-time users. interface UserViewLite { id: string; slug: string; name: string; icon?: string; show_count: boolean; } function initUserViewsGroup(): void { const items = document.getElementById("sidebar-views-items"); if (!items) return; // Skip on auth-anon pages (/login, landing) — /api/user-views would 401. if (!document.body.classList.contains("has-sidebar")) return; fetch("/api/user-views", { credentials: "same-origin" }) .then((r) => (r.ok ? r.json() : null)) .then((views: UserViewLite[] | null) => { if (!views) return; const currentPath = window.location.pathname; items.innerHTML = ""; for (const view of views) { items.appendChild(renderUserViewItem(view, currentPath)); } // The synchronous restore in initSidebarScrollRestore() happened // before these views were appended, so a saved scrollTop that // pointed below the Ansichten group would now sit on the wrong // row. Re-apply once the layout has stabilised. reapplySidebarScroll(); // After rendering, kick off count refresh for views that opted in. for (const view of views) { if (view.show_count) { void refreshUserViewCount(view); } } }) .catch(() => { // Silent — sidebar already shows "+ Neue Sicht" even on failure. }); } // fixVerfahrensablaufActive removed (t-paliad-179 Slice 1). The two // sidebar entries now map 1:1 to distinct URLs (/tools/fristenrechner // vs /tools/verfahrensablauf), so the SSR navItem helper picks the // correct active class by pathname alone. function renderUserViewItem(view: UserViewLite, currentPath: string): HTMLElement { const a = document.createElement("a"); a.href = `/views/${encodeURIComponent(view.slug)}`; const active = currentPath === a.pathname; a.className = `sidebar-item sidebar-user-view-item${active ? " active" : ""}`; a.dataset.slug = view.slug; a.dataset.viewId = view.id; const iconWrap = document.createElement("span"); iconWrap.className = "sidebar-icon"; iconWrap.innerHTML = userViewIconSvg(view.icon); a.appendChild(iconWrap); const label = document.createElement("span"); label.className = "sidebar-label"; label.textContent = view.name; a.appendChild(label); if (view.show_count) { const badge = document.createElement("span"); badge.className = "sidebar-badge sidebar-user-view-badge"; badge.id = `sidebar-user-view-badge-${view.id}`; badge.style.display = "none"; badge.setAttribute("aria-hidden", "true"); a.appendChild(badge); } return a; } async function refreshUserViewCount(view: UserViewLite): Promise { try { const r = await fetch(`/api/views/${encodeURIComponent(view.slug)}/run`, { method: "POST", credentials: "same-origin", headers: { "Content-Type": "application/json" }, body: JSON.stringify({}), }); if (!r.ok) return; const data = (await r.json()) as { rows: unknown[] }; const badge = document.getElementById(`sidebar-user-view-badge-${view.id}`); if (!badge) return; if (data.rows.length > 0) { badge.textContent = String(data.rows.length); badge.style.display = ""; } else { badge.style.display = "none"; } } catch (_e) { /* noop */ } } // userViewIconSvg picks an SVG from a small fixed registry. Falls back // to the folder icon for unknown / missing keys. Inline SVGs are used // elsewhere in the sidebar (Sidebar.tsx); we duplicate a minimal subset // here rather than re-exporting because client TS doesn't import from // JSX-emitting modules. function userViewIconSvg(icon?: string): string { switch (icon) { case "clock": return ''; case "calendar": return ''; case "bell": return ''; case "users": return ''; case "building": return ''; case "folder": default: return ''; } } // PALIADIN_OWNER_EMAIL must match services.PaliadinOwnerEmail (Go side). // PoC scope — see docs/design-paliadin-2026-05-07.md §0.5. const PALIADIN_OWNER_EMAIL = "matthias.siebels@hoganlovells.com"; // initPaliadinLinks reveals the Paliadin sidebar entries (under Übersicht // + Admin) when /api/me confirms the caller is the Paliadin owner. Same // fail-closed display:none pattern as initAdminGroup. Non-owners never // see the entries; the routes themselves return 404 if they navigate // to /paliadin or /admin/paliadin manually anyway. function initPaliadinLinks(): void { const top = document.getElementById("sidebar-paliadin-link") as HTMLElement | null; const admin = document.getElementById("sidebar-admin-paliadin-link") as HTMLElement | null; if (!top && !admin) return; fetch("/api/me", { credentials: "same-origin" }) .then((r) => (r.ok ? r.json() : null)) .then((me: { email?: string } | null) => { if (me && me.email && me.email.toLowerCase() === PALIADIN_OWNER_EMAIL) { if (top) top.style.display = ""; if (admin) admin.style.display = ""; } }) .catch(() => { // silent: failing closed is the safe default. }); } // initProjectContextChartLink (t-paliad-177 Slice 3) reveals an "Als Chart // anzeigen" entry in the sidebar when the user is browsing a project // detail page. Hidden everywhere else, hidden on the chart page itself // (the chart is the destination, not the source). // // Self-contained on URL parsing — no per-page handshake needed. Pages // don't have to know about the sidebar slot; this function walks the // pathname and renders the link if it matches. // // Layout intent: chip sits directly under the "Übersicht" group so it's // visible on every project sub-tab (Verlauf / Team / Parteien / …). function initProjectContextChartLink(): void { const link = document.getElementById("sidebar-project-chart-link") as HTMLAnchorElement | null; if (!link) return; const match = /^\/projects\/([0-9a-fA-F-]{36})(\/.*)?$/.exec(window.location.pathname); if (!match) return; const id = match[1]; const rest = match[2] || ""; // Hide on the chart page itself — a reciprocal "Zurück zum Verlauf" // affordance lives on the chart page header (separate slice). if (rest === "/chart" || rest === "/chart/") return; link.href = `/projects/${encodeURIComponent(id)}/chart`; link.style.display = ""; } // initAdminGroup reveals the Admin section in the sidebar when the caller's // /api/me lookup confirms global_role='global_admin'. The markup is in the // DOM with display:none for everyone — flipping it on after the fetch lands // keeps non-admin pageloads cheap (no flash, no second render) and avoids a // privilege flash for admins on cached pages. function initAdminGroup(): void { const group = document.getElementById("sidebar-admin-group") as HTMLElement | null; if (!group) return; fetch("/api/me", { credentials: "same-origin" }) .then((r) => (r.ok ? r.json() : null)) .then((me: { global_role?: string } | null) => { if (me && me.global_role === "global_admin") { group.style.display = ""; } }) .catch(() => { // silent: not being able to check the permission just means we keep // the section hidden, which fails closed. }); } // Invitation modal — opened from the sidebar "Kolleg:in einladen" button. // Keeps the whole flow client-side: validates, POSTs to /api/invite, shows // success or the server's error message in the same modal. Kept inside // sidebar.ts because the Sidebar component owns the modal markup — every // page that renders picks up the behaviour for free. function initInviteModal(): void { const btn = document.getElementById("sidebar-invite-btn") as HTMLButtonElement | null; const modal = document.getElementById("invite-modal") as HTMLElement | null; const closeBtn = document.getElementById("invite-modal-close") as HTMLButtonElement | null; const cancelBtn = document.getElementById("invite-modal-cancel") as HTMLButtonElement | null; const form = document.getElementById("invite-form") as HTMLFormElement | null; const emailInput = document.getElementById("invite-email") as HTMLInputElement | null; const messageInput = document.getElementById("invite-message") as HTMLTextAreaElement | null; const submitBtn = document.getElementById("invite-submit") as HTMLButtonElement | null; const feedback = document.getElementById("invite-feedback") as HTMLElement | null; if (!btn || !modal || !form || !emailInput || !submitBtn || !feedback) return; function open(): void { clearFeedback(); modal!.style.display = "flex"; setTimeout(() => emailInput!.focus(), 30); } function close(): void { modal!.style.display = "none"; form!.reset(); clearFeedback(); } function clearFeedback(): void { feedback!.style.display = "none"; feedback!.textContent = ""; feedback!.classList.remove("form-msg-success", "form-msg-error"); } function setFeedback(kind: "success" | "error", text: string): void { feedback!.textContent = text; feedback!.classList.remove("form-msg-success", "form-msg-error"); feedback!.classList.add(kind === "success" ? "form-msg-success" : "form-msg-error"); feedback!.style.display = "block"; } btn.addEventListener("click", (e) => { e.preventDefault(); open(); }); closeBtn?.addEventListener("click", close); cancelBtn?.addEventListener("click", close); modal.addEventListener("click", (e) => { if (e.target === modal) close(); }); document.addEventListener("keydown", (e) => { if (e.key === "Escape" && modal.style.display !== "none") close(); }); form.addEventListener("submit", async (e) => { e.preventDefault(); const email = emailInput.value.trim(); const message = messageInput?.value.trim() ?? ""; if (!email) return; submitBtn.disabled = true; clearFeedback(); try { const res = await fetch("/api/invite", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email, message }), }); const data = await res.json().catch(() => ({})); if (res.ok) { const remaining = typeof data.remaining_today === "number" ? data.remaining_today : null; const baseMsg = (document.documentElement.lang === "en") ? `Invitation sent to ${email}.` : `Einladung gesendet an ${email}.`; const tail = remaining !== null ? ((document.documentElement.lang === "en") ? ` (${remaining} invitations remaining today.)` : ` (Noch ${remaining} Einladungen heute m\u00f6glich.)`) : ""; setFeedback("success", baseMsg + tail); form.reset(); setTimeout(close, 2500); } else { const msg = typeof data.error === "string" ? data.error : ((document.documentElement.lang === "en") ? "Failed to send invitation." : "Einladung konnte nicht gesendet werden."); setFeedback("error", msg); } } catch (_err) { setFeedback("error", (document.documentElement.lang === "en") ? "Network error — please try again." : "Netzwerkfehler — bitte erneut versuchen."); } finally { submitBtn.disabled = false; } }); }