markemacht/resources/js/web/klarheits-check.js
Kevin Adametz ba4098ba55 feat(marketing): Marken-Klarheits-Check — Seite, geführte UX und Hochkant-Tab
Inbound-Magnet aus dem Konzept (dev/klarheits-check/): 5 Fragen mit Skala +
optionalem Freitext, deterministischer Reifegrad im Frontend, KI-Einordnung
folgt in Phase 3 über einen serverseitigen Proxy.

- Route + Seite /klarheits-check im Marketing-Layout (teilbare URL)
- Fragenkatalog als config/klarheits_check.php (eine Wahrheit für Anzeige + späteren Prompt)
- Wiederverwendbare Partial; Quiz-JS mit Auswahl, geführter Navigation
  (Check starten → Frage 1, „Weiter" pro Frage), deterministischem Reifegrad
  und Ergebnis-Rendering; Fetch an POST /klarheits-check/auswerten mit
  sauberem Fallback (Endpoint folgt in Phase 3)
- Inline-Validierung mit Sprung zur offenen Frage; „Neu starten" hinter dem Audit-CTA
- Hochkant-Tab rechts (mobil Pill unten rechts), verlinkt auf die Seite,
  auf der Seite selbst ausgeblendet
- kc-*-Styles über die bestehenden Theme-Tokens (beide Wirkungs-Modi)
- CSRF-Meta im Layout für den späteren POST
- Feature-Tests (Rendering, geführte Navigation, Tab-Sichtbarkeit)

Enthält erstmalig routes/marketing.php und das Theme-Stylesheet, da die
Funktion darauf aufbaut. config/services.php (Anthropic/OpenAI) bleibt für
den Phase-3-Commit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 17:21:54 +00:00

220 lines
9 KiB
JavaScript

/* Marken-Klarheits-Check — Quiz-State, geführte Navigation, deterministischer Reifegrad,
* Ergebnis-Rendering. Die KI-Einordnung kommt (nur bei Freitext) vom serverseitigen Proxy
* POST /klarheits-check/auswerten. Schlägt der Call fehl oder fehlt Freitext, greift das
* deterministische Ergebnis — das Tool funktioniert immer.
* Funktioniert auf /klarheits-check wie im Overlay (mehrere [data-kc]-Roots).
*/
(function () {
'use strict';
// Reifegrad je Skala-Index — Spiegel zu config/klarheits_check.php.
const GRAD = ['scharf', 'unscharf', 'nicht entschieden', 'nicht entschieden'];
const GRADCLASS = ['scharf', 'unscharf', 'offen', 'offen'];
function escapeHtml(s) {
return String(s).replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
}
function scrollToEl(el) {
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
function gradLine(state) {
const c = { scharf: 0, unscharf: 0, offen: 0 };
state.forEach((s) => { if (s.sel != null) c[GRADCLASS[s.sel]]++; });
if (c.scharf >= 4) return 'Ihr Fundament steht weitgehend. Die Schärfe ist da, jetzt zählt, dass sie auch dokumentiert ist.';
if (c.offen >= 3) return 'Das Fundament ist an mehreren Stellen noch nicht entschieden. Das ist kein Mangel an Substanz, sondern an Festlegung.';
return 'Das Fundament steht in Teilen. An einigen Stellen ist die Entscheidung noch nicht getroffen oder nicht festgehalten.';
}
function initCheck(root) {
if (root.dataset.kcInit) return; // Doppel-Init vermeiden (Seite + Overlay teilen das Markup)
root.dataset.kcInit = '1';
const questions = Array.from(root.querySelectorAll('.kc-q'));
const state = questions.map(() => ({ sel: null, text: '' }));
// Antwort wählen → markieren, Freitextfeld dezent einladen, Weiter-Button zeigen.
root.querySelectorAll('[data-kc-opt]').forEach((btn) => {
btn.addEventListener('click', () => {
const q = +btn.dataset.q;
const o = +btn.dataset.o;
state[q].sel = o;
root.querySelectorAll('[data-kc-opt][data-q="' + q + '"]').forEach((x) => x.setAttribute('aria-pressed', 'false'));
btn.setAttribute('aria-pressed', 'true');
const qEl = questions[q];
const field = qEl.querySelector('[data-kc-field]');
if (field && !state[q].text) field.classList.add('is-invited'); // Hervorhebung, KEIN Fokus
const next = qEl.querySelector('[data-kc-next]');
if (next) next.hidden = false;
hideError(root);
});
});
// Freitext → speichern; sobald etwas drinsteht, Einladungs-Hervorhebung lösen.
root.querySelectorAll('[data-kc-text]').forEach((t) => {
t.addEventListener('input', () => {
const q = +t.dataset.q;
state[q].text = t.value.trim();
const field = questions[q].querySelector('[data-kc-field]');
if (field) field.classList.toggle('is-invited', !state[q].text && state[q].sel != null);
});
});
// Weiter → nächste Frage, bei der letzten zur Auswertung.
root.querySelectorAll('[data-kc-next-btn]').forEach((btn) => {
btn.addEventListener('click', () => {
const qEl = btn.closest('.kc-q');
const idx = questions.indexOf(qEl);
const target = questions[idx + 1] || root.querySelector('[data-kc-submit]');
scrollToEl(target);
});
});
const submitBtn = root.querySelector('[data-kc-submit]');
if (submitBtn) submitBtn.addEventListener('click', () => auswerten(root, state, submitBtn, questions));
const jumpBtn = root.querySelector('[data-kc-error-jump]');
if (jumpBtn) {
jumpBtn.addEventListener('click', () => {
const firstOpen = state.findIndex((s) => s.sel == null);
if (firstOpen !== -1) scrollToEl(questions[firstOpen]);
});
}
const resetBtn = root.querySelector('[data-kc-reset]');
if (resetBtn) resetBtn.addEventListener('click', () => window.location.reload());
}
function hideError(root) {
const err = root.querySelector('[data-kc-error]');
if (err) err.hidden = true;
}
async function auswerten(root, state, submitBtn, questions) {
if (state.some((s) => s.sel == null)) {
const err = root.querySelector('[data-kc-error]');
if (err) err.hidden = false;
return;
}
hideError(root);
submitBtn.disabled = true;
const loading = root.querySelector('[data-kc-loading]');
const hatText = state.some((s) => s.text);
if (loading && hatText) loading.hidden = false;
let ki = null;
if (hatText) {
try {
const meta = document.querySelector('meta[name="csrf-token"]');
const res = await fetch('/klarheits-check/auswerten', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
'X-CSRF-TOKEN': meta ? meta.getAttribute('content') : '',
},
body: JSON.stringify({ antworten: state.map((s) => ({ skala: s.sel, text: s.text })) }),
});
if (res.ok) ki = await res.json();
} catch (_) { ki = null; }
}
render(root, state, ki, questions);
if (loading) loading.hidden = true;
// Ergebnis-Zustand: Fragen + kompletter Aktionsbereich weg, Hero-Start weg.
const form = root.querySelector('[data-kc-form]');
if (form) form.hidden = true;
const actions = root.querySelector('.kc-actions');
if (actions) actions.hidden = true;
document.querySelectorAll('[data-kc-start-link]').forEach((el) => { el.hidden = true; });
// „Neu starten“ hinter den Audit-CTA ans Ende des Ergebnisses verschieben.
const result = root.querySelector('[data-kc-result]');
const resetBtn = root.querySelector('[data-kc-reset]');
const lastBlock = result ? result.querySelector('.kc-r-block:last-child') : null;
if (resetBtn && lastBlock) {
resetBtn.hidden = false;
lastBlock.appendChild(resetBtn);
}
scrollToEl(result);
}
function render(root, state, ki, questions) {
const spiegelFor = (n) => {
if (!ki || !ki.spiegel) return '';
const m = ki.spiegel.find((s) => s.nr === n);
return m && m.text ? m.text : '';
};
let html = '<div class="kc-r-eyebrow meta">Ihr Ergebnis</div>'
+ '<div class="kc-r-gesamt">' + (ki && ki.gesamt ? escapeHtml(ki.gesamt) : escapeHtml(gradLine(state))) + '</div>';
questions.forEach((q, i) => {
const sel = state[i].sel;
const bw = (q.querySelector('.kc-q-bw') || {}).textContent || '';
const selBtn = root.querySelector('[data-kc-opt][data-q="' + i + '"][aria-pressed="true"]');
const optText = selBtn ? selBtn.textContent.trim() : '';
const spiegel = spiegelFor(i + 1);
html += '<div class="kc-r-row">'
+ '<div><div class="kc-r-grad ' + GRADCLASS[sel] + '">' + GRAD[sel] + '</div></div>'
+ '<div><div class="kc-r-label meta">' + escapeHtml(bw) + '</div>'
+ '<div class="kc-r-spiegel">' + (spiegel ? escapeHtml(spiegel) : 'Einschätzung: ' + escapeHtml(optText) + '.') + '</div></div>'
+ '</div>';
});
if (ki && ki.luecken && ki.luecken.length) {
html += '<div class="kc-r-block"><h3 class="meta">Lücke zwischen Selbstbild und Wortlaut</h3><ul>'
+ ki.luecken.map((l) => '<li>' + escapeHtml(l) + '</li>').join('') + '</ul></div>';
}
if (ki && ki.stimmigkeit && ki.stimmigkeit.length) {
html += '<div class="kc-r-block"><h3 class="meta">Innere Stimmigkeit</h3><ul>'
+ ki.stimmigkeit.map((l) => '<li>' + escapeHtml(l) + '</li>').join('') + '</ul></div>';
}
const schluss = ki && ki.schluss
? ki.schluss
: 'Wo das Fundament noch unscharf ist, liegt das nicht an Ihnen, sondern an einer Entscheidung, die noch aussteht. Genau diese Entscheidung hält das Marken-Klarheits-Audit als dokumentierte Marken-DNA fest.';
html += '<div class="kc-r-block"><div class="kc-r-schluss">' + escapeHtml(schluss) + '</div>'
+ '<a class="btn btn-nav-cta" href="/audit">Zum Marken-Klarheits-Audit</a></div>';
const result = root.querySelector('[data-kc-result]');
if (result) {
result.innerHTML = html;
result.hidden = false;
}
}
function init() {
document.querySelectorAll('[data-kc]').forEach(initCheck);
// „Check starten“ im Hero: sanft zum Anker scrollen statt hart zu springen.
document.querySelectorAll('[data-kc-start-link]').forEach((link) => {
if (link.dataset.kcBound) return;
link.dataset.kcBound = '1';
link.addEventListener('click', (e) => {
const href = link.getAttribute('href') || '';
const target = href.charAt(0) === '#' ? document.querySelector(href) : null;
if (target) {
e.preventDefault();
target.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
});
});
}
if (document.readyState !== 'loading') {
init();
} else {
document.addEventListener('DOMContentLoaded', init);
}
// Für dynamisch eingefügte Overlays (CP 2.x) erneut aufrufbar.
window.MarkemachtKlarheitsCheck = { init: init };
})();