Phase 5: add internal operator onboarding page

Self-contained admin form that POSTs to the /webhook/onboard endpoint
(creates Clients + Projects + Activity Log rows). Hosted behind HTTP
basic auth at onboard.mivanchenko.de (Caddy basic_auth -> smb-demos
container). Not linked from the public demos; creates billable records
so it stays operator-only.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-25 08:00:36 +02:00
parent a59a22b38b
commit f1577bcefa
+230
View File
@@ -0,0 +1,230 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="robots" content="noindex, nofollow" />
<title>Kunden-Onboarding · SMB-Online (intern)</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,500;9..144,600&family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
<style>
:root {
--bg: #f6f8f8; --surface: #ffffff; --ink: #16302f; --muted: #5d716f;
--teal: #0f8a7e; --teal-2: #0b6b62; --teal-soft: #e4f3f1; --line: #e3eae9;
--radius: 14px; --shadow: 0 16px 44px rgba(16,48,47,.10);
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: 'Inter', system-ui, -apple-system, Segoe UI, Roboto, sans-serif; background: var(--bg); color: var(--ink); line-height: 1.6; -webkit-font-smoothing: antialiased; padding: 0 0 80px; }
h1, h2 { font-family: 'Fraunces', Georgia, serif; font-weight: 600; line-height: 1.15; }
.topbar { background: #0c2b29; color: #9fe6dc; font-size: .76rem; letter-spacing: 1px; text-align: center; padding: 7px 12px; text-transform: uppercase; }
.wrap { width: min(820px, 92vw); margin: 36px auto 0; }
.head { display: flex; align-items: center; gap: 11px; margin-bottom: 6px; }
.logo { width: 34px; height: 34px; border-radius: 9px; background: linear-gradient(135deg, var(--teal), var(--teal-2)); display: grid; place-items: center; color: #fff; font-size: 1.05rem; font-weight: 700; }
.head h1 { font-size: 1.7rem; }
.sub { color: var(--muted); font-size: .95rem; margin-bottom: 26px; }
.card { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow); padding: 26px 28px; }
fieldset { border: 0; margin-bottom: 22px; }
legend { font-family: 'Inter'; font-size: .74rem; text-transform: uppercase; letter-spacing: 1.4px; color: var(--teal-2); font-weight: 700; margin-bottom: 14px; width: 100%; border-bottom: 1px solid var(--line); padding-bottom: 7px; }
.row { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-bottom: 14px; }
.row.one { grid-template-columns: 1fr; }
.field { display: flex; flex-direction: column; gap: 5px; }
label { font-size: .82rem; font-weight: 600; color: var(--ink); }
label .opt { color: var(--muted); font-weight: 400; }
input, select, textarea { font-family: inherit; font-size: .94rem; color: var(--ink); background: var(--bg); border: 1px solid var(--line); border-radius: 9px; padding: 10px 12px; transition: border-color .15s, background .15s; }
input:focus, select:focus, textarea:focus { outline: none; border-color: var(--teal); background: #fff; }
textarea { resize: vertical; min-height: 64px; }
.hint { font-size: .76rem; color: var(--muted); }
.tier-toggle { display: flex; gap: 10px; }
.tier-toggle label { flex: 1; cursor: pointer; border: 1px solid var(--line); border-radius: 10px; padding: 11px 13px; background: var(--bg); display: flex; flex-direction: column; gap: 2px; transition: all .15s; }
.tier-toggle input { display: none; }
.tier-toggle label:has(input:checked) { border-color: var(--teal); background: var(--teal-soft); }
.tier-toggle b { font-size: .9rem; }
.tier-toggle span { font-size: .74rem; color: var(--muted); font-weight: 400; }
.preview { background: var(--teal-soft); border: 1px dashed var(--teal); border-radius: 10px; padding: 13px 15px; font-size: .84rem; color: var(--teal-2); margin-bottom: 20px; }
.preview b { font-weight: 700; }
.actions { display: flex; align-items: center; gap: 16px; margin-top: 6px; }
.btn { background: var(--teal); color: #fff; font-weight: 600; padding: 13px 26px; border-radius: 999px; border: 0; cursor: pointer; font-size: .98rem; transition: transform .15s, background .2s; }
.btn:hover { transform: translateY(-2px); background: var(--teal-2); }
.btn:disabled { opacity: .6; cursor: default; transform: none; }
.toast { display: none; padding: 13px 16px; border-radius: 10px; font-size: .9rem; font-weight: 500; margin-top: 18px; }
.toast.ok { background: #e3f6ec; color: #0c6b3c; border: 1px solid #b8e6cc; }
.toast.err { background: #fdecec; color: #9b2226; border: 1px solid #f3c7c7; }
@media (max-width: 620px) { .row { grid-template-columns: 1fr; } }
</style>
</head>
<body>
<div class="topbar">⚙ Interne Seite — Kunden anlegen · nicht öffentlich teilen</div>
<div class="wrap">
<div class="head">
<div class="logo"></div>
<h1>Kunden-Onboarding</h1>
</div>
<p class="sub">Neuen Kunden ins CRM aufnehmen. Erzeugt Zeilen in <b>Clients</b>, <b>Projects</b> und <b>Activity Log</b>. <code>client_id</code>, <code>renewal_date</code> &amp; <code>created_at</code> werden automatisch vergeben.</p>
<div class="card">
<form id="onboard" onsubmit="return submitOnboard(event, this)">
<fieldset>
<legend>Betrieb</legend>
<div class="row">
<div class="field">
<label for="business_name">Firmenname *</label>
<input id="business_name" name="business_name" required placeholder="Café Lichtblick" />
</div>
<div class="field">
<label for="owner_name">Inhaber:in <span class="opt">(optional)</span></label>
<input id="owner_name" name="owner_name" placeholder="Sara Hell" />
</div>
</div>
<div class="row">
<div class="field">
<label for="email">E-Mail <span class="opt">(optional)</span></label>
<input id="email" name="email" type="email" placeholder="sara@cafe-lichtblick.de" />
</div>
<div class="field">
<label for="phone">Telefon <span class="opt">(optional)</span></label>
<input id="phone" name="phone" placeholder="+49 911 445566" />
</div>
</div>
<div class="row">
<div class="field">
<label for="niche">Branche <span class="opt">(optional)</span></label>
<input id="niche" name="niche" placeholder="Café" />
</div>
<div class="field">
<label for="domain">Domain <span class="opt">(optional)</span></label>
<input id="domain" name="domain" placeholder="cafe-lichtblick.de" />
</div>
</div>
</fieldset>
<fieldset>
<legend>Leistung &amp; Abrechnung</legend>
<div class="row one">
<div class="field">
<label>Tier</label>
<div class="tier-toggle">
<label><input type="radio" name="tier" value="A" checked /><b>Tier A</b><span>Google-Stack · €0 Infra</span></label>
<label><input type="radio" name="tier" value="B" /><b>Tier B</b><span>Self-hosted · Datenschutz</span></label>
</div>
</div>
</div>
<div class="row one">
<div class="field">
<label for="services">Services <span class="opt">(Komma-getrennt)</span></label>
<input id="services" name="services" value="site, booking, leads" />
<span class="hint">Wird zur Projekt-Checkliste: jedes Wort wird ein Häkchen.</span>
</div>
</div>
<div class="row">
<div class="field">
<label for="billing_cycle">Abrechnung</label>
<select id="billing_cycle" name="billing_cycle" onchange="updatePreview()">
<option value="monthly">monatlich</option>
<option value="yearly">jährlich</option>
<option value="one-off">einmalig</option>
</select>
</div>
<div class="field">
<label for="monthly_fee_eur">Gebühr (€) <span class="opt">(optional)</span></label>
<input id="monthly_fee_eur" name="monthly_fee_eur" type="number" min="0" step="1" placeholder="35" />
</div>
</div>
<div class="row one">
<div class="field">
<label for="start_date">Startdatum <span class="opt">(leer = heute)</span></label>
<input id="start_date" name="start_date" type="date" onchange="updatePreview()" />
</div>
</div>
</fieldset>
<fieldset>
<legend>Intern</legend>
<div class="row">
<div class="field">
<label for="stack_notes">Stack-Notizen <span class="opt">(optional)</span></label>
<input id="stack_notes" name="stack_notes" placeholder="Cloudflare Pages + Google Kalender" />
</div>
<div class="field">
<label for="vault_ref">Vault-Referenz <span class="opt">(optional)</span></label>
<input id="vault_ref" name="vault_ref" placeholder="VW-0002" />
</div>
</div>
<div class="row one">
<div class="field">
<label for="notes">Notizen <span class="opt">(optional)</span></label>
<textarea id="notes" name="notes" placeholder="Sonstiges …"></textarea>
</div>
</div>
</fieldset>
<div class="preview" id="preview"></div>
<div class="actions">
<button class="btn" type="submit">Kunde anlegen</button>
<span class="hint">Schreibt ins CRM &amp; meldet per Telegram — kann ~14 Min dauern.</span>
</div>
<div class="toast" id="toast"></div>
</form>
</div>
</div>
<script>
const ONBOARD_WEBHOOK = 'https://n8n.mivanchenko.de/webhook/onboard';
function addMonths(d, n) { const x = new Date(d); x.setUTCMonth(x.getUTCMonth() + n); return x; }
function addYears(d, n) { const x = new Date(d); x.setUTCFullYear(x.getUTCFullYear() + n); return x; }
function addDays(d, n) { const x = new Date(d); x.setUTCDate(x.getUTCDate() + n); return x; }
function iso(d) { return d.toISOString().slice(0, 10); }
function updatePreview() {
const sd = document.getElementById('start_date').value;
const cycle = document.getElementById('billing_cycle').value;
const start = sd ? new Date(sd + 'T00:00:00Z') : new Date();
let renewal = '—';
if (cycle === 'monthly') renewal = iso(addMonths(start, 1));
else if (cycle === 'yearly') renewal = iso(addYears(start, 1));
const goLive = iso(addDays(start, 14));
document.getElementById('preview').innerHTML =
'Auto-berechnet → Start: <b>' + iso(start) + '</b> · Verlängerung: <b>' + renewal +
'</b> · Go-Live-Ziel: <b>' + goLive + '</b> · client_id wird fortlaufend vergeben.';
}
async function submitOnboard(e, form) {
e.preventDefault();
const btn = form.querySelector('button[type=submit]');
const toast = document.getElementById('toast');
const data = Object.fromEntries(new FormData(form).entries());
const old = btn.textContent;
btn.disabled = true; btn.textContent = 'Wird angelegt …';
toast.style.display = 'none';
try {
const r = await fetch(ONBOARD_WEBHOOK, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
if (!r.ok) throw new Error(r.status);
toast.className = 'toast ok';
toast.textContent = '✓ Kunde übermittelt. Zeilen erscheinen in 14 Min im Sheet, Telegram-Meldung folgt.';
toast.style.display = 'block';
form.reset();
document.querySelector('input[name=tier][value=A]').checked = true;
document.getElementById('services').value = 'site, booking, leads';
updatePreview();
} catch (err) {
toast.className = 'toast err';
toast.textContent = '⚠ Übermittlung fehlgeschlagen (' + err.message + '). Nochmal versuchen.';
toast.style.display = 'block';
} finally {
btn.disabled = false; btn.textContent = old;
}
return false;
}
updatePreview();
</script>
</body>
</html>