Files
Webklar-Kundenbereich/public/termine-tab-einstellungen.js
T
ClaudeandClaude Sonnet 5.5 747be432c5 Terminplaner TP2: Reiter-Exporte und wochenplanFeld fuer den Fragebogen
- termine-tab-einstellungen.js: die acht Abschnitte (betrieb, darstellung, regeln,
  gastFelder, erinnerung, storno, rechtliches, zahlung) sind Exporte; betrieb nimmt
  { nur, titel, hinweis } (nur die gewaehlten Felder, lese schreibt nur diese)
- termine-tab-team.js: wochenplanFeld(r, pfad) => { el, lese() => { plan } | { fehler } },
  der Editor einer Person benutzt es selbst
- termine-speichern.js: fehlt kommt aus der Antwort des Servers
- termine.js: baueCtx aus zeigeReiter herausgeloest (Verhalten gleich)
- test/support/fakeDom.js: Minimal-DOM und Fake-fetch fuer die Client-Tests

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 21:06:42 +00:00

352 lines
24 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 der Einbau-Code fuer die Website, 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 { nachSpeichern, neuerSpeicher } from './termine-speichern.js'
// Rechtliche Hinweise (Ruling R52): Wortlaut nicht aendern ohne Ruling
const WIDERRUF_HINWEIS = 'Privatpersonen können eine Buchung innerhalb von 14 Tagen nach Vertragsschluss widerrufen (bei Anfragen zählt deine Annahme). '
+ 'Das gesetzliche Widerrufsrecht geht deiner Stornoregel vor: In dieser Zeit fällt bei einem Widerruf keine Stornogebühr an, auch wenn deine Stornofrist schon abgelaufen ist. '
+ 'Liegt der Termin innerhalb dieser 14 Tage, fragt der Terminplaner deine Gäste, ob du vorher beginnen darfst. '
+ 'Belehrung und Knopf „Vertrag widerrufen“ zeigt der Terminplaner automatisch, wenn deine Gäste Privatpersonen sind, bei sofort buchbaren Leistungen ebenso wie bei Anfragen.'
const FREIZEIT_HINWEIS = 'Nur ankreuzen, wenn deine Termine Freizeitveranstaltungen zu einem festen Termin sind (§ 312g Abs. 2 Nr. 9 BGB), zum Beispiel ein einzelner Tanzabend oder ein Escape Room. '
+ 'Friseur, Kosmetik, Massage, Tattoo, Beratung, Fotoshootings und Weiterbildungen sind in der Regel keine Freizeitleistung. '
+ 'Kreuzt du es an, zeigt der Terminplaner keine Widerrufsbelehrung. Kreuzt du es zu Unrecht an, können deine Gäste noch bis zu 12 Monate und 14 Tage widerrufen. '
+ 'Im Zweifel nicht ankreuzen oder dich rechtlich beraten lassen.'
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)
const t = { ctx, zeichne, wurzel }
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 })
if (!ok || !sp.nochHier()) return
nachSpeichern(t, 'Einstellungen gespeichert.', 'form[aria-label="Einstellungen"] button[type="submit"]')
})
// einbau() liefert null (abgeschaltet oder ohne Code): replaceChildren wuerde sonst den Text "null" zeigen
wurzel.replaceChildren(...[sp.konflikt, form, einbau(ctx), exportUndStatus(ctx)].filter(Boolean))
}
zeichne()
}
/* ----------------------------------------------------------------- Betrieb --- */
const textFeld = (wert, max, extra) => h('input', { type: 'text', maxlength: max, autocomplete: 'off', value: wert ?? '', ...extra })
const urlFeld = (wert) => textFeld(wert, MAX.url, { inputmode: 'url', placeholder: 'https://' })
/** Die Angaben zum Betrieb in der Reihenfolge der Seite: Name, Mailadresse des Betriebs, Telefon, Anschrift, Kleinunternehmer, vier Links. */
const BETRIEB_FELDER = {
name: { eingabe: (b) => textFeld(b.name, MAX.betriebName), zeige: (i) => feld('betrieb.name', 'Name des Betriebs', i) },
email: {
eingabe: (b) => h('input', { type: 'email', maxlength: '254', autocomplete: 'off', value: b.email ?? '' }),
zeige: (i) => feld('betrieb.email', 'E-Mail des Betriebs', i, 'Hierhin schicken wir dir Nachrichten zu Terminen. Gäste können auf ihre Mails hier antworten.'),
},
telefon: {
eingabe: (b) => textFeld(b.telefon, MAX.telefon),
zeige: (i) => feld('betrieb.telefon', 'Telefon (Pflicht)', i, 'Gäste sehen die Nummer als Ausweichweg und in ihren Mails.'),
},
adresse: {
eingabe: (b) => textFeld(b.adresse, MAX.adresse),
zeige: (i) => feld('betrieb.adresse', 'Anschrift', i, 'Pflicht, wenn Privatpersonen bei dir buchen oder anfragen: Die Anschrift steht in der Widerrufsbelehrung.'),
},
kleinunternehmer: {
eingabe: (b) => h('input', { type: 'checkbox', checked: b.kleinunternehmer === true }),
zeige: (i) => hakenFeld('betrieb.kleinunternehmer', i, 'Ich bin Kleinunternehmer nach § 19 UStG', 'Dann steht bei den Preisen „kein Ausweis der USt nach § 19 UStG“ statt „inkl. USt“.'),
},
impressumUrl: { eingabe: (b) => urlFeld(b.impressumUrl), zeige: (i) => feld('betrieb.impressumUrl', 'Link zum Impressum', i, 'Vollständige Adresse mit https://') },
datenschutzUrl: { eingabe: (b) => urlFeld(b.datenschutzUrl), zeige: (i) => feld('betrieb.datenschutzUrl', 'Link zur Datenschutzerklärung', i, 'Vollständige Adresse mit https://') },
agbUrl: { eingabe: (b) => urlFeld(b.agbUrl), zeige: (i) => feld('betrieb.agbUrl', 'Link zu deinen AGB (optional)', i, 'Vollständige Adresse mit https://') },
websiteUrl: { eingabe: (b) => urlFeld(b.websiteUrl), zeige: (i) => feld('betrieb.websiteUrl', 'Link zu deiner Website', i, 'Vollständige Adresse mit https://') },
}
/** Wie die Felder auf der Seite zusammenstehen: Zeilen im Raster, das Ankreuzfeld steht allein. */
const BETRIEB_ZEILEN = [
{ felder: ['name', 'email'], raster: true },
{ felder: ['telefon', 'adresse'], raster: true },
{ felder: ['kleinunternehmer'], raster: false },
{ felder: ['impressumUrl', 'datenschutzUrl', 'agbUrl', 'websiteUrl'], raster: true },
]
const BETRIEB_TITEL = 'Dein Betrieb'
const BETRIEB_HINWEIS = 'Diese Angaben sehen deine Gäste beim Buchen und in ihren E-Mails. Die meisten haben wir aus deinem Kundenkonto übernommen.'
/**
* Abschnitt „Dein Betrieb“. Ohne Optionen alle neun Felder wie bisher. Der Fragebogen zeigt in einem Schritt nur einen Teil:
* nur = Teilmenge der Felder (name, email, telefon, adresse, kleinunternehmer, impressumUrl, datenschutzUrl, agbUrl, websiteUrl); es entstehen nur
* diese Felder (in der Reihenfolge der Seite) und lese schreibt nur diese in den Entwurf. titel und hinweis ersetzen Ueberschrift und Erklaersatz
* (hinweis: null laesst ihn weg). lese(entwurf) schreibt in entwurf.betrieb und gibt die gelesenen Felder zurueck (nur die gezeigten).
* Der Hinweis auf fehlende Pflichtangaben nennt mit nur nur Angaben, die dabei zu sehen sind.
*/
export function betrieb(ctx, e, { nur, titel = BETRIEB_TITEL, hinweis = BETRIEB_HINWEIS } = {}) {
const b = e.betrieb || {}
for (const name of nur || []) if (!Object.hasOwn(BETRIEB_FELDER, name)) throw new Error(`Unbekanntes Feld im Betrieb: ${name}`)
const gezeigt = Object.keys(BETRIEB_FELDER).filter((name) => !nur || nur.includes(name))
const f = Object.fromEntries(gezeigt.map((name) => [name, BETRIEB_FELDER[name].eingabe(b)]))
const fehlt = (ctx.daten.fehlt || []).filter((pfad) => !nur || gezeigt.includes(pfad.replace(/^betrieb\./, '')))
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 zeilen = BETRIEB_ZEILEN
.map(({ felder, raster }) => {
const els = felder.filter((name) => f[name]).map((name) => BETRIEB_FELDER[name].zeige(f[name]))
if (!els.length) return null
return raster ? h('div', { class: 'tm-raster' }, els) : els
})
.filter(Boolean)
const el = abschnitt(titel, hinweis, warnung, ...zeilen)
return {
el,
lese(entwurf = {}) {
const gelesen = Object.fromEntries(gezeigt.map((name) => [name, name === 'kleinunternehmer' ? f[name].checked : f[name].value]))
entwurf.betrieb = { ...entwurf.betrieb, ...gelesen }
return gelesen
},
}
}
/* -------------------------------------------------------- Anrede und Farbe --- */
export 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 --- */
export 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 --- */
export 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 --- */
export 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 --- */
export 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 --- */
export 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, WIDERRUF_HINWEIS)),
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', FREIZEIT_HINWEIS))
return { el, lese(entwurf) { entwurf.recht = { ...entwurf.recht, gaesteSind: gaeste.value, freizeit: freizeit.checked } } }
}
/* ----------------------------------------------------------------- Bezahlung --- */
export 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) } }
}
/* ------------------------------------------------------ Einbau-Code (Website) --- */
/** Einbau-Code aus GET /einstellungen (einbau.snippet, vom Server gebaut, nur lesbar) mit Kopierknopf; gleiches Muster wie der Kalender-Link. */
function einbau(ctx) {
const snippet = ctx.daten.einbau?.snippet
if (typeof snippet !== 'string' || !snippet || ctx.nurLesen) return null
const eingabe = h('textarea', { readonly: true, rows: '5', class: 'tm-code-feld', spellcheck: 'false', wrap: 'off', 'aria-label': 'Einbau-Code für deine Website' })
eingabe.value = snippet
eingabe.addEventListener('focus', () => eingabe.select())
const status = h('p', { class: 'tm-hinweis', role: 'status' })
async function kopiere() {
try {
await navigator.clipboard.writeText(eingabe.value)
status.textContent = 'Der Code ist kopiert.'
} catch {
// kein Zugriff auf die Zwischenablage (Browser, Einstellung): der Code bleibt sichtbar und markiert zum Kopieren von Hand
eingabe.focus()
eingabe.select()
status.textContent = 'Das Kopieren hat nicht geklappt. Der Code ist markiert: drücke Strg+C (Mac: Cmd+C), am Handy halte ihn gedrückt und wähle „Kopieren“.'
}
}
return abschnitt('Einbau auf deiner Website',
'Diesen Code fügst du auf deiner Website an der Stelle ein, an der Gäste Termine buchen sollen. Der Text darin bleibt stehen, solange die Buchung nicht geladen werden kann. '
+ 'Gebucht werden kann erst, wenn deine Angaben vollständig sind und die Adresse deiner Website bei deinem Projekt als Live-Adresse eingetragen ist.',
eingabe,
h('div', { class: 'tm-frage-knoepfe' }, h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'einbau-kopieren', onclick: kopiere }, 'Code kopieren')),
status)
}
/* ------------------------------------------------------------ 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}` : ''))
}