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) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[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 [ `
`, `

${ausweich}

`, '
', ``, ].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, } }