199 lines
9.9 KiB
JavaScript
199 lines
9.9 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. */
|
|
|
|
/** 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)
|
|
}
|
|
}
|