/* RAPPORT Hosting — Frontend-Logik (Vanilla JS). * * Reine Client-Seite: spricht ausschließlich mit dem proprietären Backend * unter /api (RAPPORT-HOST). Hier liegt KEINE Geschäftslogik, nur fetch + * Rendering — so bleibt RAPPORT-WEBSITE sauber AGPL/öffentlich. * * Token im localStorage; gerendert wird in #hosting-root je nach data-page. */ (function () { "use strict"; const root = document.getElementById("hosting-root"); if (!root) return; const page = root.dataset.page || "login"; const TOKEN_KEY = "rapport_host_token"; const tok = { get: () => localStorage.getItem(TOKEN_KEY), set: (t) => localStorage.setItem(TOKEN_KEY, t), clear: () => localStorage.removeItem(TOKEN_KEY), get isLoggedIn() { return !!localStorage.getItem(TOKEN_KEY); }, }; async function api(method, path, body) { const headers = { "Content-Type": "application/json" }; const t = tok.get(); if (t) headers.Authorization = "Bearer " + t; const res = await fetch("/api" + path, { method, headers, body: body ? JSON.stringify(body) : undefined, }); const data = await res.json().catch(() => ({})); if (!res.ok) throw new Error(data.error || ("Fehler " + res.status)); return data; } const go = (p) => { window.location.href = p; }; const esc = (s) => String(s == null ? "" : s).replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c])); function card(inner, wide) { return '
' + inner + "
"; } // Auth-Karten als 2-Spalter: links Formular, rechts Vertrauens-Panel. // (Login/Register) — wirkt deutlich hochwertiger als die nackte Box. function authCard(formInner) { return ( '
' + '
' + formInner + "
" + '
' + '
RAPPORT
Hosting
' + '" + "
" + "
" ); } function msg(el, text, kind) { el.innerHTML = text ? '
' + esc(text) + "
" : ""; } // ── Login ────────────────────────────────────────────────────────────── function renderLogin() { root.innerHTML = card(authCard( '
Anmelden
' + '
Zu Ihrer Rapport-Instanz
' + '
' + '
' + '' + '' + '' + '' + '' + "
" + '
Noch kein Konto? ' + '
' ), true); const m = root.querySelector("#m"); root.querySelector("#toReg").onclick = () => go("/register/"); root.querySelector("#f").onsubmit = async (e) => { e.preventDefault(); const sub = root.querySelector("#sub"); sub.disabled = true; msg(m, "", ""); try { const { token } = await api("POST", "/auth/login", { email: root.querySelector("#email").value.trim(), password: root.querySelector("#pw").value, }); tok.set(token); go("/konto/"); } catch (err) { msg(m, err.message, "err"); sub.disabled = false; } }; } // ── Registrierung ────────────────────────────────────────────────────── function renderRegister() { root.innerHTML = card(authCard( '
Konto erstellen
' + '
In Minuten zur eigenen Instanz
' + '
' + '
' + '' + '' + '' + '' + '' + "
" + '
Schon ein Konto? ' + '
' ), true); const m = root.querySelector("#m"); root.querySelector("#toLogin").onclick = () => go("/login/"); root.querySelector("#f").onsubmit = async (e) => { e.preventDefault(); const sub = root.querySelector("#sub"); sub.disabled = true; msg(m, "", ""); try { const { token } = await api("POST", "/auth/register", { email: root.querySelector("#email").value.trim(), password: root.querySelector("#pw").value, }); tok.set(token); go("/konto/"); } catch (err) { msg(m, err.message, "err"); sub.disabled = false; } }; } // ── Konto / Dashboard (Tabs: Übersicht · Profil · Sicherheit) ─────────── let acctData = null; // gecachte /account/me-Antwort let acctTab = "overview"; async function renderKonto() { if (!tok.isLoggedIn) return go("/login/"); root.innerHTML = card('
Lädt…
', true); try { acctData = await api("GET", "/account/me"); } catch (err) { if (/angemeldet|abgelaufen|ungültig/i.test(err.message)) { tok.clear(); return go("/login/"); } root.innerHTML = card('
' + esc(err.message) + "
"); return; } paintKonto(); } function paintKonto() { const { account } = acctData; const tabs = [["overview", "Übersicht"], ["profile", "Profil"], ["security", "Sicherheit"]]; const head = '
' + '
Mein Konto
' + '
' + '
' + esc(account.email) + "
" + '
' + tabs.map(([id, label]) => '" ).join("") + "
"; const body = { overview: tabOverview, profile: tabProfile, security: tabSecurity }[acctTab](); root.innerHTML = card(head + '
' + body + "
", true); root.querySelector("#logout").onclick = () => { tok.clear(); go("/"); }; root.querySelectorAll("[data-tab]").forEach((b) => (b.onclick = () => { acctTab = b.dataset.tab; paintKonto(); }) ); wireTab(); } // — Tab: Übersicht (Abo + Instanzen) — function tabOverview() { const { subscription, instances, plans } = acctData; const params = new URLSearchParams(location.search); let h = ""; if (params.get("provisioned") === "1") h += '
Zahlung erfolgreich — Ihre Instanz wird bereitgestellt.
'; if (subscription) { h += '
Abo' + esc(subscription.plan) + " · " + esc(subscription.status) + "
"; if (subscription.current_period_end) h += '
Nächste Verlängerung' + esc(new Date(subscription.current_period_end).toLocaleDateString("de-CH")) + "
"; } if (instances && instances.length) { h += '
Ihre Instanzen
'; h += instances.map((i) => '
' + esc(i.label || i.studio_slug) + "" + '
' + esc(i.instance_url) + "
" + 'Öffnen →
' ).join(""); // Weitere Instanz (Multi-Instanz vorbereitet — Backend-Sperre öffnen wir später) h += '
'; } else { h += '
Wählen Sie ein Abo, um Ihre Instanz freizuschalten:
' + '
' + (plans || []).map(planCard).join("") + "
"; } return h; } // — Tab: Profil (Firma / Rechnungsadresse) — function tabProfile() { const a = acctData.account; const f = (id, label, val, ph) => '" + ''; return '
' + f("company", "Firma / Büro", a.company, "Architektur Muster GmbH") + f("contact_name", "Ansprechperson", a.contact_name, "Vor- und Nachname") + f("street", "Strasse & Nr.", a.street) + '
' + '
' + f("zip", "PLZ", a.zip) + "
" + '
' + f("city", "Ort", a.city) + "
" + f("country", "Land", a.country || "CH") + f("phone", "Telefon", a.phone) + ''; } // — Tab: Sicherheit (Passwort) — function tabSecurity() { return '
' + '' + '' + '' + '' + ''; } // — Event-Wiring je Tab — function wireTab() { if (acctTab === "overview") { root.querySelectorAll("[data-plan]").forEach((b) => (b.onclick = async () => { b.disabled = true; try { const { url } = await api("POST", "/billing/checkout", { planId: b.dataset.plan }); go(url); } catch (err) { alert(err.message); b.disabled = false; } }) ); const add = root.querySelector("#addInstance"); if (add) add.onclick = () => alert("Weitere Instanzen kommen bald. Kontaktieren Sie uns für ein Mehrfach-Abo."); } if (acctTab === "profile") { root.querySelector("#saveProfile").onclick = async (e) => { const btn = e.target; btn.disabled = true; const m = root.querySelector("#pmsg"); const payload = {}; ["company", "contact_name", "street", "zip", "city", "country", "phone"].forEach( (id) => (payload[id] = root.querySelector("#" + id).value.trim()) ); try { const { account } = await api("PATCH", "/account/me", payload); acctData.account = { ...acctData.account, ...account }; msg(m, "Gespeichert.", "ok"); } catch (err) { msg(m, err.message, "err"); } btn.disabled = false; }; } if (acctTab === "security") { root.querySelector("#savePw").onclick = async (e) => { const btn = e.target; btn.disabled = true; const m = root.querySelector("#smsg"); try { await api("POST", "/account/password", { currentPassword: root.querySelector("#curPw").value, newPassword: root.querySelector("#newPw").value, }); root.querySelector("#curPw").value = ""; root.querySelector("#newPw").value = ""; msg(m, "Passwort geändert.", "ok"); } catch (err) { msg(m, err.message, "err"); } btn.disabled = false; }; } } function planCard(p) { return '
' + (p.recommended ? '
Empfohlen
' : "") + '
' + esc(p.name) + "
" + '
CHF ' + esc(p.priceChf) + '/' + esc(p.interval) + "
" + "" + '
'; } // ── Preise (öffentlich, mit CTA in den Flow) ─────────────────────────── async function renderPreise() { root.innerHTML = card('
Lädt…
', true); let plans = []; try { plans = (await api("GET", "/billing/plans")).plans; } catch (_) {} const html = '
Abo wählen
' + '
Monatlich kündbar · Preise in CHF, exkl. MwSt.
' + '
' + plans.map(planCard).join("") + "
"; root.innerHTML = card(html, true); root.querySelectorAll("[data-plan]").forEach((b) => { b.onclick = () => go(tok.isLoggedIn ? "/konto/" : "/register/"); }); } // ── Admin / Betreiber-Bereich (SEPARATES Login, eigener Token) ──────────── const ADMIN_KEY = "rapport_admin_token"; const adminTok = { get: () => localStorage.getItem(ADMIN_KEY), set: (t) => localStorage.setItem(ADMIN_KEY, t), clear: () => localStorage.removeItem(ADMIN_KEY), }; // wie api(), aber mit Admin-Token statt Kunden-Token. async function adminApi(method, path, body) { const headers = { "Content-Type": "application/json" }; const t = adminTok.get(); if (t) headers.Authorization = `Bearer ${t}`; const res = await fetch(`/api${path}`, { method, headers, body: body ? JSON.stringify(body) : undefined }); const data = await res.json().catch(() => ({})); if (!res.ok) { const e = new Error(data.error || `Fehler ${res.status}`); e.status = res.status; throw e; } return data; } function renderAdminLogin(errText) { root.innerHTML = card( '
Betreiber-Login
' + '
Interner Bereich
' + (errText ? '
' + esc(errText) + "
" : "") + '
' + '' + '
' ); root.querySelector("#af").onsubmit = async (e) => { e.preventDefault(); const sub = root.querySelector("#asub"); sub.disabled = true; try { const { token } = await adminApi("POST", "/admin/login", { password: root.querySelector("#apw").value }); adminTok.set(token); renderAdmin(); } catch (err) { renderAdminLogin(err.message); } }; } async function renderAdmin() { if (!adminTok.get()) return renderAdminLogin(""); root.innerHTML = card('
Lädt…
', true); let stats, accounts; try { [stats, accounts] = await Promise.all([ adminApi("GET", "/admin/stats"), adminApi("GET", "/admin/accounts"), ]); } catch (err) { if (err.status === 401 || err.status === 403) { adminTok.clear(); return renderAdminLogin("Bitte neu anmelden."); } root.innerHTML = card('
' + esc(err.message) + "
"); return; } adminCache = { stats, accounts: accounts.accounts }; paintAdmin(); } // Such-/Filter-State des Cockpits. let adminCache = null; let adminSearch = ""; let adminPlanFilter = ""; function statCard(label, val, sub) { return '
' + esc(val) + "
" + '
' + esc(label) + "
" + (sub ? '
' + esc(sub) + "
" : "") + "
"; } function paintAdmin() { const { stats } = adminCache; const planNames = Object.values(stats.byPlan || {}); const planChips = planNames.length ? '
' + planNames.map((p) => '
' + esc(p.name) + " · " + esc(p.count) + ' (CHF ' + esc(p.revenue) + ")
" ).join("") + "
" : ""; // Kunden filtern (Suche über Email/Firma + Plan-Filter). const q = adminSearch.trim().toLowerCase(); const list = adminCache.accounts.filter((a) => { const matchesQ = !q || (a.email + " " + (a.company || "")).toLowerCase().includes(q); const matchesP = !adminPlanFilter || a.plan === adminPlanFilter; return matchesQ && matchesP; }); const rows = list.map((a) => '' + "" + esc(a.email) + "" + (a.company ? '
' + esc(a.company) + "
" : "") + "" + "" + (a.plan ? '' + esc(a.plan) + "" + (a.sub_status && a.sub_status !== "active" ? ' ' + esc(a.sub_status) + "" : "") : '') + "" + '' + esc(a.instance_count) + "" + '' + esc(new Date(a.created_at).toLocaleDateString("de-CH")) + "" + '' + "" ).join(""); const planOptions = [''] .concat(planNames.map((p) => '")) .join(""); const html = '
' + '
Cockpit
' + '
' + '
' + statCard("Kunden", stats.accounts, "+" + (stats.newAccounts30d || 0) + " (30 T.)") + statCard("Aktive Abos", stats.activeSubscriptions) + statCard("Instanzen", stats.activeInstances + "/" + stats.instances, (stats.suspendedInstances ? stats.suspendedInstances + " gesperrt" : "")) + statCard("MRR", "CHF " + stats.mrrChf, "ARR CHF " + (stats.arrChf || stats.mrrChf * 12)) + "
" + planChips + '
' + '' + '" + "
" + '' + "" + (rows || '') + "
KontoAboInst.Seit
Keine Treffer.
"; root.innerHTML = card(html, true); root.querySelector("#alogout").onclick = () => { adminTok.clear(); renderAdminLogin(""); }; const s = root.querySelector("#asearch"); s.oninput = () => { adminSearch = s.value; const pos = s.selectionStart; paintAdmin(); const n = root.querySelector("#asearch"); n.focus(); n.setSelectionRange(pos, pos); }; root.querySelector("#afilter").onchange = (e) => { adminPlanFilter = e.target.value; paintAdmin(); }; root.querySelectorAll(".admin-row").forEach((r) => (r.onclick = () => renderAdminDetail(r.dataset.id)) ); } // — Kunden-Detailansicht (Profil, Abo-Historie, Instanzen + Aktionen) — async function renderAdminDetail(id) { root.innerHTML = card('
Lädt…
', true); let d; try { d = await adminApi("GET", "/admin/accounts/" + id); } catch (err) { if (err.status === 401 || err.status === 403) { adminTok.clear(); return renderAdminLogin("Bitte neu anmelden."); } root.innerHTML = card('
' + esc(err.message) + "
"); return; } const { account: a, subscriptions, instances } = d; const profile = [ ["Firma", a.company], ["Ansprechperson", a.contact_name], ["Adresse", [a.street, [a.zip, a.city].filter(Boolean).join(" "), a.country].filter(Boolean).join(", ")], ["Telefon", a.phone], ].filter(([, v]) => v).map(([k, v]) => '
' + esc(k) + "" + esc(v) + "
" ).join("") || '
Keine Profildaten.
'; const subRows = subscriptions.length ? subscriptions.map((s) => '
' + esc(s.plan) + " " + esc(s.status) + (s.priceChf != null ? ' CHF ' + esc(s.priceChf) + "" : "") + "" + '' + esc(new Date(s.created_at).toLocaleDateString("de-CH")) + "
" ).join("") : '
Kein Abo.
'; const instRows = instances.length ? instances.map((i) => '
' + esc(i.label || i.studio_slug) + " " + '' + esc(i.status) + "" + '
' + esc(i.instance_url) + "
" + '
' + 'Öffnen' + (i.status === "active" ? '' : '') + "
" ).join("") : '
Keine Instanzen.
'; const html = '
' + '' + '
' + '
' + esc(a.email) + "
" + '
Kunde seit ' + esc(new Date(a.created_at).toLocaleDateString("de-CH")) + "
" + '
Profil
' + profile + '
Abo-Historie
' + subRows + '
Instanzen
' + instRows; root.innerHTML = card(html, true); root.querySelector("#aback").onclick = () => paintAdmin(); root.querySelector("#alogout").onclick = () => { adminTok.clear(); renderAdminLogin(""); }; root.querySelectorAll("[data-act]").forEach((b) => (b.onclick = async () => { b.disabled = true; try { await adminApi("POST", "/admin/instances/" + b.dataset.iid + "/" + b.dataset.act); renderAdminDetail(id); // neu laden } catch (err) { alert(err.message); b.disabled = false; } }) ); } ({ login: renderLogin, register: renderRegister, konto: renderKonto, preise: renderPreise, admin: renderAdmin }[page] || renderLogin)(); })();