658 lines
37 KiB
JavaScript
658 lines
37 KiB
JavaScript
import { config } from '../../config.js'
|
||
import { anleitungsLink } 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) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[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 haengt die Kurzfassung (`kurz`) an.
|
||
//
|
||
// 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
|
||
|
||
// Zwischenregel bis Block F (F10b2 ersetzt sie durch die Endregel aus F4: WEBklar fuegt den Absatz ein, wenn die Website von WEBklar betreut wird)
|
||
const DATENSCHUTZ_ZWISCHENREGEL = 'liegt die Datenschutzerklärung nicht in unserem Repo: Absatz per Chat an den Kunden senden'
|
||
|
||
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'}; ${DATENSCHUTZ_ZWISCHENREGEL}).`,
|
||
`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) {
|
||
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() : '')
|
||
|
||
/** Warnungen fuer das Team in fester Reihenfolge: Banner, Pflichtangaben, Impressum, Datenschutz-URL, Gesundheit, Kontrast, Datenschutz-Absatz. */
|
||
function warnungenAus(d) {
|
||
const warnungen = []
|
||
if (d.entwurf) warnungen.push(ENTWURF_BANNER)
|
||
for (const feld of fehlendePflichtangaben(d.e)) warnungen.push(WARNUNG_PFLICHT[feld] ?? `Pflichtangabe fehlt: ${feld}`)
|
||
if (!zeile(d.betrieb.impressumUrl)) warnungen.push(`Impressum-URL ${WARNUNG_OHNE_URL}`)
|
||
if (!zeile(d.betrieb.datenschutzUrl)) warnungen.push(`Datenschutz-URL ${WARNUNG_OHNE_URL}`)
|
||
for (const meldung of gesundheitsWarnungen(d.e)) warnungen.push(meldungText(meldung))
|
||
warnungen.push(...cssVorschlag(d.farbe).hinweise)
|
||
if (!d.datenschutz) warnungen.push(WARNUNG_OHNE_DATENSCHUTZ)
|
||
return warnungen
|
||
}
|
||
|
||
// ---------------------------------------------------------------- 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` ist '' (kommt mit D4).
|
||
*/
|
||
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 gemeinsam = {
|
||
feature,
|
||
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)) : '',
|
||
}
|
||
|
||
if (!istObjekt(einstellung)) {
|
||
const abschnitte = [abschnittSnippet({ ...gemeinsam, telefon: '' })]
|
||
return { stand, warnungen: [...banner, 'Keine Einstellungen gespeichert'], abschnitte, text: volltext(abschnitte), kurz: '' }
|
||
}
|
||
|
||
const ressourcen = liste(einstellung.ressourcen).filter(istObjekt)
|
||
const geaendert = updatedAt ? new Date(updatedAt) : null
|
||
const betrieb = istObjekt(einstellung.betrieb) ? einstellung.betrieb : {}
|
||
const daten = {
|
||
...gemeinsam,
|
||
e: einstellung,
|
||
now,
|
||
entwurf: Boolean(entwurf),
|
||
heuteTag: heute(now),
|
||
version,
|
||
geaendert: geaendert && !Number.isNaN(geaendert.getTime()) ? datumKurz(geaendert) : '',
|
||
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'
|
||
return {
|
||
stand,
|
||
warnungen: warnungenAus(daten),
|
||
abschnitte,
|
||
text: volltext(abschnitte.filter((a) => a.id !== andere)),
|
||
kurz: '',
|
||
}
|
||
}
|