Files
Webklar-Kundenbereich/server/services/termine/einbauanleitung.js
T

881 lines
48 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { config } from '../../config.js'
import { TICKET_DETAILS_MAX, TICKET_GEKUERZT, anleitungsLink, kuerze } from '../featureRegistry.js'
import { gesundheitsWarnungen } from './fragebogen.js'
import {
VORGABE_FARBE, fehlendePflichtangaben, hatPreis, preisHinweis, preisText, sichtbareLeistungen, text as bereinigterText, zahlungText, zeigtWiderruf,
} from './einstellungen.js'
import { RECHTSTEXTE_ENTWURF, datenschutzAbsatz } from './rechtstexte.js'
import { datumKurz, heute } from './zeit.js'
/**
* Einbau des Terminplaners auf der Website des Kunden (Spezifikation 2026-10-03-terminplaner-tp2-verkauf-design.md, Abschnitt
* Einbauanleitung; Anleitung fuer das Team: docs/funktionen/termine-einbau.md).
*
* Zwei Teile in einer Datei:
* - Bausteine (esc, einbauSnippet, Farbfunktionen, cssVorschlag): reine Funktionen ohne Appwrite und ohne config, die Adresse
* des Portals (`apiBase`) und alle Werte kommen als Parameter. Der Einbau-Code stand bis TP2 in routes/termine.js;
* GET /api/termine/einstellungen ruft ihn von hier auf (Modus inline, die Kundenansicht bleibt unveraendert).
* - Generator einbauanleitung() (ganz unten): setzt aus den Einstellungen des Betriebs die Anleitung fuer das Team zusammen.
* Auch er ist rein (kein I/O, keine Zufallswerte); config wird nur beim Aufruf gelesen (Portal-Adresse, Haltezeit, WOMS-Adresse,
* Preis). Er nutzt die reinen Hilfen gesundheitsWarnungen (fragebogen.js) und datenschutzAbsatz (rechtstexte.js).
*
* Die Farbfunktionen (luminanz, textFarbeAuf) sind 1:1 die aus public/embed/termine.js (Marken @farben-anfang/@farben-ende),
* bewusst kopiert und nicht importiert: das Widget ist ein eigenstaendiges Browser-Skript. test/termineEmbedStatic.test.js
* haelt beide fuer 4096 Farben gleich; wer eine Seite aendert, aendert die andere mit.
*/
const FARBE_RE = /^#[0-9a-fA-F]{6}$/
const KNOPF_MAX = 40
const MIN_RAHMEN = 3 // WCAG 2.1 AA fuer Bedienelemente und Rahmen (1.4.11): der Rahmen freier Tage steht in der Akzentfarbe
// ---------------------------------------------------------------- Text
/** Maskiert &, <, >, " fuer Text und Attributwerte in doppelten Anfuehrungszeichen. null und undefined werden zu ''. */
export function esc(text) {
return String(text ?? '').replace(/[&<>"]/g, (z) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[z])
}
/** Knopf-Text fuer data-knopf: Zeilenumbrueche und Steuerzeichen zu Leerzeichen, hoechstens 40 Zeichen (nie mitten in einem Zeichen). */
function knopfTextBereinigt(roh) {
if (typeof roh !== 'string') return ''
const eine = roh.replace(/\r\n/g, ' ').replace(/[\p{Cc}\p{Zl}\p{Zp}]/gu, ' ').trim()
return Array.from(eine).slice(0, KNOPF_MAX).join('')
}
// ---------------------------------------------------------------- Einbau-Code
/**
* Einbau-Code fuer die Website: Platzhalter mit Ausweichtext (bleibt ohne JavaScript stehen) und das Skript.
* modus: 'inline' (Standard, die Strecke steht in der Seite) | 'knopf' (ein Knopf oeffnet einen Dialog); jeder andere Wert gilt
* als 'inline'. knopfText gilt nur im Modus knopf und nur, wenn er nicht leer ist; sonst nimmt das Widget "Termin buchen".
*/
export function einbauSnippet({ siteKey, apiBase, telefon, modus = 'inline', knopfText } = {}) {
const knopf = modus === 'knopf' ? knopfTextBereinigt(knopfText) : ''
const ausweich = telefon ? `Termine telefonisch unter ${esc(telefon)}.` : 'Termine telefonisch vereinbaren.'
const attribute = [
`data-webklar-termine="${esc(siteKey)}"`,
`data-api="${esc(apiBase)}"`,
`data-modus="${modus === 'knopf' ? 'knopf' : 'inline'}"`,
...(knopf ? [`data-knopf="${esc(knopf)}"`] : []),
]
return [
`<div ${attribute.join(' ')}>`,
` <p>${ausweich}</p>`,
'</div>',
`<script src="${esc(apiBase)}/embed/termine.js" defer></script>`,
].join('\n')
}
// ---------------------------------------------------------------- Farben
/** Relative Leuchtdichte nach WCAG 2.1 fuer '#rrggbb' (wie im Widget; erwartet eine gueltige Farbe). */
export function luminanz(farbe) {
const rgb = [1, 3, 5].map((i) => {
const c = parseInt(farbe.slice(i, i + 2), 16) / 255
return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4)
})
return 0.2126 * rgb[0] + 0.7152 * rgb[1] + 0.0722 * rgb[2]
}
/** Kontrastverhaeltnis zweier Leuchtdichten (1 bis 21), wie kontrast() im Widget. */
const verhaeltnis = (a, b) => (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05)
/** Kontrastverhaeltnis zweier Farben '#rrggbb' (1 bis 21, Reihenfolge egal). */
export function kontrast(hexA, hexB) {
return verhaeltnis(luminanz(hexA), luminanz(hexB))
}
/**
* Schrift auf der Betriebsfarbe: Weiss oder das dunkle Grau, je nachdem was mehr Kontrast gibt; erreicht keines 4,5 : 1
* (mittlere Toene), nimmt sie Schwarz (mindestens 4,58 : 1 fuer jede Farbe). 1:1 aus dem Widget.
*/
export function textFarbeAuf(farbe) {
const l = luminanz(farbe)
const weiss = verhaeltnis(l, 1)
const dunkel = verhaeltnis(l, luminanz('#111827'))
const beste = Math.max(weiss, dunkel)
if (beste < 4.5 && verhaeltnis(l, 0) > beste) return '#000000'
return weiss >= dunkel ? '#ffffff' : '#111827'
}
/** Kontrast mit einer Nachkommastelle und deutschem Komma, abgerundet ("unter 3 : 1" bleibt auch im Text unter 3). */
const kontrastAnzeige = (wert) => (Math.floor(wert * 10) / 10).toFixed(1).replace('.', ',')
/**
* CSS-Vorschlag fuer die Website aus der Betriebsfarbe (Anleitung Abschnitt 3): Kommentar plus
* `[data-webklar-termine]{ --wkt-akzent; --wkt-akzent-text }`. Nur eine gueltige '#rrggbb' (gross oder klein) wird uebernommen;
* alles andere ersetzt VORGABE_FARBE, der Rohtext kommt nie in die Ausgabe.
* Rueckgabe: css, akzent (die verwendete Farbe, klein), akzentText (Schrift darauf), kontrastText (Akzent gegen Schrift),
* kontrastSeite (Akzent gegen Weiss), hinweise (Warnungen, leer wenn alles passt). Der Rahmen der freien Tage steht in der
* Akzentfarbe und braucht gegen den Hintergrund mindestens 3 : 1; das Widget hat standardmaessig einen weissen Hintergrund.
*/
export function cssVorschlag(farbe) {
const akzent = typeof farbe === 'string' && FARBE_RE.test(farbe) ? farbe.toLowerCase() : VORGABE_FARBE
const akzentText = textFarbeAuf(akzent)
const textKontrast = kontrast(akzent, akzentText)
const seitenKontrast = kontrast(akzent, '#ffffff')
const hinweise = []
if (seitenKontrast < MIN_RAHMEN) {
hinweise.push(
`Kontrast gegen Weiß: nur ${kontrastAnzeige(seitenKontrast)} : 1. Der Rahmen der freien Tage steht in der Akzentfarbe ${akzent} und braucht ` +
`mindestens ${MIN_RAHMEN} : 1 gegen den Hintergrund. Auf weißem Grund wähle eine dunklere Farbe, auf dunklem Grund setze --wkt-hintergrund passend.`
)
}
const css = [
`/* Terminplaner: Farbe der Knöpfe und Schrift darauf, aus der Betriebsfarbe. Kontrast der Schrift: ${kontrastAnzeige(textKontrast)} : 1. */`,
'[data-webklar-termine]{',
` --wkt-akzent:${akzent};`,
` --wkt-akzent-text:${akzentText};`,
'}',
].join('\n')
return { css, akzent, akzentText, kontrastText: textKontrast, kontrastSeite: seitenKontrast, hinweise }
}
// ================================================================ Generator einbauanleitung()
// Task D3a: Modell, Leistungen, Ressourcen, Regeln, Gastfelder, Benachrichtigungen. Task D3b: Einbau, Snippet, CSS, Datenschutz-Absaetze,
// Checkliste, Abrechnung, die Warnungen und der Volltext. Task D4: die Kurzfassung fuer das Einrichtungs-Ticket (`kurz`), ticketStand und driftWarnung.
//
// Feldweissliste: gelesen werden nur ausdruecklich benannte Felder, nie ganze Objekte. Nie ausgegeben werden feedToken (Kalender-
// Abo der Ressource), die Betriebs-E-Mail (sie steht nur in den Datenschutz-Absaetzen), Beschreibungen der Leistungen und alles,
// was sonst an den Objekten haengt. Kundentexte (Namen, Titel, Storno-Text, Wuensche zum Einbau) stehen unmaskiert als Text; wer sie
// anzeigt, setzt sie als Text ein. Einzeilige Werte werden auf eine Zeile gebracht, mehrzeilige eingerueckt, damit ein Kundentext
// keine Ueberschrift vortaeuschen kann. Wuensche des Kunden stehen nie im Snippet und nie im CSS (nur der Knopf-Text, maskiert,
// als data-knopf).
/** Kennzeichnung der Gaestetexte als Entwurf; erste Warnung, solange `entwurf` gilt (Standard RECHTSTEXTE_ENTWURF). */
export const ENTWURF_BANNER = 'ENTWURF – nur Pilot, vor Einsatz bei fremden Betrieben prüfen'
const WOCHENTAGE = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So']
const TAG_RE = /^\d{4}-\d{2}-\d{2}$/
const MAX_ABWESENHEITEN = 20
const MAX_SONDEROEFFNUNGEN = 20
const NICHT_ANGEGEBEN = 'nicht angegeben'
const GAESTE = { verbraucher: 'Privatpersonen (Verbraucher)', unternehmer: 'Unternehmen', gemischt: 'Privatpersonen und Unternehmen' }
const BUCHUNGSART = { sofort: 'sofort buchbar', anfrage: 'nur als Anfrage' }
const GAST_FELD = { aus: 'aus', optional: 'optional', pflicht: 'Pflicht' }
const istObjekt = (wert) => wert !== null && typeof wert === 'object' && !Array.isArray(wert)
const liste = (wert) => (Array.isArray(wert) ? wert : [])
const anzahl = (n, einzahl, mehrzahl) => `${n} ${n === 1 ? einzahl : mehrzahl}`
const imWort = (map, schluessel, vorgabe) => (typeof schluessel === 'string' && Object.hasOwn(map, schluessel) ? map[schluessel] : vorgabe)
/** Einzeiliger Text: Leerraum, Umbrueche und Steuerzeichen werden zu einem Leerzeichen, Rand getrimmt; Nicht-Text = ''. */
const zeile = (wert) => (typeof wert === 'string' ? wert.replace(/[\p{Cc}\p{Zl}\p{Zp}\s]+/gu, ' ').trim() : '')
/** '2026-10-05' => '05.10.2026'. */
const tagText = (tag) => tag.split('-').reverse().join('.')
/** [['09:00','12:30'],['14:00','18:00']] => '09:00–12:30, 14:00–18:00'; Unbrauchbares faellt weg. */
function fensterText(fenster) {
return liste(fenster)
.filter((f) => Array.isArray(f) && f.length === 2 && typeof f[0] === 'string' && typeof f[1] === 'string')
.map(([von, bis]) => `${zeile(von)}–${zeile(bis)}`)
.join(', ')
}
/** Wochenplan { 1: [['09:00','12:30'], …], … } (ISO-Wochentage) als Zeilen 'Mo 09:00–12:30, 14:00–18:00', Montag bis Sonntag, ohne leere Tage. */
export function wochenplanText(plan) {
const aus = []
for (let tag = 1; tag <= 7; tag += 1) {
const text = fensterText(istObjekt(plan) ? plan[tag] : null)
if (text) aus.push(`${WOCHENTAGE[tag - 1]} ${text}`)
}
return aus
}
/** Die ersten `max` Eintraege, bei mehr als `max` gefolgt von '… und N weitere'. Aendert die Liste nicht. */
export function kuerzeListe(eintraege, max) {
if (eintraege.length <= max) return [...eintraege]
return [...eintraege.slice(0, max), `… und ${eintraege.length - max} weitere`]
}
const neuerAbschnitt = (id, titel, text, kopierbar = false) => ({ id, titel, text, kopierbar })
/** Zeilen als Aufzaehlung: '- Eintrag'. */
const aufzaehlung = (eintraege) => eintraege.map((eintrag) => `- ${eintrag}`)
// ---------------------------------------------------------------- Abschnitt Modell
function standText(d) {
if (!Number.isInteger(d.version) || d.version < 1) return 'keine gespeicherte Version'
return `Version ${d.version}${d.geaendert ? `, zuletzt geändert ${d.geaendert}` : ''}`
}
function abschnittModell(d) {
const { e, betrieb: b } = d
const aktive = (eintraege) => eintraege.filter((x) => x.aktiv).length
const angabe = (wert) => zeile(wert) || NICHT_ANGEGEBEN
const gaeste = imWort(GAESTE, e.recht?.gaesteSind, GAESTE.verbraucher) + (e.recht?.freizeit === true ? ', Freizeitleistung' : '')
return neuerAbschnitt('modell', 'Modell', [
`Erzeugt am ${datumKurz(d.now)}`,
`Stand der Einstellungen: ${standText(d)}`,
...(d.kunde ? [`Kunde: ${d.kunde}`] : []),
...(d.projekt ? [`Projekt: ${d.projekt}`] : []),
`Betrieb: ${angabe(b.name)}`,
`Anschrift: ${angabe(b.adresse)}`,
`Telefon: ${angabe(b.telefon)}`,
`Impressum: ${angabe(b.impressumUrl)}`,
`Datenschutzerklärung: ${angabe(b.datenschutzUrl)}`,
...(zeile(b.agbUrl) ? [`AGB: ${zeile(b.agbUrl)}`] : []),
...(zeile(b.websiteUrl) ? [`Website: ${zeile(b.websiteUrl)}`] : []),
`Farbe: ${d.farbe}`,
`Anrede der Gäste: ${e.anrede === 'du' ? 'Du' : 'Sie'}`,
`Gäste: ${gaeste}`,
`Umfang: ${anzahl(d.ressourcen.length, 'Person oder Platz', 'Personen und Plätze')} (${aktive(d.ressourcen)} aktiv), `
+ `${anzahl(d.leistungen.length, 'Leistung', 'Leistungen')} (${aktive(d.leistungen)} aktiv)`,
`Portal-Adresse (data-api): ${d.portal}`,
].join('\n'))
}
// ---------------------------------------------------------------- Abschnitt Leistungen
/** 'Personen: Anna, Ben' | 'Plätze: …' | 'Personen und Plätze: …'; unbekannte Zuordnungen fallen weg. */
function zuordnungText(ids, nachId) {
const treffer = liste(ids).map((id) => nachId.get(id)).filter(Boolean)
if (treffer.length === 0) return 'keine Person und kein Platz zugeordnet'
const plaetze = treffer.filter((r) => r.typ === 'platz').length
const label = plaetze === 0 ? 'Personen' : plaetze === treffer.length ? 'Plätze' : 'Personen und Plätze'
return `${label}: ${treffer.map((r) => r.name).join(', ')}`
}
function leistungsZeile(l, nachId) {
const dauer = Number.isFinite(l.dauer) ? `${l.dauer} Min.` : 'Dauer fehlt'
const pause = Number.isFinite(l.pufferNach) && l.pufferNach > 0 ? ` (+ ${l.pufferNach} Min. Pause danach)` : ''
return `${zeile(l.name) || 'ohne Namen'}${l.aktiv ? '' : ' (inaktiv)'}: ${dauer}${pause}, ${preisText(l) || 'kein Preis'}, `
+ `${imWort(BUCHUNGSART, l.buchungsart, zeile(l.buchungsart) || BUCHUNGSART.sofort)}, ${zuordnungText(l.ressourcen, nachId)}`
}
function abschnittLeistungen(d) {
if (d.leistungen.length === 0) return neuerAbschnitt('leistungen', 'Leistungen', 'Keine Leistungen angelegt.')
const aktiv = d.leistungen.filter((l) => l.aktiv).length
return neuerAbschnitt('leistungen', 'Leistungen', [
`${anzahl(d.leistungen.length, 'Leistung', 'Leistungen')} (${aktiv} aktiv):`,
...aufzaehlung(d.leistungen.map((l) => leistungsZeile(l, d.nachId))),
].join('\n'))
}
// ---------------------------------------------------------------- Abschnitt Ressourcen
function ressourcenBlock(r) {
const plan = wochenplanText(r.plan)
return [
`- ${zeile(r.name) || 'ohne Namen'} (${r.typ === 'platz' ? 'Platz' : 'Person'}${r.aktiv ? '' : ', inaktiv'})`,
...(plan.length ? plan : ['keine Zeiten eingetragen']).map((z) => ` ${z}`),
].join('\n')
}
/** Abwesenheiten, die heute oder spaeter enden (laufende bleiben), nach Beginn sortiert. */
function abwesenheitsZeilen(d) {
const eintraege = liste(d.e.abwesenheiten)
.filter((a) => istObjekt(a) && TAG_RE.test(a.von) && TAG_RE.test(a.bis) && a.bis >= d.heuteTag)
.sort((a, b) => (a.von < b.von ? -1 : a.von > b.von ? 1 : a.bis < b.bis ? -1 : a.bis > b.bis ? 1 : 0))
.map((a) => {
const zeitraum = a.von === a.bis ? tagText(a.von) : `${tagText(a.von)}–${tagText(a.bis)}`
const wer = a.ressourceId ? d.nachId.get(a.ressourceId)?.name ?? 'unbekannt' : 'alle'
return `${zeitraum}: ${zeile(a.titel) || 'Abwesenheit'} (${wer})`
})
return aufzaehlung(kuerzeListe(eintraege, MAX_ABWESENHEITEN))
}
/** Sonderoeffnungszeiten ab heute ([] = an diesem Tag geschlossen), nach Tag sortiert. */
function sonderoeffnungsZeilen(d) {
const roh = istObjekt(d.e.sonderoeffnungen) ? d.e.sonderoeffnungen : {}
const eintraege = Object.keys(roh)
.filter((tag) => TAG_RE.test(tag) && tag >= d.heuteTag)
.sort()
.map((tag) => `${tagText(tag)}: ${fensterText(roh[tag]) || 'geschlossen'}`)
return aufzaehlung(kuerzeListe(eintraege, MAX_SONDEROEFFNUNGEN))
}
function abschnittRessourcen(d) {
const abwesenheiten = abwesenheitsZeilen(d)
const sonder = sonderoeffnungsZeilen(d)
return neuerAbschnitt('ressourcen', 'Personen und Plätze', [
d.ressourcen.length ? d.ressourcen.map(ressourcenBlock).join('\n') : 'Keine Personen oder Plätze angelegt.',
...(abwesenheiten.length ? [['Abwesenheiten (ab heute):', ...abwesenheiten].join('\n')] : []),
...(sonder.length ? [['Sonderöffnungszeiten (ab heute, für alle):', ...sonder].join('\n')] : []),
].join('\n\n'))
}
// ---------------------------------------------------------------- Abschnitt Regeln
/** Vorlauf in der groessten glatten Einheit: '45 Minuten', '2 Stunden', '1 Tag'; 0 = kein Vorlauf. */
function vorlaufText(minuten) {
if (!Number.isFinite(minuten)) return NICHT_ANGEGEBEN
if (minuten === 0) return 'kein Vorlauf'
if (minuten % 1440 === 0) return anzahl(minuten / 1440, 'Tag', 'Tage')
if (minuten % 60 === 0) return anzahl(minuten / 60, 'Stunde', 'Stunden')
return anzahl(minuten, 'Minute', 'Minuten')
}
function stornoZeilen(storno) {
const text = typeof storno?.text === 'string' ? storno.text.trim() : ''
if (!text) return ['Stornobedingungen: keine hinterlegt']
return [`Stornobedingungen (Fassung ${zeile(storno.version) || 'ohne Angabe'}):`, ...text.split(/\r\n|\r|\n/).map((z) => ` ${z}`)]
}
function abschnittRegeln(d) {
const { e } = d
const r = istObjekt(e.regeln) ? e.regeln : {}
const mit = (wert, f) => (Number.isFinite(wert) ? f(wert) : NICHT_ANGEGEBEN)
return neuerAbschnitt('regeln', 'Regeln', [
`Raster: ${mit(r.raster, (n) => anzahl(n, 'Minute', 'Minuten'))}`,
`Vorlauf: ${vorlaufText(r.vorlaufMin)}`,
`Buchungszeitraum: ${mit(r.horizontTage, (n) => `${anzahl(n, 'Tag', 'Tage')} im Voraus`)}`,
`Absage durch Gäste: ${mit(r.absageBisStd, (n) => (n === 0 ? 'bis zum Terminbeginn' : `bis ${anzahl(n, 'Stunde', 'Stunden')} vor dem Termin`))}`,
`Offene Termine je E-Mail-Adresse: ${mit(r.maxOffeneJeEmail, (n) => `höchstens ${n}`)}`,
`Person wählbar: ${r.personWaehlbar === false ? 'nein' : 'ja'}`,
`Feiertage: ${e.feiertageGeschlossen === false ? 'geöffnet wie im Wochenplan' : 'geschlossen'} (Bundesland ${zeile(e.bundesland) || NICHT_ANGEGEBEN})`,
zahlungText(e),
`Preishinweis: ${preisHinweis(e)}`,
zeigtWiderruf(e)
? 'Widerruf: Widerrufsbelehrung und Widerrufsfunktion werden Gästen angezeigt'
: 'Widerruf: keine Widerrufsbelehrung (Gäste sind keine Verbraucher oder es ist keine Leistung sichtbar)',
...stornoZeilen(e.storno),
].join('\n'))
}
// ---------------------------------------------------------------- Abschnitt Gastfelder
function abschnittGastfelder(d) {
const g = istObjekt(d.e.gastFelder) ? d.e.gastFelder : {}
const zusatz = liste(g.zusatz).filter(istObjekt).map((z) => {
const art = z.typ === 'auswahl' ? `Auswahl: ${liste(z.optionen).map(zeile).filter(Boolean).join(', ')}` : 'Text'
return `${zeile(z.label) || 'ohne Beschriftung'} (${art}; ${z.pflicht === true ? 'Pflicht' : 'optional'})`
})
return neuerAbschnitt('gastfelder', 'Angaben der Gäste', [
'Name: Pflicht',
'E-Mail-Adresse: Pflicht',
`Telefon: ${imWort(GAST_FELD, g.telefon, NICHT_ANGEGEBEN)}`,
`Nachricht: ${imWort(GAST_FELD, g.nachricht, NICHT_ANGEGEBEN)}`,
...(zusatz.length ? [`Zusatzfelder (${zusatz.length}):`, ...aufzaehlung(zusatz)] : ['Zusatzfelder: keine']),
].join('\n'))
}
// ---------------------------------------------------------------- Abschnitt Benachrichtigungen
function abschnittBenachrichtigungen(d) {
const { e } = d
const sichtbar = sichtbareLeistungen(e)
const sofort = sichtbar.some((l) => l.buchungsart !== 'anfrage')
const anfrage = sichtbar.some((l) => l.buchungsart === 'anfrage')
const widerruf = zeigtWiderruf(e)
const erinnerung = e.erinnerung?.anbieten === true && Number.isFinite(e.erinnerung.stundenVorher)
const haltezeit = Number.isFinite(d.haltezeit) ? ` (gültig ${anzahl(d.haltezeit, 'Minute', 'Minuten')})` : ''
return neuerAbschnitt('benachrichtigungen', 'Benachrichtigungen', [
`Gäste werden mit „${e.anrede === 'du' ? 'Du' : 'Sie'}“ angesprochen. Absender ist der Betriebsname (${zeile(d.betrieb.name) || NICHT_ANGEGEBEN}), `
+ 'Antworten gehen an den Betrieb.',
['Mails an Gäste:', ...aufzaehlung([
`Bestätigungslink zur E-Mail-Adresse${haltezeit}`,
...(sofort ? ['Terminbestätigung (sofort buchbare Leistungen)'] : []),
...(anfrage ? ['Eingang und Ablehnung von Terminanfragen (Leistungen mit Anfrage)'] : []),
...(erinnerung ? [`Erinnerung ${anzahl(e.erinnerung.stundenVorher, 'Stunde', 'Stunden')} vor dem Termin (nur wenn der Gast sie ankreuzt)`] : []),
'Absage und Verschiebung',
...(widerruf ? ['Eingangsbestätigung des Widerrufs'] : []),
])].join('\n'),
['Mails an den Betrieb (Adresse aus den Einstellungen, steht nicht in dieser Anleitung):', ...aufzaehlung([
...(sofort ? ['Neue Buchung'] : []),
...(anfrage ? ['Neue Terminanfrage'] : []),
'Absage oder Verschiebung durch den Gast',
...(widerruf ? ['Widerruf'] : []),
])].join('\n'),
'Kalender-Abo (ICS): je Person oder Platz im Portal unter Termine. Der Link ist geheim und steht nicht in dieser Anleitung.',
].join('\n\n'))
}
// ---------------------------------------------------------------- Wuensche des Kunden (Fragebogen, Schritt Einbau)
const EINBAU_MODI = ['inline', 'knopf']
const MODUS_TEXT = {
inline: 'inline – die Buchungsstrecke steht direkt in der Seite',
knopf: 'knopf – ein Knopf öffnet die Buchungsstrecke in einem Dialog',
}
// Grenzen wie in fragebogen.js (TEXTGRENZEN): hier erneut angewandt, die Anleitung traut dem gespeicherten Fragebogen nicht blind
const WUNSCH_GRENZEN = { knopfText: 40, stelle: 300, gestaltung: 1000, bemerkung: 1000 }
const WUNSCH_MEHRZEILIG = new Set(['stelle', 'gestaltung', 'bemerkung'])
/** Modus (ohne Fragebogen oder ohne gueltige Wahl: inline) und die vier Wunschtexte, bereinigt, gekuerzt und wohlgeformt. */
function kundenwunsch(fragebogen) {
const einbau = istObjekt(fragebogen) && istObjekt(fragebogen.einbau) ? fragebogen.einbau : {}
const modusGewaehlt = EINBAU_MODI.includes(einbau.modus)
const wunsch = { modus: modusGewaehlt ? einbau.modus : 'inline', modusGewaehlt }
for (const [feld, max] of Object.entries(WUNSCH_GRENZEN)) {
wunsch[feld] = bereinigterText(einbau[feld], max, { mehrzeilig: WUNSCH_MEHRZEILIG.has(feld) }).toWellFormed()
}
return wunsch
}
/** ' Stelle: erste Zeile' und weitere Zeilen um vier Leerzeichen eingerueckt (nie am Zeilenanfang, also nie eine Ueberschrift). */
function wunschZeilen(bezeichnung, wert) {
if (!wert) return []
const [erste, ...weitere] = wert.split('\n')
return [` ${bezeichnung}: ${erste}`, ...weitere.map((z) => (z === '' ? '' : ` ${z}`))]
}
function kundenwunschBlock(wunsch) {
const zeilen = [
...wunschZeilen('Knopf-Text', wunsch.knopfText),
...wunschZeilen('Stelle', wunsch.stelle),
...wunschZeilen('Gestaltung', wunsch.gestaltung),
...wunschZeilen('Bemerkung', wunsch.bemerkung),
]
return zeilen.length ? ['Kundenwunsch (Text des Kunden):', ...zeilen].join('\n') : 'Kundenwunsch (Text des Kunden): keine Angaben im Fragebogen'
}
// ---------------------------------------------------------------- Abschnitte Einbau, Snippet, CSS
function abschnittEinbau(d) {
const w = d.wunsch
const quelle = w.modusGewaehlt ? 'Wahl des Kunden im Fragebogen' : 'Standard, der Fragebogen nennt keinen Modus'
const telefon = zeile(d.betrieb.telefon) || NICHT_ANGEGEBEN
const impressum = zeile(d.betrieb.impressumUrl) || NICHT_ANGEGEBEN
const datenschutz = zeile(d.betrieb.datenschutzUrl) || NICHT_ANGEGEBEN
return neuerAbschnitt('einbau', 'Einbau', [
`siteKey: ${d.siteKey || NICHT_ANGEGEBEN}`,
`Modus: ${MODUS_TEXT[w.modus]} (${quelle})`,
['So wird eingebaut:', ...aufzaehlung([
'Platzhalter und Skript aus dem Abschnitt „Einbau-Code (Snippet)“ in die Seite setzen: den Platzhalter an die Stelle, die der Kunde nennt '
+ '(siehe Kundenwunsch), das Skript einmal pro Seite, am besten vor dem schließenden body-Tag. Das Skript hat keine Abhängigkeiten, '
+ 'setzt keine Cookies und lädt nichts von Dritten.',
'Der Ausweichtext im Platzhalter gehört der Website: Er bleibt ohne JavaScript, bei falschem siteKey, abgeschalteter Funktion, fehlenden '
+ `Pflichtangaben und Netzfehlern stehen. Telefon des Betriebs: ${telefon}. Gestalte ihn im Stil der Seite.`,
'Aussehen: den Abschnitt „Aussehen (CSS)“ in die CSS der Website übernehmen. Weitere Variablen stehen in docs/funktionen/termine-einbau.md, Abschnitt 3.',
'Datenschutz: den Absatz „Online-Terminbuchung“ (Abschnitte „Datenschutz-Absatz für die Website“) in die Datenschutzerklärung der Website aufnehmen, '
+ 'bevor das Widget freigeschaltet wird.',
`Impressum und Datenschutzerklärung: Die URLs stehen in den Betriebsangaben im Portal (Impressum: ${impressum}; Datenschutzerklärung: ${datenschutz}), `
+ 'das Widget verlinkt sie im Fuß.',
])].join('\n'),
kundenwunschBlock(w),
].join('\n\n'))
}
/** Einbau-Code aus den Angaben des Betriebs und dem Modus des Fragebogens; Wuensche ausser dem Knopf-Text kommen nie hinein. */
const baueSnippet = (d) =>
einbauSnippet({ siteKey: d.siteKey, apiBase: d.portal, telefon: d.telefon, modus: d.wunsch.modus, knopfText: d.wunsch.knopfText })
const abschnittSnippet = (d) => neuerAbschnitt('snippet', 'Einbau-Code (Snippet)', baueSnippet(d), true)
const abschnittCss = (d) => neuerAbschnitt('css', 'Aussehen (CSS)', cssVorschlag(d.farbe).css, true)
// ---------------------------------------------------------------- Abschnitte Datenschutz-Absatz (Sie, Du)
const DATENSCHUTZ_HINWEIS =
'Der Datenschutz-Absatz konnte nicht erzeugt werden: In den Einstellungen fehlt der Name oder die E-Mail-Adresse des Betriebs. '
+ 'Beides im Portal eintragen lassen und die Anleitung neu laden.'
/** { sie, du } oder null, wenn Name oder E-Mail-Adresse des Betriebs fehlen (datenschutzAbsatz wirft dann einen TypeError). */
function datenschutzTexte(einstellung) {
try {
return { sie: datenschutzAbsatz(einstellung, 'sie'), du: datenschutzAbsatz(einstellung, 'du') }
} catch (err) {
if (err instanceof TypeError) return null
throw err
}
}
/** Nur ein echter Absatz ist kopierbar; der Hinweis, dass er fehlt, nie (er gehoert nicht in eine Datenschutzerklaerung). */
const abschnittDatenschutz = (anrede) => (d) => {
const id = `datenschutz_${anrede}`
const titel = `Datenschutz-Absatz für die Website (${anrede === 'du' ? 'Du' : 'Sie'}-Form)`
const absatz = d.datenschutz?.[anrede]
return absatz ? neuerAbschnitt(id, titel, absatz, true) : neuerAbschnitt(id, titel, DATENSCHUTZ_HINWEIS)
}
// ---------------------------------------------------------------- Abschnitt Abnahme-Checkliste
// Wer den Datenschutz-Absatz einfuegt: die Regel der Leistungsbeschreibung (Nr. 4 Abs. 9); Checkliste und Schritt 4 der Kurzfassung teilen sie
const DATENSCHUTZ_REGEL =
'liegt die Datenschutzerklärung in der von WEBklar betreuten Website, fügen wir den Absatz beim Einbau ein, das gehört zur Einrichtung und wird nicht auf das Stundenkontingent angerechnet; '
+ 'sonst den Absatz als Textbaustein per Chat an den Kunden senden, der ihn selbst einfügt'
/**
* Abrechnung am Dokument (einrichtungAbrechnung, Abo-Modell der Kundenansicht neu): 'abo' (aufs Abo-Stundenkonto) oder 'projekt' (im Projekt
* enthalten); alles andere (Kauf-Bestand, Pilot, Kulanz) ist '' und behaelt die bisherigen Texte Zeichen fuer Zeichen.
*/
const abrechnungVon = (feature) => (feature?.einrichtungAbrechnung === 'abo' || feature?.einrichtungAbrechnung === 'projekt' ? feature.einrichtungAbrechnung : '')
/** DATENSCHUTZ_REGEL je Abrechnung: ohne Marker die Konstante wörtlich, im Abo-Modell ohne "nicht angerechnet". */
function datenschutzRegel(feature) {
const abrechnung = abrechnungVon(feature)
if (!abrechnung) return DATENSCHUTZ_REGEL
return DATENSCHUTZ_REGEL.replace('wird nicht auf das Stundenkontingent angerechnet', abrechnung === 'abo' ? 'zählt mit zum Stundenkonto' : 'ist im Projekt enthalten')
}
function abschnittCheckliste(d) {
const { e } = d
const widerruf = zeigtWiderruf(e)
const preis = sichtbareLeistungen(e).some(hatPreis)
const knopf = d.wunsch.modus === 'knopf'
const impressum = zeile(d.betrieb.impressumUrl) || 'fehlt'
const datenschutz = zeile(d.betrieb.datenschutzUrl) || 'fehlt'
const wo = d.anleitungsLink ? ` (Kunde → Reiter Funktionen, Zeile Terminplaner: ${d.anleitungsLink})` : ''
const punkte = [
// vor der Freischaltung
'Einbau-Code eingefügt (Abschnitt „Einbau-Code (Snippet)“). Das Widget bleibt unsichtbar bis zur Freischaltung: Bis dahin steht nur der Ausweichtext auf der Seite.',
...(knopf ? ['Modus knopf: --wkt-akzent immer setzen (Abschnitt „Aussehen (CSS)“). Der Knopf kennt die Betriebsfarbe erst nach dem Klick und nimmt sonst #1d4ed8.'] : []),
'Ausweichtext geprüft: JavaScript aus oder falscher siteKey: Der Text steht, nichts ist kaputt.',
`Datenschutz-Absatz „Online-Terminbuchung“ steht in der Datenschutzerklärung der Website (Fassung ${e.anrede === 'du' ? 'Du' : 'Sie'}; ${datenschutzRegel(d.feature)}).`,
`URLs von Impressum (${impressum}) und Datenschutzerklärung (${datenschutz}) stimmen und beginnen mit https://, sonst fehlen die Links im Fuß des Widgets.`,
`„Freischalten (Abnahme)“ im Ticketsystem ausgelöst${wo}. Erst danach antwortet die öffentliche Schnittstelle (nur im Zustand active), und das Widget erscheint.`,
// am sichtbaren Widget
'Probebuchung bis „Fast geschafft“ durchgespielt: Die Bestätigungs-E-Mail kommt an, der Link bestätigt, die zweite E-Mail (mit Kalenderdatei) kommt an, im Portal steht der Termin. '
+ 'Danach den Testtermin stornieren (Verwaltungslink aus der E-Mail oder im Portal).',
...(widerruf ? ['Widerrufsbelehrung ist im Widget aufklappbar (Verbraucher-Betrieb, auch bei nur Anfrage-Leistungen).'] : []),
...(widerruf && preis ? ['Der Haken für den vorzeitigen Beginn erscheint bei Leistungen mit Preis für Termine in den nächsten 14 Tagen.'] : []),
...(widerruf ? ['Der Knopf „Vertrag widerrufen“ steht im Fuß, das Formular funktioniert.'] : []),
...(widerruf && knopf
? ['Modus knopf: Widerrufsknopf dauerhaft neben dem Buchungsknopf (nicht erst im Dialog); er öffnet den Dialog gleich beim Widerrufsformular.']
: []),
'Kontrast geprüft: Text auf dem Hintergrund und Schrift auf dem Akzent mindestens 4,5 : 1, Rand gegen den Hintergrund mindestens 3 : 1; globale Regeln des Themes (button, input) stören nicht.',
'Handy (390 px Breite): kein seitliches Scrollen, Knöpfe gut treffbar, der Dialog passt in den Bildschirm; Zoom 200 %: nichts wird abgeschnitten.',
'Nur mit der Tastatur bedienen: Fokus immer sichtbar, Monatsraster mit Pfeilen, Pos1/Ende und Bild ↑/↓, Esc schließt den Dialog.',
]
return neuerAbschnitt('checkliste', 'Abnahme-Checkliste', punkte.map((p) => `- [ ] ${p}`).join('\n'))
}
// ---------------------------------------------------------------- Abschnitt Abrechnung
const RECHNUNG_RE = /^https:\/\/\S+$/i
/** Preis der Einrichtung aus dem Register (config.features.termine.priceLabel), sonst „149 €“. */
function einrichtungsPreis() {
const preis = zeile(config.features?.termine?.priceLabel)
return preis || '149 €'
}
function abschnittAbrechnung(d) {
const abrechnung = abrechnungVon(d.feature)
if (abrechnung) {
// Abo-Modell: Einrichtung ohne Zahlung, die Zeit gehoert aufs Abo-Stundenkonto bzw. ins Projekt
return neuerAbschnitt('abrechnung', 'Abrechnung', [
abrechnung === 'abo'
? 'Abrechnung: Einrichtung aus dem Abo, Zeit im Arbeitsblatt aufs Abo-Stundenkonto anrechnen (Vorschuss möglich).'
: 'Abrechnung: im Projekt enthalten, Zeit im Arbeitsblatt, nicht aufs Abo anrechnen.',
'Leistungsumfang: ein Widget an einer Stelle, Gestaltung nach Fragebogen, Einbau; Mehrwünsche (zweites Widget, Knöpfe je Leistung, Sonderdesign) nach Stunden, vorher im Chat abstimmen.',
].join('\n'))
}
if (d.feature?.unlockedByPurchase !== true) {
return neuerAbschnitt('abrechnung', 'Abrechnung', 'Abrechnung: ohne Zahlung freigeschaltet (Pilot/Kulanz), keine Abrechnung.')
}
const rechnung = zeile(d.feature.invoiceUrl)
return neuerAbschnitt('abrechnung', 'Abrechnung', [
`Abrechnung: Einrichtung bezahlt (${einrichtungsPreis()}, Rechnung ${RECHNUNG_RE.test(rechnung) ? rechnung : 'folgt im Portal'}), Zeit im Arbeitsblatt, nicht auf Abo-Stunden.`,
'Leistungsumfang: ein Widget an einer Stelle, Gestaltung nach Fragebogen, Einbau; Mehrwünsche (zweites Widget, Knöpfe je Leistung, Sonderdesign) nach Stunden, vorher im Chat abstimmen.',
].join('\n'))
}
// ---------------------------------------------------------------- Warnungen
const WARNUNG_PFLICHT = {
'betrieb.telefon': 'Telefonnummer des Betriebs fehlt: Gäste können nicht buchen, das Widget zeigt nur den Ausweichtext.',
'betrieb.adresse':
'Anschrift des Betriebs fehlt (sie steht in der Widerrufsbelehrung): Gäste können nicht buchen, das Widget zeigt nur den Ausweichtext.',
}
const WARNUNG_OHNE_URL = 'fehlt: Im Fuß des Widgets fehlt der Link.'
const WARNUNG_OHNE_DATENSCHUTZ = 'Datenschutz-Absatz nicht erzeugbar: Name oder E-Mail-Adresse des Betriebs fehlt in den Einstellungen.'
/**
* Meldungen aus gesundheitsWarnungen (Block C) tragen Namen des Kunden: gekuerzt in UTF-16-Einheiten (ein halbes Zeichenpaar ist moeglich)
* und ungemaskiert. Hier werden sie wohlgeformt und einzeilig; sie bleiben Klartext, der Anzeiger setzt sie als Text ein (nie als HTML).
*/
const meldungText = (wert) => zeile(typeof wert === 'string' ? wert.toWellFormed() : '')
/**
* Die Gesundheitsmeldungen einer Einstellung (hoechstens 10, Pruefung aus Block C) in der Form, in der sie im Panel, im Ticket und in T16 stehen:
* wohlgeformt, einzeilig, Klartext. Ohne Einstellung (null) und ohne Treffer leer.
*/
export function gesundheitsMeldungen(einstellung) {
return gesundheitsWarnungen(einstellung).map(meldungText)
}
/**
* Warnungen fuer das Team in fester Reihenfolge, in drei Teilen: davor (Banner, Pflichtangaben, Impressum, Datenschutz-URL), die Gesundheits-
* meldungen, danach (Kontrast, Datenschutz-Absatz). warnungen = davor + gesundheit + danach; die Kurzfassung (Ticket) setzt die Gesundheit
* in einen eigenen Absatz und zaehlt sie nicht zu ihren fuenf Warnungen.
*/
function warnungsTeile(d) {
const davor = []
if (d.entwurf) davor.push(ENTWURF_BANNER)
for (const feld of fehlendePflichtangaben(d.e)) davor.push(WARNUNG_PFLICHT[feld] ?? `Pflichtangabe fehlt: ${feld}`)
if (!zeile(d.betrieb.impressumUrl)) davor.push(`Impressum-URL ${WARNUNG_OHNE_URL}`)
if (!zeile(d.betrieb.datenschutzUrl)) davor.push(`Datenschutz-URL ${WARNUNG_OHNE_URL}`)
const danach = [...cssVorschlag(d.farbe).hinweise]
if (!d.datenschutz) danach.push(WARNUNG_OHNE_DATENSCHUTZ)
return { davor, gesundheit: gesundheitsMeldungen(d.e), danach }
}
// ---------------------------------------------------------------- Kurzfassung fuer das Einrichtungs-Ticket (Task D4)
// `kurz` steht in ticket.details (Klartext, das WOMS zeigt es mit pre-wrap) und ist die einzige Fassung, die ins Ticket geht: ohne Datenschutz-Absatz
// (4,8 k Zeichen je Variante), ohne Token. Absaetze durch eine Leerzeile; der erste ist der Kopf und endet mit der Konfigurationsstand-Zeile, die
// ticketStand liest. Platz: TICKET_DETAILS_MAX. Statt mitten im Satz abzuschneiden, ist es eine Budgetliste: Kopf, Gesundheitsabsatz, Snippet,
// Schritte und Verweis bleiben immer (eine Gesundheitsmeldung verschwindet nie unter Platzdruck); passt die Summe nicht, fallen erst die Zaehlungen
// weg, dann die uebrigen Warnungen (die letzte zuerst), dann der Kundenwunsch-Block. Nur wenn selbst die Pflichtteile nicht passen (ein unge-
// pruefter Wert sprengt sie), kuerzt kuerze() am Ende an einer Zeilengrenze mit der Schlusszeile des Tickets.
const KURZ_NAMEN = 8 // so viele Namen je Zaehlung, danach "… und N weitere"
const KURZ_NAME_MAX = 60
const KURZ_KOPF_MAX = 120
const KURZ_WARNUNGEN = 5
const GESUNDHEIT_ABSATZ = 'Gesundheitsbereich prüfen:'
const KURZ_OHNE_LINK = 'Verweis: im WOMS unter Kunden → Funktionen, Anleitung des Projekts'
const kopfWert = (wert) => kuerze(zeile(wert), KURZ_KOPF_MAX, { endzeile: '…' })
const kurzName = (name) => kuerze(zeile(name) || 'ohne Namen', KURZ_NAME_MAX, { endzeile: '…' })
/** 'Konfigurationsstand: Version N, TT.MM.JJJJ' | '… Version N' (ohne Datum) | '… Version 0 (noch keine Einstellungen gespeichert)' | '… (Einstellungen nicht lesbar)'. */
function standZeile(version, geaendert, hatEinstellung) {
const n = Number.isInteger(version) && version >= 1 ? version : 0
if (n === 0) return 'Konfigurationsstand: Version 0 (noch keine Einstellungen gespeichert)'
if (!hatEinstellung) return `Konfigurationsstand: Version ${n} (Einstellungen nicht lesbar)`
return `Konfigurationsstand: Version ${n}${geaendert ? `, ${geaendert}` : ''}`
}
/** Kopf: Funktion, Projekt, Domain (wie createFeatureTicket: liveDomain, sonst previewUrl, sonst subdomain), Kunde, siteKey, Stand. */
function kurzKopf(k) {
const domain = zeile(k.project?.liveDomain) || zeile(k.project?.previewUrl) || zeile(k.project?.subdomain)
return [
'Funktion: Terminplaner',
`Projekt: ${kopfWert(k.project?.projectName) || kopfWert(k.project?.subdomain)}`,
`Domain: ${kopfWert(domain)}`,
`Kunde: ${kopfWert(k.kunde)}`,
`siteKey: ${k.siteKey}`,
standZeile(k.version, k.geaendert, k.hatEinstellung),
].join('\n')
}
/** ' Stelle: erste Zeile' und die weiteren Zeilen um vier Leerzeichen eingerueckt; Leerzeilen fallen weg (nie ein Absatzumbruch im Kundentext). */
function kurzWunschZeilen(bezeichnung, wert) {
const [erste, ...weitere] = wert.split('\n').filter((z) => z !== '')
return erste === undefined ? [] : [` ${bezeichnung}: ${erste}`, ...weitere.map((z) => ` ${z}`)]
}
/** Kundenwunsch: Modus (ohne Wahl: Standard) und die vier Wunschtexte des Kunden (Text des Kunden, nie im Snippet ausser dem Knopf-Text). */
function kurzWunsch(w) {
const zeilen = [
...kurzWunschZeilen('Knopf-Text', w.knopfText),
...kurzWunschZeilen('Stelle', w.stelle),
...kurzWunschZeilen('Gestaltung', w.gestaltung),
...kurzWunschZeilen('Bemerkung', w.bemerkung),
]
return [
'Kundenwunsch (Text des Kunden):',
` Modus: ${w.modus}${w.modusGewaehlt ? '' : ' (Standard)'}`,
...(zeilen.length ? zeilen : [' Sonst keine Angaben im Fragebogen']),
].join('\n')
}
/** '- 12 Leistungen (11 aktiv): A, B, … und 4 weitere': Anzahl, aktive (nur wenn es welche gibt und gezaehlt wird) und die ersten 8 Namen. */
function kurzZaehlung(eintraege, einzahl, mehrzahl, { mitAktiv = false } = {}) {
const n = eintraege.length
const aktiv = mitAktiv && n > 0 ? ` (${eintraege.filter((x) => x.aktiv).length} aktiv)` : ''
const namen = kuerzeListe(eintraege.map((x) => kurzName(x.name)), KURZ_NAMEN).join(', ')
return `- ${anzahl(n, einzahl, mehrzahl)}${aktiv}${n > 0 ? `: ${namen}` : ''}`
}
function kurzUmfang(k) {
return [
'Umfang:',
kurzZaehlung(k.leistungen, 'Leistung', 'Leistungen', { mitAktiv: true }),
kurzZaehlung(k.ressourcen, 'Person oder Platz', 'Personen und Plätze', { mitAktiv: true }),
kurzZaehlung(k.zusatzfelder.map((z) => ({ name: z.label })), 'Zusatzfeld', 'Zusatzfelder'),
].join('\n')
}
/** Die sechs Schritte des Teams; Schritt 6 nennt den Preis nur, wenn die Einrichtung bezahlt ist (unlockedByPurchase === true). */
function kurzSchritte(k) {
const form = !k.hatEinstellung ? 'Sie- oder Du-Form nach der Anrede der Gäste' : k.anrede === 'du' ? 'Du-Form' : 'Sie-Form'
const bezahlt = k.feature?.unlockedByPurchase === true ? ` (Einrichtung bezahlt, ${einrichtungsPreis()})` : ''
const abrechnung = abrechnungVon(k.feature)
return [
'Schritte:',
'1. WOMS-Panel öffnen (Kunde → Reiter Funktionen → Anleitung des Projekts): dort stehen alle Einstellungen, die Kopierknöpfe und die Abnahme-Checkliste.',
'2. Einbau-Code einfügen: den Code oben an der Stelle einsetzen, die der Kunde nennt (siehe Kundenwunsch), das Skript einmal pro Seite.',
'3. Gestaltung und Kontrast: das CSS aus der Anleitung (Abschnitt „Aussehen (CSS)“) in die Website übernehmen und den Kontrast prüfen.',
`4. Datenschutz-Absatz (${form}) aus der Anleitung in die Datenschutzerklärung der Website aufnehmen, bevor freigeschaltet wird (${datenschutzRegel(k.feature)}).`,
'5. „Freischalten (Abnahme)“ im WOMS auslösen, danach eine Probebuchung durchspielen und den Testtermin wieder stornieren (Checkliste in der Anleitung).',
abrechnung ? '6. Ticket erledigen und die Zeit im Arbeitsblatt buchen.' : `6. Ticket erledigen und die Zeit im Arbeitsblatt buchen. Nicht auf das Stundenkontingent anrechnen${bezahlt}.`,
].join('\n')
}
/**
* Die Kurzfassung aus den vorbereiteten Teilen. k: feature, siteKey, project, kunde, version, geaendert, hatEinstellung, anrede, wunsch, leistungen,
* ressourcen, zusatzfelder, warnungen (ohne Gesundheit), gesundheit (Meldungen), snippet, anleitungsLink.
*/
function kurzfassung(k) {
const pflicht = {
kopf: kurzKopf(k),
gesundheit: k.gesundheit.length ? [GESUNDHEIT_ABSATZ, ...k.gesundheit.map((m) => `- ${m}`)].join('\n') : '',
snippet: `Einbau-Code (Snippet):\n${k.snippet}`,
schritte: kurzSchritte(k),
verweis: k.anleitungsLink ? `Verweis: ${k.anleitungsLink}` : KURZ_OHNE_LINK,
}
const umfang = k.hatEinstellung ? kurzUmfang(k) : ''
const wunsch = kurzWunsch(k.wunsch)
const warnungen = k.warnungen.slice(0, KURZ_WARNUNGEN).map((w) => `- ${w}`)
let mitUmfang = Boolean(umfang)
let mitWunsch = true
const zusammen = () =>
[
pflicht.kopf,
mitWunsch ? wunsch : '',
mitUmfang ? umfang : '',
warnungen.length ? ['Warnungen:', ...warnungen].join('\n') : '',
pflicht.gesundheit,
pflicht.snippet,
pflicht.schritte,
pflicht.verweis,
].filter(Boolean).join('\n\n').toWellFormed()
let text = zusammen()
if (text.length > TICKET_DETAILS_MAX) {
mitUmfang = false
text = zusammen()
}
while (text.length > TICKET_DETAILS_MAX && warnungen.length > 0) {
warnungen.pop()
text = zusammen()
}
if (text.length > TICKET_DETAILS_MAX) {
mitWunsch = false
text = zusammen()
}
return text.length > TICKET_DETAILS_MAX ? kuerze(text, TICKET_DETAILS_MAX, { anZeile: true, endzeile: TICKET_GEKUERZT }) : text
}
const STAND_RE = /^Konfigurationsstand: Version (\d{1,9})(?!\d)/m
/**
* Die Version der Einstellungen, auf der ein Ticket beruht: die erste Zeile "Konfigurationsstand: Version N" am Zeilenanfang (die Wuensche des
* Kunden stehen eingerueckt und koennen sie nicht nachahmen). Ohne die Zeile, mit mehr als neun Stellen oder bei Nicht-Text null; Version 0 ist ein Stand.
*/
export function ticketStand(details) {
if (typeof details !== 'string') return null
const treffer = STAND_RE.exec(details)
return treffer ? Number(treffer[1]) : null
}
/**
* Hinweis fuers Team, wenn die Einstellungen seit der Bestellung gespeichert wurden: ticketVersion aus ticketStand, jetzt die aktuelle Version.
* Nur bei zwei ganzen Zahlen und jetzt > ticketVersion, sonst null (auch ohne Stand im Ticket).
*/
export function driftWarnung(ticketVersion, jetzt) {
if (!Number.isInteger(ticketVersion) || !Number.isInteger(jetzt) || jetzt <= ticketVersion) return null
return `Einstellungen seit der Bestellung geändert (Ticket Version ${ticketVersion}, jetzt ${jetzt}).`
}
// ---------------------------------------------------------------- Zusammenbau
const ABSCHNITTE = [
abschnittModell, abschnittLeistungen, abschnittRessourcen, abschnittRegeln, abschnittGastfelder, abschnittBenachrichtigungen,
abschnittEinbau, abschnittSnippet, abschnittCss, abschnittDatenschutz('sie'), abschnittDatenschutz('du'), abschnittCheckliste, abschnittAbrechnung,
]
/** Alle Abschnitte mit Ueberschrift '## Titel', durch eine Leerzeile getrennt. */
const volltext = (abschnitte) => abschnitte.map((a) => `## ${a.titel}\n${a.text}`).join('\n\n')
/** 'Projektname (Domain)' | 'Projektname' | 'Domain' | '' (Domain = liveDomain, sonst previewUrl, sonst subdomain). */
function projektText(project) {
const name = zeile(project?.projectName) || zeile(project?.subdomain)
const domain = zeile(project?.liveDomain) || zeile(project?.previewUrl) || zeile(project?.subdomain)
return name && domain && name !== domain ? `${name} (${domain})` : name || domain
}
/**
* Anleitung fuer den Einbau des Terminplaners bei einem Betrieb (Team-Sicht): rein, kein I/O, gleiche Eingabe und gleiches `now`
* ergeben gleiche Ausgabe (deepEqual). config wird beim Aufruf gelesen.
*
* @param {object} p
* @param {object} [p.feature] portalFeatures-Dokument der Funktion (gelesen: siteKey, customerId, projectId, unlockedByPurchase,
* invoiceUrl; der Registereintrag config.features.termine wird nicht gebraucht)
* @param {object} [p.project] websiteProjects-Dokument (gelesen: projectName, subdomain, liveDomain, previewUrl)
* @param {object|null} p.einstellung gespeicherte Einstellung (termineConfig.einstellung); null = noch keine
* @param {object|null} [p.fragebogen] geparster Fragebogen oder null (gelesen: einbau.modus, knopfText, stelle, gestaltung, bemerkung)
* @param {number} p.version Version der Einstellungen (0 = noch nie gespeichert)
* @param {Date} p.now Zeitpunkt der Erzeugung; ein ungueltiges now wirft einen TypeError
* @param {string|null} [p.updatedAt] ISO-Zeitpunkt der letzten Aenderung der Einstellungen
* @param {{name?: string}|null} [p.kunde] gelesen wird nur der Name
* @param {boolean} [p.entwurf] Rechtstexte sind noch ENTWURF (erste Warnung ENTWURF_BANNER); Standard RECHTSTEXTE_ENTWURF
* @returns {{stand: {version: number, updatedAt: string|null}, warnungen: string[], abschnitte: Array<{id: string, titel: string, text: string, kopierbar: boolean}>, text: string, kurz: string}}
* abschnitte: 13 Stueck in fester Reihenfolge (modell bis abrechnung), kopierbar nur snippet, css und die beiden Datenschutz-Absaetze.
* text: alle Abschnitte mit Ueberschrift, aber nur die Datenschutz-Fassung, die zu einstellung.anrede passt ('du', sonst Sie).
* Ohne Einstellung (null): nur der Abschnitt snippet und die Warnung "Keine Einstellungen gespeichert".
* kurz: die Kurzfassung fuer ticket.details (Klartext, hoechstens TICKET_DETAILS_MAX Zeichen, erster Absatz = Kopf mit der Konfigurationsstand-Zeile,
* ohne Datenschutz-Absatz und ohne Token); auch ohne Einstellung gefuellt (Kopf mit Version 0, Snippet, Schritte, Verweis).
*/
export function einbauanleitung({ feature, project, einstellung, fragebogen, version, now, updatedAt = null, kunde = null, entwurf = RECHTSTEXTE_ENTWURF }) {
if (!(now instanceof Date) || Number.isNaN(now.getTime())) throw new TypeError('einbauanleitung: now fehlt')
const stand = { version, updatedAt }
const banner = entwurf ? [ENTWURF_BANNER] : []
const geaendertAm = updatedAt ? new Date(updatedAt) : null
const gemeinsam = {
feature,
project,
siteKey: zeile(feature?.siteKey),
portal: zeile(config.publicBaseUrl),
wunsch: kundenwunsch(fragebogen),
anleitungsLink: feature && zeile(feature.customerId) && zeile(feature.projectId) ? anleitungsLink(zeile(feature.customerId), zeile(feature.projectId)) : '',
version,
geaendert: geaendertAm && !Number.isNaN(geaendertAm.getTime()) ? datumKurz(geaendertAm) : '',
}
if (!istObjekt(einstellung)) {
const abschnitte = [abschnittSnippet({ ...gemeinsam, telefon: '' })]
const warnungen = [...banner, 'Keine Einstellungen gespeichert']
const kurz = kurzfassung({
...gemeinsam,
kunde: zeile(kunde?.name),
hatEinstellung: false,
anrede: null,
leistungen: [],
ressourcen: [],
zusatzfelder: [],
warnungen,
gesundheit: [],
snippet: abschnitte[0].text,
})
return { stand, warnungen, abschnitte, text: volltext(abschnitte), kurz }
}
const ressourcen = liste(einstellung.ressourcen).filter(istObjekt)
const betrieb = istObjekt(einstellung.betrieb) ? einstellung.betrieb : {}
const daten = {
...gemeinsam,
e: einstellung,
now,
entwurf: Boolean(entwurf),
heuteTag: heute(now),
betrieb,
telefon: zeile(betrieb.telefon),
kunde: zeile(kunde?.name),
projekt: projektText(project),
farbe: typeof einstellung.farbe === 'string' && FARBE_RE.test(einstellung.farbe) ? einstellung.farbe.toLowerCase() : VORGABE_FARBE,
haltezeit: config.termine?.holdMinutes,
ressourcen,
leistungen: liste(einstellung.leistungen).filter(istObjekt),
nachId: new Map(ressourcen.map((r) => [r.id, { typ: r.typ, name: zeile(r.name) || 'ohne Namen' }])),
datenschutz: datenschutzTexte(einstellung),
}
const abschnitte = ABSCHNITTE.map((baue) => baue(daten))
// der Volltext fuehrt nur die Datenschutz-Fassung, die zur Anrede der Gaeste passt
const andere = einstellung.anrede === 'du' ? 'datenschutz_sie' : 'datenschutz_du'
const teile = warnungsTeile(daten)
const kurz = kurzfassung({
...daten,
hatEinstellung: true,
anrede: einstellung.anrede,
zusatzfelder: liste(istObjekt(einstellung.gastFelder) ? einstellung.gastFelder.zusatz : null).filter(istObjekt),
warnungen: [...teile.davor, ...teile.danach],
gesundheit: teile.gesundheit,
snippet: abschnitte.find((a) => a.id === 'snippet').text,
})
return {
stand,
warnungen: [...teile.davor, ...teile.gesundheit, ...teile.danach],
abschnitte,
text: volltext(abschnitte.filter((a) => a.id !== andere)),
kurz,
}
}