296 lines
15 KiB
JavaScript
296 lines
15 KiB
JavaScript
/* 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/<key>/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 `#<etage>?projekt=<id>`). */
|
|
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.',
|
|
})
|
|
|
|
/** 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/ und /vertragsunterlagen/; 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/')) 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(), bei = {} } = o
|
|
let daten = o.daten
|
|
const kundenart = () => (o.kundenartWirksam === undefined ? daten?.kundenart : o.kundenartWirksam)
|
|
const z = { haken: false, serverAb: '', sperreAb: '', sendet: false, meldung: '' }
|
|
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 = () => beginnLage({ kundenart: kundenart(), abo: daten?.abo, jetzt: jetzt(), serverAb: z.serverAb })
|
|
|
|
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: () => (o.geheZu ?? geheZuStandard)('#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() {
|
|
// folgt in Task 4
|
|
}
|
|
|
|
leeren(behaelter).append(abschnitt)
|
|
fuelle()
|
|
return abschnitt
|
|
}
|