/* Kundenansicht neu (P3): Einschalten einer Funktion im Abo-Modell. Dieser Teil ist rein (ohne DOM, ohne Netz) und läuft unter Node: Wege nach der Vorprüfung, Fristen, Körper des Absendens, Fehlerfolgen. Die Vorprüfung spiegelt aboDeckt (server/services/aboFunktionen.js) nur, um den richtigen Ort zu wählen; entschieden wird immer vom Server (POST /api/funktionen//einschalten, 409 mit Code). Kundentexte sind Festwerte (TEXTE), Fremdtext kommt nie als HTML in die Seite. */ import { h, leeren } from './dom.js' import { api as apiStandard } from './api.js' import { geheZu as geheZuStandard } from './router.js' /** Etage 2 je Funktion (Schlüssel der Hülle in FUNKTIONS_MODULE, Hash `#?projekt=`). */ export const ETAGE_FUER = Object.freeze({ news: 'news', termine: 'termine', shop: 'webshop', unterkunft: 'buchungen', zeiten: 'zeiten', analyse: 'analyse' }) /** Festtexte der Oberfläche (du-Form, kein Preis). */ export const TEXTE = Object.freeze({ stundenkonto: 'Die Einrichtung geht von deinem Stundenkonto ab, auch wenn das Guthaben noch nicht reicht. Extrakosten entstehen keine.', nichtVerfuegbar: 'Diese Funktion ist für dich gerade nicht verfügbar.', gekuendigt: 'Dein Abo ist gekündigt. Solange die Kündigung läuft, kannst du nichts Neues einschalten.', nichtAktiv: 'Dein Abo ist noch nicht aktiv. Sobald die erste Zahlung da ist, kannst du einschalten.', falschesAbo: 'Dein Abo enthält diese Funktion nicht.', beginntSpaeter: (datum) => `Dein Abo beginnt am ${datum}. Ab dann kannst du einschalten.`, fassungVeraltet: 'Die Bedingungen wurden aktualisiert. Bitte lies die Zeile noch einmal.', allgemein: 'Das Einschalten hat gerade nicht geklappt. Versuch es gleich noch einmal oder schreib uns im Chat.', nurBetrieb: (name) => `${name} gibt es für Betriebe. Stimmt das nicht? Schreib uns im Chat.`, registerFehlt: 'Einschalten ist gerade nicht möglich. Schreib uns im Chat.', kundenansicht: 'In der Kundenansicht kann nichts eingeschaltet werden.', kundenansichtKnopf: 'In der Kundenansicht nicht möglich.', // Hinweis neben dem gesperrten Knopf in Entdecken laden: 'Die Angaben konnten gerade nicht geladen werden. Bitte versuch es gleich noch einmal.', }) /** Die Erklärung der Funktion aus dem Katalog der Antwort (einzige Stelle für das Register); fehlt sie, null. */ export function consentVon(daten, key) { const eintrag = Array.isArray(daten?.katalog) ? daten.katalog.find((e) => e?.key === key) : null const consent = eintrag?.consent return consent && typeof consent === 'object' && Array.isArray(consent.items) ? consent : null } /** Anfragestatus von Webshop oder Buchungskalender an einer Website (N18, einzige Stelle); sonst ''. */ export function anfrageStatusVon(projekt, key) { const status = projekt?.features?.[key]?.anfrageStatus return typeof status === 'string' ? status : '' } /** * Wohin führt „Einschalten“? Prüfreihenfolge wie der Server (V5): verfügbar, gekündigt, kein Abo, nicht aktiv, Hosting bei einer * Wartungs-Funktion, nur Betrieb; danach Etage (Analyse, Fragebogen, Anfrage ohne Zusage) oder die Bestätigungsseite. * Alles außer 'unternehmen' gilt als privat (E4). */ export function einschaltWeg({ eintrag, projekt, abo, kundenart }) { if (eintrag?.verfuegbar !== true) return { art: 'meldung', text: TEXTE.nichtVerfuegbar } if (abo?.gekuendigt === true) return { art: 'meldung', text: TEXTE.gekuendigt } if (!abo || abo.produkt === null || abo.produkt === undefined) return { art: 'bestellen', produkt: eintrag.enthaltenIn } if (abo.laeuft !== true) return { art: 'meldung', text: TEXTE.nichtAktiv } if (eintrag.enthaltenIn === 'wartung' && abo.produkt === 'hosting') return { art: 'wechsel' } if (eintrag.nurBetrieb === true && kundenart !== 'unternehmen') return { art: 'meldung', text: TEXTE.nurBetrieb(eintrag.name) } if (eintrag.key === 'analyse') return { art: 'etage', etage: ETAGE_FUER.analyse } if (eintrag.ablauf === 'fragebogen') return { art: 'etage', etage: ETAGE_FUER[eintrag.key] } if (eintrag.ablauf === 'anfrage' && anfrageStatusVon(projekt, eintrag.key) !== 'angenommen') return { art: 'etage', etage: ETAGE_FUER[eintrag.key] } return { art: 'seite' } } const TAG = /^\d{4}-\d{2}-\d{2}$/ /** Berliner Kalendertag 'JJJJ-MM-TT' eines Zeitpunkts (ISO-Text, Date) oder eines Tagestextes; unlesbar ergibt ''. */ function berlinTag(wert) { if (typeof wert === 'string' && TAG.test(wert)) return wert const ms = wert instanceof Date ? wert.getTime() : (typeof wert === 'string' && wert !== '' ? Date.parse(wert) : Number.NaN) if (Number.isNaN(ms)) return '' return new Date(ms).toLocaleDateString('sv-SE', { timeZone: 'Europe/Berlin' }) } /** Der Berliner Tag nach dem Fristende (Zeitpunkt) als 'JJJJ-MM-TT'; unlesbar ergibt ''. */ export function tagNachFrist(wert) { const tag = berlinTag(wert) if (!tag) return '' const [j, m, t] = tag.split('-').map(Number) return new Date(Date.UTC(j, m - 1, t + 1)).toISOString().slice(0, 10) } /** 'JJJJ-MM-TT' → 'TT.MM.'; sonst ''. */ export function datumKurz(tag) { if (typeof tag !== 'string' || !TAG.test(tag)) return '' const [, m, t] = tag.split('-') return `${t}.${m}.` } /** 'JJJJ-MM-TT' → 'TT.MM.JJJJ' wie datumDe des Servers; sonst ''. */ function datumLang(tag) { if (typeof tag !== 'string' || !TAG.test(tag)) return '' const [j, m, t] = tag.split('-') return `${t}.${m}.${j}` } /** * Braucht der Kunde das Häkchen „sofortiger Beginn“, und ab wann ginge es ohne? Nur Privatkunden (leer zählt als privat) und nur bis * zum Fristende (am Ende selbst noch Frist). Eine Antwort des Servers (serverAb: ISO oder Tag) gewinnt. * @returns {{ noetig: boolean, ab: string }} ab = 'JJJJ-MM-TT' oder '' */ export function beginnLage({ kundenart, abo, jetzt, serverAb = '' }) { const vomServer = berlinTag(serverAb) if (vomServer) return { noetig: true, ab: vomServer } const bis = abo?.widerrufsfristBis const ende = typeof bis === 'string' ? Date.parse(bis) : Number.NaN if (kundenart !== 'unternehmen' && ende >= jetzt.getTime()) return { noetig: true, ab: tagNachFrist(bis) } return { noetig: false, ab: '' } } /** Körper des Absendens: Fassung aus dem Register, Haken nur 'beginn'; die Kundenart sendet der Client nie. */ export function einschaltKoerper({ projectId, consent, beginnGesetzt }) { return { projectId, zustimmung: { version: consent.version, haken: beginnGesetzt ? ['beginn'] : [] } } } /** Nur https ohne Zugangsdaten oder Pfade unter /rechtliches/, /vertragsunterlagen/ und /neu/rechtliches/ (Fassung 6); sonst ''. */ export function sichereAdresse(url) { if (typeof url !== 'string' || url === '') return '' if (/[\s\u0000-\u001f\u007f\\]/.test(url) || url.includes('..') || /%(2e|2f|5c)/i.test(url)) return '' if (url.startsWith('/rechtliches/') || url.startsWith('/vertragsunterlagen/') || url.startsWith('/neu/rechtliches/')) return url if (!url.startsWith('https://')) return '' try { const u = new URL(url) return u.protocol === 'https:' && u.hostname !== '' && u.username === '' && u.password === '' ? url : '' } catch { return '' } } const UNTERLAGEN_WOERTER = [['Leistungsbeschreibung', 'leistungsbeschreibung'], ['Auftragsverarbeitung', 'avv']] /** * Die Zeile aus dem Register als Stücke [{ text, href? }]: das erste Vorkommen von „Leistungsbeschreibung“ und „Auftragsverarbeitung“ * wird zum Link, wenn die Unterlage eine sichere Adresse hat. Fehlt das Wort bei einer sicheren Unterlage, hängt ihr Link hinten an * (zwei Links mit „ · “ dazwischen). Benachbarte Textstücke sind zusammengefasst. */ export function linkzeile(text, unterlagen) { const basis = typeof text === 'string' ? text : '' const treffer = [] const anhang = [] for (const [wort, schluessel] of UNTERLAGEN_WOERTER) { const href = sichereAdresse(unterlagen?.[schluessel]?.url) if (!href) continue const pos = basis.indexOf(wort) if (pos >= 0) treffer.push({ pos, wort, href }) else anhang.push({ wort, href }) } treffer.sort((a, b) => a.pos - b.pos) const stuecke = [] const textStueck = (t) => { if (t === '') return const letztes = stuecke.at(-1) if (letztes && letztes.href === undefined) letztes.text += t else stuecke.push({ text: t }) } let ab = 0 for (const t of treffer) { textStueck(basis.slice(ab, t.pos)) stuecke.push({ text: t.wort, href: t.href }) ab = t.pos + t.wort.length } textStueck(basis.slice(ab)) anhang.forEach((a, i) => { textStueck(i === 0 ? ' ' : ' · ') stuecke.push({ text: a.wort, href: a.href }) }) return stuecke } /** * Was folgt aus einem Fehler des Absendens? fehler = ApiFehler ({ status, code?, message, body }). * wechsel/bestellen/etage führen weg, beginn und spaeter sperren den Knopf bis zum Datum (beginn: Häkchen möglich), neu-laden lädt die * Liste neu, schon heißt: ist schon eingeschaltet, meldung zeigt den Text. Alles ohne eigene Regel zeigt den Text des Servers. */ export function fehlerAktion(fehler, { eintrag }) { const body = fehler?.body && typeof fehler.body === 'object' ? fehler.body : {} const code = fehler?.code || body.code || '' const servertext = typeof fehler?.message === 'string' && fehler.message !== '' ? fehler.message : TEXTE.allgemein const meldung = (text) => ({ art: 'meldung', text }) switch (code) { case 'nicht_verfuegbar': return meldung(TEXTE.nichtVerfuegbar) case 'abo_fehlt': { if (body.wechsel === true) return { art: 'wechsel' } if (body.grund === 'kein_abo') return { art: 'bestellen', produkt: eintrag.enthaltenIn } if (body.grund === 'gekuendigt') return meldung(TEXTE.gekuendigt) if (body.grund === 'nicht_aktiv') return meldung(TEXTE.nichtAktiv) if (body.grund === 'falsches_abo') return meldung(TEXTE.falschesAbo) if (body.grund === 'beginnt_spaeter') { const tag = berlinTag(body.ab) return tag ? { art: 'spaeter', ab: body.ab, text: TEXTE.beginntSpaeter(datumLang(tag)) } : meldung(servertext) } return meldung(servertext) } case 'nur_betrieb': return meldung(TEXTE.nurBetrieb(eintrag.name)) case 'bestellung_offen': return { art: 'etage', etage: ETAGE_FUER[eintrag.key] } case 'beginn_noetig': return { art: 'beginn', ab: body.ab } case 'bereits_eingeschaltet': return { art: 'schon' } case 'fassung_veraltet': return { art: 'neu-laden', text: TEXTE.fassungVeraltet } default: return meldung(servertext) } } /* ------------------------------------------------------------------------------------------- Bestätigungsseite --- */ /** * Die Bestätigungsseite zum Einschalten zeichnen (Aufbau und Zustand von Häkchen und Knopf; Senden steht weiter unten). * @param {HTMLElement} behaelter Platz, in dem die Seite gezeichnet wird (sein Inhalt wird ersetzt) * @param {object} o * key, projectId; daten = Antwort von GET /api/funktionen (Abo-Modell); kundenartWirksam (leer oder unbekannt zählt als privat); * viewAs (Kundenansicht der Mitarbeiter: nichts einschalten); imModul (ein Funktionsmodul bettet die Seite ein: kein „Abbrechen“); * jetzt, api, geheZu, speicher (einspeisbar für Tests); bei = { fertig, weiter, abbrechen, neuLaden } (alle optional) * @returns {HTMLElement} section.kn-fn-einschalten */ export function renderEinschalten(behaelter, o) { const { key, projectId, viewAs = false, imModul = false, jetzt = () => new Date() } = o let daten = o.daten const api = o.api ?? apiStandard const geheZu = o.geheZu ?? geheZuStandard const kundenart = () => (o.kundenartWirksam === undefined ? daten?.kundenart : o.kundenartWirksam) const z = { haken: false, serverAb: '', sperreAb: '', erzwingeHaken: false, sendet: false, meldung: '' } // Standards der Rückmeldungen (Task 4): Erfolg merkt die Meldung und geht zu Meine, Abbrechen zu Entdecken const bei = { fertig: (antwort) => { merkeMeldung(erfolgText(String(eintragVon()?.name ?? key), antwort)); geheZu('#funktionen', { ersetzen: true }) }, weiter: (weg) => navigiere(weg, { key, projectId }, { geheZu, speicher: o.speicher }), abbrechen: () => geheZu('#entdecken', { ersetzen: true }), neuLaden: () => api('/api/funktionen'), ...o.bei, } const abschnitt = h('section', { class: 'kn-fn-einschalten', 'data-key': key, 'data-projekt': projectId }) let knopf = null let meldungEl = null const eintragVon = () => (Array.isArray(daten?.katalog) ? daten.katalog.find((e) => e?.key === key) ?? null : null) const lageJetzt = () => { const lage = beginnLage({ kundenart: kundenart(), abo: daten?.abo, jetzt: jetzt(), serverAb: z.serverAb }) return z.erzwingeHaken && !lage.noetig ? { noetig: true, ab: '' } : lage // beginn_noetig ohne lesbares Datum: Häkchen trotzdem zeigen } function aktualisiereKnopf() { if (!knopf) return const lage = lageJetzt() const fehltHaken = lage.noetig && !z.haken const ab = z.sperreAb || (fehltHaken ? lage.ab : '') knopf.disabled = viewAs || z.sendet || fehltHaken || Boolean(z.sperreAb) knopf.textContent = ab && datumKurz(ab) ? `Einschalten ab ${datumKurz(ab)}` : 'Einschalten' } function setzeMeldung(text) { z.meldung = text if (meldungEl) meldungEl.textContent = text } function fuelle() { const eintrag = eintragVon() const consent = consentVon(daten, key) const items = consent?.items ?? [] const item = (id) => items.find((i) => i?.id === id) ?? null const zeile = item('einschalten-zeile') const hinweis = item('hinweis') const beginn = item('beginn') const lage = lageJetzt() const register = Boolean(eintrag && zeile && (!lage.noetig || beginn)) const seiten = Array.isArray(daten?.projects) ? daten.projects : [] const websiteName = seiten.find((p) => p?.projectId === projectId)?.projectName const abo = daten?.abo?.produkt ?? eintrag?.enthaltenIn const fakten = [ seiten.length > 1 ? ['Website', String(websiteName ?? '')] : null, ['Leistung', 'Einrichtung durch WEBklar'], ['Dauer', String(eintrag?.einrichtung ?? '')], ['Laufzeit', `läuft, solange dein ${abo === 'hosting' ? 'Hosting-Abo' : 'Wartungs-Abo'} läuft`], ].filter(Boolean) const box = h('input', { type: 'checkbox', 'data-haken': 'beginn', on: { change: () => { z.haken = box.checked; aktualisiereKnopf() } } }) box.checked = z.haken meldungEl = h('p', { class: 'kn-fn-meldung', role: 'alert', 'data-meldung': true }, z.meldung) knopf = register ? h('button', { type: 'button', class: 'kn-fn-knopf', 'data-aktion': 'einschalten', on: { click: () => senden() } }, 'Einschalten') : null const kinder = [ h('h2', null, `${String(eintrag?.name ?? key)} einschalten`), h('dl', { class: 'kn-fn-fakten' }, fakten.map(([name, wert]) => h('div', null, h('dt', null, name), h('dd', null, wert)))), h('p', { class: 'kn-fn-konto' }, TEXTE.stundenkonto), register ? [ h('p', { class: 'kn-fn-recht' }, linkzeile(zeile.text, eintrag.unterlagen).map((s) => (s.href ? h('a', { href: s.href, target: '_blank', rel: 'noopener noreferrer' }, s.text) : s.text))), hinweis ? h('p', { class: 'kn-fn-hinweis' }, String(hinweis.text ?? '')) : null, lage.noetig ? h('label', { class: 'kn-fn-haken' }, box, h('span', null, String(beginn.text ?? ''))) : null, ] : h('p', { class: 'kn-fn-info' }, TEXTE.registerFehlt), viewAs ? h('p', { class: 'kn-fn-info' }, TEXTE.kundenansicht) : null, meldungEl, h('div', { class: 'kn-fn-aktionen' }, knopf ?? h('button', { type: 'button', class: 'kn-fn-knopf', 'data-aktion': 'chat', on: { click: () => geheZu('#chat') } }, 'Im Chat schreiben'), imModul ? null : h('button', { type: 'button', class: 'kn-fn-knopf kn-fn-knopf--zweit', 'data-aktion': 'abbrechen', on: { click: () => bei.abbrechen() } }, 'Abbrechen')), ] abschnitt.replaceChildren(...kinder.flat(Infinity).filter(Boolean)) aktualisiereKnopf() } async function senden() { if (z.sendet || viewAs || z.sperreAb) return const eintrag = eintragVon() const consent = consentVon(daten, key) const lage = lageJetzt() if (!eintrag || !consent || (lage.noetig && !z.haken)) return z.sendet = true setzeMeldung('') aktualisiereKnopf() let antwort try { antwort = await api(`/api/funktionen/${encodeURIComponent(key)}/einschalten`, { method: 'POST', body: einschaltKoerper({ projectId, consent, beginnGesetzt: lage.noetig && z.haken }), }) } catch (fehler) { z.sendet = false await behandleFehler(fehler, eintrag) return } bei.fertig(antwort) // der Knopf bleibt gesperrt: nach Erfolg gibt es nichts mehr zu senden } async function behandleFehler(fehler, eintrag) { const aktion = fehlerAktion(fehler, { eintrag }) switch (aktion.art) { case 'wechsel': case 'bestellen': aktualisiereKnopf() bei.weiter(aktion) break case 'etage': // im Modul steht der Servertext (die Etage ist schon offen), sonst führt die Seite hin if (imModul) { setzeMeldung(fehler?.message || TEXTE.allgemein); aktualisiereKnopf() } else { aktualisiereKnopf(); bei.weiter(aktion) } break case 'schon': bei.fertig(null) break case 'beginn': // Häkchen zeigen, Knopf bis dahin gesperrt mit Datum z.serverAb = typeof aktion.ab === 'string' ? aktion.ab : '' z.erzwingeHaken = true z.haken = false fuelle() abschnitt.querySelector('input[data-haken="beginn"]')?.focus?.() break case 'spaeter': // das Abo beginnt später: Text und gesperrter Knopf mit Datum, kein Häkchen z.sperreAb = berlinTag(aktion.ab) setzeMeldung(aktion.text) aktualisiereKnopf() break case 'neu-laden': try { daten = await bei.neuLaden() z.haken = false z.serverAb = '' z.erzwingeHaken = false setzeMeldung(aktion.text) fuelle() } catch { setzeMeldung(TEXTE.allgemein) aktualisiereKnopf() } break default: setzeMeldung(aktion.text) aktualisiereKnopf() } } leeren(behaelter).append(abschnitt) fuelle() return abschnitt } /* ------------------------------------------------------------------------------------ Navigation und Meldungen --- */ /** * Wohin nach einem Weg von einschaltWeg oder fehlerAktion? ziel = { key, projectId }; 'seite' behandelt die Ansicht selbst. * etage → #?projekt=, bestellen → #bestellen-, wechsel → die gewünschte Funktion merken (V11), dann #wechsel. * Der Wechsel geht auch, wenn sessionStorage fehlt oder voll ist. */ export async function navigiere(weg, ziel, { geheZu = geheZuStandard, speicher } = {}) { switch (weg?.art) { case 'etage': geheZu(`#${weg.etage}?projekt=${encodeURIComponent(ziel.projectId)}`) break case 'bestellen': geheZu(`#bestellen-${weg.produkt}`) break case 'wechsel': try { const ablage = speicher ?? globalThis.sessionStorage ablage.setItem('kn-wechsel-funktion', JSON.stringify({ key: ziel.key, projectId: ziel.projectId })) } catch { /* ohne Speicher geht der Wechsel trotzdem; nur die Rückkehr zur Funktion entfällt */ } geheZu('#wechsel') break default: } } /** Text nach dem Einschalten; antwort null heißt: war schon eingeschaltet. */ export function erfolgText(name, antwort) { if (!antwort) return `${name} ist schon eingeschaltet.` const woid = antwort.ticketWoid ?? antwort.feature?.ticketWoid // der Server nennt das Ticket an beiden Stellen const ticket = woid ? ` Ticket #${woid}.` : '' return `${name} ist eingeschaltet. Die Einrichtung läuft jetzt.${ticket}` } let gemerkteMeldung = '' /** Merkt genau eine Meldung für den nächsten Aufbau von Meine oder Entdecken. */ export function merkeMeldung(text) { gemerkteMeldung = String(text ?? '') } /** Gibt die gemerkte Meldung zurück und vergisst sie. */ export function nimmMeldung() { const text = gemerkteMeldung gemerkteMeldung = '' return text } /** * Vertrag mit den Funktionsmodulen (P4, VF1): die Bestätigungsseite in `platz`, ohne „Abbrechen“. Lädt GET /api/funktionen, wenn * `daten` fehlt. onFertig({ feature, ticketWoid }) nach 200, onFertig({ feature: null, ticketWoid: null }) nach 409 bereits_eingeschaltet; * alle anderen Fehler zeigt die Seite selbst. Liefert die gezeichnete Seite (oder die Fehlerzeile beim Laden). */ export async function zeigeEinschalten(platz, { key, projectId, onFertig, daten, kundenartWirksam, viewAs = false, api = apiStandard, geheZu, speicher, jetzt } = {}) { let liste = daten if (!liste) { leeren(platz).append(h('p', { class: 'kn-leer' }, 'Wird geladen …')) try { liste = await api('/api/funktionen') } catch { return leeren(platz).appendChild(h('p', { class: 'kn-fn-info', role: 'alert' }, TEXTE.laden, h('button', { type: 'button', class: 'kn-fn-knopf kn-fn-knopf--zweit', 'data-aktion': 'nochmal', on: { click: () => zeigeEinschalten(platz, { key, projectId, onFertig, kundenartWirksam, viewAs, api, geheZu, speicher, jetzt }) } }, 'Nochmal versuchen'))) } } return renderEinschalten(platz, { key, projectId, daten: liste, kundenartWirksam, viewAs, imModul: true, api, geheZu, speicher, jetzt, bei: { fertig: (antwort) => onFertig?.(antwort ?? { feature: null, ticketWoid: null }) }, }) }