Files
Webklar-Kundenbereich/public/termine-tab-einstellungen.js
T

282 lines
19 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* Terminplaner im Kundenbereich: Reiter "Einstellungen" (Spezifikation Abschnitt 9, Punkt 4) – Betriebsangaben fuer Gaeste, Anrede und Farbe,
Regeln, Angaben von Gaesten (Zusatzfelder), Erinnerung, Stornobedingungen (mit Muster), Rechtliches, Bezahlung, Aufbewahrung,
darunter Export und Status. render(panel, ctx) ist der Einstieg (Registry REITER in termine.js).
Alles ist EIN Formular mit einem Speichern (termine-speichern.js). Jeder Abschnitt ist eine Funktion ctx => { el, lese(entwurf, fehler) }:
el die Karte, lese uebertraegt die Felder in den Entwurf und meldet Eingaben, die kein Feld verstehen kann, in fehler (Pfad ⇒ Text).
Pruefen und Begrenzen bleibt Sache des Servers. Abgeschaltete Funktion (ctx.nurLesen): Felder gesperrt, kein Speichern, Export bleibt. */
import {
ERINNERUNG_STUNDEN, GAST_FELD_WAHL, GESUNDHEIT_HINWEIS, LIMITS, MAX, RASTER_MIN, ZAHLARTEN, aktivSeitText, fehltText, ganzzahl, optionenAusText, optionenText, zusatzSchluessel,
} from './termine-view.js'
import { ANREDE_WAHL, stornoMuster } from './termine-gaestetexte.js'
import { abschnitt, allgemeinerFehler, exportLink, feld, gesperrt, h, hakenFeld, zeigeFehler } from './termine-dom.js'
import { neuerSpeicher } from './termine-speichern.js'
const wahl = (liste, aktuell) => liste.map(([wert, text]) => h('option', { value: wert, selected: String(aktuell) === String(wert) }, text))
const zahlenFeld = (name, label, wert, { min, max, schritt = 1, hinweis }) => {
const input = h('input', { type: 'number', inputmode: 'numeric', min, max, step: schritt, value: wert })
return { input, el: feld(name, label, input, hinweis) }
}
const liesZahl = (fehler, name, input) => {
const wert = ganzzahl(input.value)
if (wert === null) fehler[name] = 'Bitte gib eine ganze Zahl an.'
return wert
}
export function render(panel, ctx) {
const wurzel = h('div', { class: 'tm-tab' })
panel.replaceChildren(wurzel)
const sp = neuerSpeicher(ctx, wurzel, zeichne)
function zeichne() {
const e = ctx.daten.einstellung
const regelnAbschnitt = regeln(e)
const abschnitte = [betrieb(ctx, e), darstellung(e), regelnAbschnitt, gastFelder(ctx, e), erinnerung(e), storno(ctx, e, regelnAbschnitt.absage), rechtliches(e), zahlung(e), aufbewahrung(e)]
const speichern = h('button', { type: 'submit', class: 'kb-btn-primary' }, 'Einstellungen speichern')
const form = h('form', { novalidate: true, 'aria-label': 'Einstellungen' },
gesperrt(ctx.nurLesen, abschnitte.map((a) => a.el)),
allgemeinerFehler(),
ctx.nurLesen ? null : h('div', { class: 'tm-frage-knoepfe' }, speichern))
form.addEventListener('submit', async (ev) => {
ev.preventDefault()
const ok = await sp.speichere((entwurf) => {
const fehler = {}
for (const a of abschnitte) a.lese(entwurf, fehler)
if (Object.keys(fehler).length) {
zeigeFehler(form, { fehler })
return false
}
return true
}, { container: form, knopf: speichern })
if (!ok || !sp.nochHier()) return
zeichne()
ctx.meldung('Einstellungen gespeichert.')
})
wurzel.replaceChildren(sp.konflikt, form, exportUndStatus(ctx))
}
zeichne()
}
/* ----------------------------------------------------------------- Betrieb --- */
function betrieb(ctx, e) {
const b = e.betrieb || {}
const text = (wert, max, extra) => h('input', { type: 'text', maxlength: max, autocomplete: 'off', value: wert ?? '', ...extra })
const url = (wert) => text(wert, MAX.url, { inputmode: 'url', placeholder: 'https://' })
const f = {
name: text(b.name, MAX.betriebName), email: h('input', { type: 'email', maxlength: '254', autocomplete: 'off', value: b.email ?? '' }), telefon: text(b.telefon, MAX.telefon),
adresse: text(b.adresse, MAX.adresse), impressumUrl: url(b.impressumUrl), datenschutzUrl: url(b.datenschutzUrl), agbUrl: url(b.agbUrl), websiteUrl: url(b.websiteUrl),
kleinunternehmer: h('input', { type: 'checkbox', checked: b.kleinunternehmer === true }),
}
const fehlt = ctx.daten.fehlt || []
const warnung = fehlt.length
? h('div', { class: 'tm-box tm-box-warn', role: 'note' }, `Ohne Telefon/Anschrift ist der Terminplaner auf deiner Website nicht buchbar. Es fehlt noch: ${fehltText(fehlt) || 'eine Angabe zu deinem Betrieb'}.`)
: null
const el = abschnitt('Dein Betrieb',
'Diese Angaben sehen deine Gäste beim Buchen und in ihren E-Mails. Die meisten haben wir aus deinem Kundenkonto übernommen.',
warnung,
h('div', { class: 'tm-raster' },
feld('betrieb.name', 'Name des Betriebs', f.name),
feld('betrieb.email', 'E-Mail des Betriebs', f.email, 'Hierhin schicken wir dir Nachrichten zu Terminen. Gäste können auf ihre Mails hier antworten.')),
h('div', { class: 'tm-raster' },
feld('betrieb.telefon', 'Telefon (Pflicht)', f.telefon, 'Gäste sehen die Nummer als Ausweichweg und in ihren Mails.'),
feld('betrieb.adresse', 'Anschrift', f.adresse, 'Pflicht, wenn Privatpersonen eine Leistung sofort buchen: Die Anschrift steht in der Widerrufsbelehrung.')),
hakenFeld('betrieb.kleinunternehmer', f.kleinunternehmer, 'Ich bin Kleinunternehmer nach § 19 UStG', 'Dann steht bei den Preisen „kein Ausweis der USt nach § 19 UStG“ statt „inkl. USt“.'),
h('div', { class: 'tm-raster' },
feld('betrieb.impressumUrl', 'Link zum Impressum', f.impressumUrl, 'Vollständige Adresse mit https://'),
feld('betrieb.datenschutzUrl', 'Link zur Datenschutzerklärung', f.datenschutzUrl, 'Vollständige Adresse mit https://'),
feld('betrieb.agbUrl', 'Link zu deinen AGB (optional)', f.agbUrl, 'Vollständige Adresse mit https://'),
feld('betrieb.websiteUrl', 'Link zu deiner Website', f.websiteUrl, 'Vollständige Adresse mit https://')))
return {
el,
lese(entwurf) {
entwurf.betrieb = {
...entwurf.betrieb,
name: f.name.value, email: f.email.value, telefon: f.telefon.value, adresse: f.adresse.value, kleinunternehmer: f.kleinunternehmer.checked,
impressumUrl: f.impressumUrl.value, datenschutzUrl: f.datenschutzUrl.value, agbUrl: f.agbUrl.value, websiteUrl: f.websiteUrl.value,
}
},
}
}
/* -------------------------------------------------------- Anrede und Farbe --- */
function darstellung(e) {
const anrede = h('select', null, wahl(ANREDE_WAHL, e.anrede ?? 'sie'))
const farbe = h('input', { type: 'color', value: /^#[0-9a-f]{6}$/.test(e.farbe) ? e.farbe : '#7c3aed' })
const el = abschnitt('Anrede und Farbe', 'So treten die Buchungsseite und die E-Mails an deine Gäste auf.',
h('div', { class: 'tm-raster' },
feld('anrede', 'Wie sprichst du deine Gäste an?', anrede),
feld('farbe', 'Akzentfarbe', farbe, 'Für Knöpfe und Kopfzeile der Buchungsseite und der E-Mails.')))
return { el, lese(entwurf) { entwurf.anrede = anrede.value; entwurf.farbe = farbe.value } }
}
/* ------------------------------------------------------------------ Regeln --- */
function regeln(e) {
const r = e.regeln || {}
const raster = h('select', null, wahl(RASTER_MIN.map((n) => [n, `${n} Minuten`]), r.raster ?? 15))
const vorlauf = zahlenFeld('regeln.vorlaufMin', 'Mindestvorlauf (Minuten)', r.vorlaufMin, { min: 0, max: 20160, hinweis: 'Wie kurzfristig darf gebucht werden? 120 sind 2 Stunden, höchstens 20160 (14 Tage).' })
const horizont = zahlenFeld('regeln.horizontTage', 'Buchungszeitraum (Tage)', r.horizontTage, { min: 1, max: 365, hinweis: 'Wie weit im Voraus darf gebucht werden? 1 bis 365 Tage.' })
const absage = zahlenFeld('regeln.absageBisStd', 'Selbst absagen bis (Stunden vorher)', r.absageBisStd, { min: 0, max: 336, hinweis: 'Bis dahin dürfen Gäste ihren Termin selbst absagen oder verschieben. 0 bis 336.' })
const offene = zahlenFeld('regeln.maxOffeneJeEmail', 'Offene Termine je E-Mail-Adresse', r.maxOffeneJeEmail, { min: 1, max: 10, hinweis: 'So viele Termine darf eine Adresse gleichzeitig offen haben (1 bis 10). Das schützt vor Spaßbuchungen.' })
const wahlbar = h('input', { type: 'checkbox', checked: r.personWaehlbar !== false })
const el = abschnitt('Regeln fürs Buchen', null,
h('div', { class: 'tm-raster' }, feld('regeln.raster', 'Startzeiten alle', raster, 'In diesen Schritten bieten wir Uhrzeiten an.'), vorlauf.el, horizont.el, absage.el, offene.el),
hakenFeld('regeln.personWaehlbar', wahlbar, 'Gäste dürfen die Person oder den Platz selbst wählen', 'Sonst vergeben wir automatisch eine freie Person oder einen freien Platz.'))
return {
el,
lese(entwurf, fehler) {
entwurf.regeln = {
...entwurf.regeln,
raster: Number(raster.value), vorlaufMin: liesZahl(fehler, 'regeln.vorlaufMin', vorlauf.input), horizontTage: liesZahl(fehler, 'regeln.horizontTage', horizont.input),
absageBisStd: liesZahl(fehler, 'regeln.absageBisStd', absage.input), maxOffeneJeEmail: liesZahl(fehler, 'regeln.maxOffeneJeEmail', offene.input), personWaehlbar: wahlbar.checked,
}
},
absage: absage.input,
}
}
/* ------------------------------------------------- Angaben von Gaesten --- */
function gastFelder(ctx, e) {
const g = e.gastFelder || {}
const telefon = h('select', null, wahl(GAST_FELD_WAHL, g.telefon ?? 'optional'))
const nachricht = h('select', null, wahl(GAST_FELD_WAHL, g.nachricht ?? 'aus'))
// Zusatzfelder: der Zustand steht in zeilen, die Liste wird daraus gezeichnet (Namen der Fehlerfelder = Stelle in der Liste)
const zeilen = (g.zusatz || []).map((z) => ({ key: z.key, label: z.label, typ: z.typ, optionen: optionenText(z.optionen), pflicht: z.pflicht === true }))
const liste = h('div', { class: 'tm-zusatz' })
const neuKnopf = ctx.nurLesen ? null : h('button', {
type: 'button', class: 'kb-btn-outline', 'data-aktion': 'zusatz-neu',
onclick: () => { zeilen.push({ key: '', label: '', typ: 'text', optionen: '', pflicht: false }); zeichneListe(); liste.lastElementChild?.querySelector('input')?.focus() },
}, '+ Zusatzfeld')
const platz = h('p', { class: 'tm-fehler', 'data-fehler': 'gastFelder.zusatz', role: 'alert' })
function zeichneListe() {
if (neuKnopf) neuKnopf.disabled = zeilen.length >= LIMITS.zusatzfelder
liste.replaceChildren(...zeilen.map((z, i) => {
const p = `gastFelder.zusatz.${i}`
const label = h('input', { type: 'text', maxlength: MAX.zusatzLabel, autocomplete: 'off', value: z.label })
const typ = h('select', null, wahl([['text', 'Freier Text'], ['auswahl', 'Auswahl aus Liste']], z.typ))
const optionen = h('textarea', { rows: '3', maxlength: String(MAX.option * LIMITS.optionen + LIMITS.optionen) }, z.optionen)
const pflicht = h('input', { type: 'checkbox', checked: z.pflicht })
const optionenFeld = feld(`${p}.optionen`, 'Auswahlmöglichkeiten', optionen, `Eine je Zeile, höchstens ${LIMITS.optionen}.`)
optionenFeld.hidden = z.typ !== 'auswahl'
label.addEventListener('input', () => { z.label = label.value })
optionen.addEventListener('input', () => { z.optionen = optionen.value })
pflicht.addEventListener('change', () => { z.pflicht = pflicht.checked })
typ.addEventListener('change', () => { z.typ = typ.value; optionenFeld.hidden = z.typ !== 'auswahl' })
return h('div', { class: 'tm-zusatz-zeile', 'data-zusatz': String(i) },
h('div', { class: 'tm-raster' }, feld(`${p}.label`, 'Beschriftung', label), feld(`${p}.typ`, 'Art', typ)),
optionenFeld,
hakenFeld(`${p}.pflicht`, pflicht, 'Pflichtfeld'),
ctx.nurLesen ? null : h('button', {
type: 'button', class: 'kb-btn-outline tm-gefahr', 'data-aktion': 'zusatz-weg', 'aria-label': `Zusatzfeld entfernen: ${z.label || i + 1}`,
onclick: () => { zeilen.splice(i, 1); zeichneListe(); neuKnopf?.focus() },
}, 'Entfernen'))
}))
}
zeichneListe()
const el = abschnitt('Angaben von Gästen',
'Name und E-Mail brauchen wir immer. Alles Weitere entscheidest du.',
h('div', { class: 'tm-raster' },
feld('gastFelder.telefon', 'Telefonnummer des Gastes', telefon),
feld('gastFelder.nachricht', 'Nachricht des Gastes', nachricht, `Über dem Feld steht für deine Gäste: „${GESUNDHEIT_HINWEIS}“`)),
h('h4', null, 'Zusatzfelder'),
h('p', { class: 'tm-hinweis' }, `Eigene Fragen an deine Gäste, zum Beispiel nach der Haarlänge oder einer Wunschfarbe. Höchstens ${LIMITS.zusatzfelder}. Keine Gesundheitsangaben abfragen.`),
liste,
platz,
neuKnopf)
return {
el,
lese(entwurf) {
const belegt = zeilen.map((z) => z.key).filter(Boolean)
const zusatz = zeilen.map((z) => {
const key = z.key || zusatzSchluessel(z.label, belegt)
belegt.push(key)
return { key, label: z.label, typ: z.typ, optionen: z.typ === 'auswahl' ? optionenAusText(z.optionen) : [], pflicht: z.pflicht }
})
entwurf.gastFelder = { ...entwurf.gastFelder, telefon: telefon.value, nachricht: nachricht.value, zusatz }
},
}
}
/* -------------------------------------------------------------- Erinnerung --- */
function erinnerung(e) {
const r = e.erinnerung || {}
const anbieten = h('input', { type: 'checkbox', checked: r.anbieten !== false })
const stunden = h('select', null, wahl(ERINNERUNG_STUNDEN.map((n) => [n, `${n} Stunden vorher`]), r.stundenVorher ?? 24))
const el = abschnitt('Erinnerung', null,
hakenFeld('erinnerung.anbieten', anbieten, 'Gästen eine E-Mail-Erinnerung anbieten', 'Gäste entscheiden beim Buchen selbst (der Haken ist nie vorangekreuzt).'),
feld('erinnerung.stundenVorher', 'Die Erinnerung kommt', stunden))
return { el, lese(entwurf) { entwurf.erinnerung = { ...entwurf.erinnerung, anbieten: anbieten.checked, stundenVorher: Number(stunden.value) } } }
}
/* --------------------------------------------------------- Stornobedingungen --- */
function storno(ctx, e, fristFeld) {
const s = e.storno || {}
const textfeld = h('textarea', { rows: '6', maxlength: String(MAX.storno) }, s.text ?? '')
// das Muster setzt die Frist ein, die im Feld „Selbst absagen bis“ steht (auch ungespeichert)
const muster = h('blockquote', { class: 'tm-muster-text' }, stornoMuster(e.regeln?.absageBisStd))
fristFeld.addEventListener('input', () => { muster.textContent = stornoMuster(ganzzahl(fristFeld.value)) })
const einfuegen = ctx.nurLesen ? null : h('button', {
type: 'button', class: 'kb-btn-outline', 'data-aktion': 'muster-uebernehmen',
onclick: () => { textfeld.value = muster.textContent; textfeld.focus() },
}, 'Muster in das Textfeld übernehmen')
const el = abschnitt('Stornobedingungen',
'Gäste müssen diesen Text vor dem Buchen bestätigen. Lässt du das Feld leer, gibt es keine Stornobedingungen und keinen Haken dazu.',
feld('storno.text', 'Dein Text', textfeld, s.version ? `Aktuelle Fassung: ${s.version}. Ändert sich der Text, bekommt er beim Speichern eine neue Fassung, und Gäste stimmen immer der Fassung zu, die sie gesehen haben.` : 'Beim ersten Speichern bekommt der Text eine Fassungsnummer.'),
h('div', { class: 'tm-muster', role: 'group', 'aria-label': 'Mustertext' },
h('p', null, h('span', { class: 'tm-pille tm-pille-wait' }, 'Muster'), ' Ein Vorschlag, kein fertiger Rechtstext. Prüfe ihn und passe Frist, Prozentsatz und Anrede an deinen Betrieb an. Er ist in der förmlichen Anrede geschrieben. Das gesetzliche Widerrufsrecht deiner privaten Gäste bleibt unberührt.'),
muster,
einfuegen,
einfuegen ? h('p', { class: 'tm-hinweis' }, 'Das ersetzt den Text im Feld. Gespeichert wird erst mit „Einstellungen speichern“.') : null))
return { el, lese(entwurf) { entwurf.storno = { ...entwurf.storno, text: textfeld.value } } }
}
/* ------------------------------------------------------------- Rechtliches --- */
function rechtliches(e) {
const r = e.recht || {}
const gaeste = h('select', null, wahl([['verbraucher', 'Privatpersonen'], ['unternehmer', 'Unternehmen'], ['gemischt', 'Beides']], r.gaesteSind ?? 'verbraucher'))
const freizeit = h('input', { type: 'checkbox', checked: r.freizeit === true })
const el = abschnitt('Rechtliches', null,
h('div', { class: 'tm-box tm-box-info', role: 'note' }, h('span', null,
h('strong', null, 'Der Widerruf schlägt die Stornofrist. '),
'Privatpersonen können einen verbindlich gebuchten Termin bis 14 Tage nach der Buchung widerrufen, auch wenn deine kostenlose Stornofrist schon vorbei ist. Die Widerrufsbelehrung zeigt der Terminplaner deinen Gästen automatisch.')),
feld('recht.gaesteSind', 'Deine Gäste sind', gaeste, 'Bei „Unternehmen“ gibt es kein gesetzliches Widerrufsrecht.'),
hakenFeld('recht.freizeit', freizeit, 'Meine Leistungen sind Freizeitangebote mit festem Termin', 'Zum Beispiel ein Kurs oder ein Workshop. Dann gibt es kein gesetzliches Widerrufsrecht (§ 312g Abs. 2 Nr. 9 BGB). Setze das Häkchen nur, wenn du dir sicher bist oder dich bei uns rückversichert hast.'))
return { el, lese(entwurf) { entwurf.recht = { ...entwurf.recht, gaesteSind: gaeste.value, freizeit: freizeit.checked } } }
}
/* ----------------------------------------------------------------- Bezahlung --- */
function zahlung(e) {
const z = e.zahlung || {}
const arten = ZAHLARTEN.map(([key, name]) => ({ key, name, input: h('input', { type: 'checkbox', checked: (z.vorOrtArten || []).includes(key) }) }))
const el = abschnitt('Bezahlung', 'Bezahlt wird bei dir vor Ort. Welche Zahlarten nimmst du an? Gäste sehen das vor der Buchung.',
h('div', { class: 'tm-optionen', role: 'radiogroup', 'aria-label': 'Wo wird bezahlt?' },
h('label', { class: 'tm-haken' }, h('input', { type: 'radio', name: 'tm-zahlmodus', value: 'vorort', checked: true }), h('span', null, 'Vor Ort bezahlen')),
h('label', { class: 'tm-haken tm-aus' }, h('input', { type: 'radio', name: 'tm-zahlmodus', value: 'online', disabled: true }), h('span', null, 'Online bezahlen ', h('span', { class: 'tm-pille tm-pille-off' }, 'kommt bald')))),
h('fieldset', { class: 'tm-gruppe-felder', 'data-feld': 'zahlung.vorOrtArten', tabindex: '-1' },
h('legend', { class: 'tm-label' }, 'Zahlarten vor Ort'),
arten.map(({ name, input }) => h('label', { class: 'tm-haken' }, input, h('span', null, name))),
h('p', { class: 'tm-fehler', 'data-fehler': 'zahlung.vorOrtArten', role: 'alert' })))
return { el, lese(entwurf) { entwurf.zahlung = { modus: 'vorort', vorOrtArten: arten.filter(({ input }) => input.checked).map(({ key }) => key) } } }
}
/* ------------------------------------------------------------ Aufbewahrung --- */
function aufbewahrung(e) {
const tage = zahlenFeld('aufbewahrungTage', 'Aufbewahrung (Tage)', e.aufbewahrungTage, {
min: 14, max: 365,
hinweis: 'So lange nach dem Termin bleiben erledigte Termine und Termine, zu denen Gäste nicht erschienen sind, mit allen Angaben gespeichert. Danach löschen wir sie. Abgesagte Termine löschen wir nach 30 Tagen. 14 bis 365.',
})
return { el: abschnitt('Aufbewahrung', null, tage.el), lese(entwurf, fehler) { entwurf.aufbewahrungTage = liesZahl(fehler, 'aufbewahrungTage', tage.input) } }
}
/* ------------------------------------------------------------ Export, Status --- */
function exportUndStatus(ctx) {
const seit = aktivSeitText(ctx.daten.aktivSeit)
const abgeschaltet = ctx.daten.state === 'disabled'
return abschnitt('Export und Status', null,
h('p', null, exportLink(ctx.projectId, 'Alle Termine als CSV-Datei herunterladen'), ' (öffnet sich in Excel und Numbers).'),
h('p', { class: 'tm-status' },
h('span', { class: `tm-pille tm-pille-${abgeschaltet ? 'off' : 'ok'}` }, abgeschaltet ? 'Abgeschaltet' : 'Aktiv'),
seit ? ` ${seit}` : ''))
}