einbauanleitung({ feature, project, einstellung, fragebogen, version, now, updatedAt, kunde,
entwurf }) setzt die ersten sechs Abschnitte der Team-Anleitung zusammen (modell, leistungen,
ressourcen, regeln, gastfelder, benachrichtigungen, feste Reihenfolge, nicht kopierbar) plus
stand { version, updatedAt } und den Volltext. Rein: kein I/O, config nur beim Aufruf
(Portal-Adresse, Haltezeit); gleiche Eingabe und gleiches now ergeben gleiche Ausgabe, die
Eingabe bleibt unveraendert.
Feldweissliste statt Objektkopie: nie feedToken, keine Betriebs-E-Mail, keine
Leistungsbeschreibung, keine unbekannten Zusatzfelder. Kundentexte stehen unmaskiert als Text,
einzeilige Werte werden auf eine Zeile gebracht (keine vorgetaeuschte Ueberschrift).
Abwesenheiten und Sonderoeffnungen nur ab heute (Berliner Kalendertag), Listen ueber 20
Eintraege enden mit "und N weitere". Hilfen wochenplanText und kuerzeListe sind exportiert.
Warnungen und Kurzfassung bleiben leer (kommen mit D3b und D4); ohne Einstellung entstehen
keine Abschnitte.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
434 lines
23 KiB
JavaScript
434 lines
23 KiB
JavaScript
import { config } from '../../config.js'
|
||
import { VORGABE_FARBE, preisHinweis, preisText, sichtbareLeistungen, zahlungText, zeigtWiderruf } from './einstellungen.js'
|
||
import { RECHTSTEXTE_ENTWURF } 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).
|
||
*
|
||
* 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 haengt Einbau bis Abrechnung, die
|
||
// Warnungen und den vollen Volltext an, Task D4 die Kurzfassung (`kurz`).
|
||
//
|
||
// 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 im Datenschutz-Absatz, D3b), Beschreibungen der Leistungen und alles,
|
||
// was sonst an den Objekten haengt. Kundentexte (Namen, Titel, Storno-Text) stehen unmaskiert als Text; wer sie anzeigt, setzt
|
||
// sie als Text ein. Einzeilige Werte werden auf eine Zeile gebracht, damit ein Name keine Ueberschrift vortaeuschen kann.
|
||
|
||
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) => ({ id, titel, text, kopierbar: false })
|
||
|
||
/** 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'))
|
||
}
|
||
|
||
// ---------------------------------------------------------------- Zusammenbau
|
||
|
||
const ABSCHNITTE = [abschnittModell, abschnittLeistungen, abschnittRessourcen, abschnittRegeln, abschnittGastfelder, abschnittBenachrichtigungen]
|
||
|
||
/** 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 (D3b: siteKey, unlockedByPurchase; 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 (D3b: Kundenwuensche zum Einbau)
|
||
* @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] D3b: Rechtstexte sind noch ENTWURF (Warnung); 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}}
|
||
* D3a: warnungen [] und kurz '' (kommen mit D3b und D4); ohne Einstellung keine Abschnitte. Die Anrede der Gaeste kommt aus einstellung.anrede.
|
||
*/
|
||
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 }
|
||
if (!istObjekt(einstellung)) return { stand, warnungen: [], abschnitte: [], text: '', kurz: '' }
|
||
|
||
const ressourcen = liste(einstellung.ressourcen).filter(istObjekt)
|
||
const geaendert = updatedAt ? new Date(updatedAt) : null
|
||
const daten = {
|
||
e: einstellung,
|
||
now,
|
||
heuteTag: heute(now),
|
||
version,
|
||
geaendert: geaendert && !Number.isNaN(geaendert.getTime()) ? datumKurz(geaendert) : '',
|
||
betrieb: istObjekt(einstellung.betrieb) ? einstellung.betrieb : {},
|
||
kunde: zeile(kunde?.name),
|
||
projekt: projektText(project),
|
||
farbe: typeof einstellung.farbe === 'string' && FARBE_RE.test(einstellung.farbe) ? einstellung.farbe.toLowerCase() : VORGABE_FARBE,
|
||
portal: zeile(config.publicBaseUrl),
|
||
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' }])),
|
||
}
|
||
const abschnitte = ABSCHNITTE.map((baue) => baue(daten))
|
||
return { stand, warnungen: [], abschnitte, text: volltext(abschnitte), kurz: '' }
|
||
}
|