feat(hosting): Hosting-Marketing + Login/Konto als Vanilla-JS-Seiten
Integriert das RAPPORT-Hosting-Angebot direkt in die bestehende Hugo-Seite
(gleicher openbureau/Hextra-Look):
- /hosting Marketing-Landing (Hero, Feature-Grid)
- /hosting-preise Abo-Übersicht (Plans aus /api/billing/plans)
- /login /register /konto Vanilla-JS-Seiten (layouts/hosting.html +
static/js/hosting-app.js), sprechen NUR mit /api
- custom.css: Karten-/Formular-/Plan-Styles im RAPPORT-Token-Set
- Navbar: 'Hosting' + 'Anmelden'
Bleibt sauber AGPL: kein Backend-Code, nur fetch('/api'). Das proprietäre
Backend (Auth/Stripe/Provisioning) liegt in RAPPORT-HOST.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,180 @@
|
||||
/* 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 '<div class="hosting-card' + (wide ? " wide" : "") + '">' + inner + "</div>";
|
||||
}
|
||||
function msg(el, text, kind) {
|
||||
el.innerHTML = text ? '<div class="hosting-msg ' + kind + '">' + esc(text) + "</div>" : "";
|
||||
}
|
||||
|
||||
// ── Login ──────────────────────────────────────────────────────────────
|
||||
function renderLogin() {
|
||||
root.innerHTML = card(
|
||||
'<div class="hosting-title">Anmelden</div>' +
|
||||
'<div class="hosting-sub">Zu Ihrer Rapport-Instanz</div>' +
|
||||
'<div id="m"></div>' +
|
||||
'<form id="f">' +
|
||||
'<label class="hosting-label">E-Mail</label>' +
|
||||
'<input class="hosting-input" type="email" id="email" placeholder="name@studio.ch" autofocus>' +
|
||||
'<label class="hosting-label">Passwort</label>' +
|
||||
'<input class="hosting-input" type="password" id="pw" placeholder="••••••">' +
|
||||
'<button class="hosting-btn hosting-btn-primary" id="sub">Anmelden</button>' +
|
||||
"</form>" +
|
||||
'<div class="hosting-foot">Noch kein Konto? ' +
|
||||
'<button class="hosting-link" id="toReg">Registrieren</button></div>'
|
||||
);
|
||||
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(
|
||||
'<div class="hosting-title">Konto erstellen</div>' +
|
||||
'<div class="hosting-sub">In Minuten zur eigenen Instanz</div>' +
|
||||
'<div id="m"></div>' +
|
||||
'<form id="f">' +
|
||||
'<label class="hosting-label">E-Mail</label>' +
|
||||
'<input class="hosting-input" type="email" id="email" placeholder="name@studio.ch" autofocus>' +
|
||||
'<label class="hosting-label">Passwort</label>' +
|
||||
'<input class="hosting-input" type="password" id="pw" placeholder="min. 8 Zeichen">' +
|
||||
'<button class="hosting-btn hosting-btn-primary" id="sub">Konto erstellen</button>' +
|
||||
"</form>" +
|
||||
'<div class="hosting-foot">Schon ein Konto? ' +
|
||||
'<button class="hosting-link" id="toLogin">Anmelden</button></div>'
|
||||
);
|
||||
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 ──────────────────────────────────────────────────
|
||||
async function renderKonto() {
|
||||
if (!tok.isLoggedIn) return go("/login/");
|
||||
root.innerHTML = card('<div class="hosting-sub">Lädt…</div>', true);
|
||||
let data;
|
||||
try { data = await api("GET", "/account/me"); }
|
||||
catch (err) {
|
||||
if (/angemeldet|abgelaufen|ungültig/i.test(err.message)) { tok.clear(); return go("/login/"); }
|
||||
root.innerHTML = card('<div class="hosting-msg err">' + esc(err.message) + "</div>"); return;
|
||||
}
|
||||
const { account, subscription, instance, plans } = data;
|
||||
const params = new URLSearchParams(location.search);
|
||||
const justOk = params.get("provisioned") === "1";
|
||||
|
||||
let html =
|
||||
'<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:24px">' +
|
||||
'<div class="hosting-title" style="text-align:left;margin:0">Konto</div>' +
|
||||
'<button class="hosting-link" id="logout">Abmelden</button></div>' +
|
||||
'<div class="hosting-sub" style="text-align:left;margin-bottom:20px">' + esc(account.email) + "</div>";
|
||||
|
||||
if (justOk) html += '<div class="hosting-msg ok">Zahlung erfolgreich — Ihre Instanz wird bereitgestellt.</div>';
|
||||
|
||||
if (subscription) {
|
||||
html += '<div class="hosting-row"><span class="muted">Abo</span><b>' + esc(subscription.plan) +
|
||||
" · " + esc(subscription.status) + "</b></div>";
|
||||
}
|
||||
if (instance) {
|
||||
html += '<div class="hosting-row"><span class="muted">Instanz</span><b>' + esc(instance.status) + "</b></div>" +
|
||||
'<a class="hosting-btn hosting-btn-primary" style="display:block;text-align:center;text-decoration:none;margin-top:18px" href="' +
|
||||
esc(instance.instance_url) + '" target="_blank" rel="noreferrer">Rapport öffnen →</a>';
|
||||
} else {
|
||||
html += '<div style="margin:18px 0 8px;font-size:14px;color:var(--rapport-text-2)">Wählen Sie ein Abo, um Ihre Instanz freizuschalten:</div>' +
|
||||
'<div class="hosting-plans">' + (plans || []).map(planCard).join("") + "</div>";
|
||||
}
|
||||
root.innerHTML = card(html, true);
|
||||
root.querySelector("#logout").onclick = () => { tok.clear(); go("/"); };
|
||||
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; }
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function planCard(p) {
|
||||
return '<div class="hosting-plan' + (p.recommended ? " rec" : "") + '">' +
|
||||
(p.recommended ? '<div class="pbadge">Empfohlen</div>' : "") +
|
||||
'<div class="pname">' + esc(p.name) + "</div>" +
|
||||
'<div class="pprice">CHF ' + esc(p.priceChf) + '<span style="font-size:13px;color:var(--rapport-text-3)">/' + esc(p.interval) + "</span></div>" +
|
||||
"<ul>" + (p.features || []).map((f) => "<li>" + esc(f) + "</li>").join("") + "</ul>" +
|
||||
'<button class="hosting-btn hosting-btn-primary" data-plan="' + esc(p.id) + '">Wählen</button></div>';
|
||||
}
|
||||
|
||||
// ── Preise (öffentlich, mit CTA in den Flow) ───────────────────────────
|
||||
async function renderPreise() {
|
||||
root.innerHTML = card('<div class="hosting-sub">Lädt…</div>', true);
|
||||
let plans = [];
|
||||
try { plans = (await api("GET", "/billing/plans")).plans; } catch (_) {}
|
||||
const html =
|
||||
'<div class="hosting-title">Abo wählen</div>' +
|
||||
'<div class="hosting-sub">Monatlich kündbar · Preise in CHF, exkl. MwSt.</div>' +
|
||||
'<div class="hosting-plans">' + plans.map(planCard).join("") + "</div>";
|
||||
root.innerHTML = card(html, true);
|
||||
root.querySelectorAll("[data-plan]").forEach((b) => {
|
||||
b.onclick = () => go(tok.isLoggedIn ? "/konto/" : "/register/");
|
||||
});
|
||||
}
|
||||
|
||||
({ login: renderLogin, register: renderRegister, konto: renderKonto, preise: renderPreise }[page] || renderLogin)();
|
||||
})();
|
||||
Reference in New Issue
Block a user