Terminplaner: Portal-Ansicht – Nachbesserungen (2/2)
- Annehmen mit Rueckfrage in der Seite (schliesst den Vertrag, schickt G02) - Offene Anfragen: alle bis Ende des Buchungszeitraums (Fenster zu je 62 Tagen, hoechstens 6 Aufrufe), passend zur Zahl auf Kachel und Banner - Meldung ins Bild geholt, Fehler mit Fokus; einzige Live-Region - Netzwerkfehler auf Deutsch; Antwort einer frueheren Website erscheint nie unter der neuen (Zaehler je Aufruf); Datumssprung nur mit echtem Tag - Ende um Mitternacht als 24:00, Statusfilter nach Eintragen auf Alle, abgeschaltet ohne Einstellung mit Export-Link, dunkles Datumsfeld - Playwright: Zeitzone New York/Berlin, ferne Anfragen, Projektwechsel, Meldung im Bild, Datumssprung, abgeschaltet ohne Einstellung Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
9c07f6a8c5
commit
650919b022
10 files changed
+410
-73
No files matched your search
@@ -140,6 +140,8 @@ function analyseTile(projects) {
|
||||
(termine.js) wird von app.js geladen; hier steht nur die Kachel, damit funktionen.js keinen weiteren Import braucht. */
|
||||
const TERMINE_TEXT = 'Deine Kundschaft bucht Termine direkt über deine Website.'
|
||||
const termineOf = (project) => project.features?.termine || { state: 'off' }
|
||||
// Doppelt zu anfragenText in termine-view.js: funktionen.js darf dort nichts importieren (die Kachel testet test/funktionenTiles.test.js
|
||||
// mit einem umgebogenen Import). Aenderungen an beiden Stellen machen.
|
||||
const anfragenText = (n) => (n === 1 ? '1 Anfrage wartet' : `${n} Anfragen warten`)
|
||||
|
||||
function pickTermineProject(projects) {
|
||||
|
||||
@@ -24,14 +24,9 @@ export function schliesseFrage(t, fokus = false) {
|
||||
|
||||
export function starteAktion(t, li, b, art, knopf) {
|
||||
t.ctx.meldung('')
|
||||
if (art === 'annehmen') {
|
||||
schliesseFrage(t)
|
||||
fuehreAus(t, b, { aktion: 'annehmen' }, b.gastEmail ? `Anfrage angenommen. ${b.gastName} bekommt eine Bestätigung per E-Mail.` : 'Anfrage angenommen.', null, knopf)
|
||||
return
|
||||
}
|
||||
schliesseFrage(t)
|
||||
const zu = () => schliesseFrage(t, true)
|
||||
const fragen = { ablehnen: frageAblehnen, absagen: frageAbsagen, verschieben: frageVerschieben, nichterschienen: frageNichtErschienen, anonymisieren: frageAnonymisieren, entsperren: frageEntsperren }
|
||||
const fragen = { annehmen: frageAnnehmen, ablehnen: frageAblehnen, absagen: frageAbsagen, verschieben: frageVerschieben, nichterschienen: frageNichtErschienen, anonymisieren: frageAnonymisieren, entsperren: frageEntsperren }
|
||||
const box = fragen[art](t, b, zu)
|
||||
li.querySelector('[data-frage]').replaceChildren(box)
|
||||
li.classList.add('tm-offen')
|
||||
@@ -80,6 +75,20 @@ function knopfZeile(bestaetigen, zu) {
|
||||
return { ok, zeile: h('div', { class: 'tm-frage-knoepfe' }, ok, h('button', { type: 'button', class: 'kb-btn-outline', onclick: zu }, 'Abbrechen')) }
|
||||
}
|
||||
|
||||
// Annehmen schliesst den Vertrag und verschickt die Bestaetigung (G02): wie jede Aktion erst nach einer Rueckfrage in der Seite
|
||||
function frageAnnehmen(t, b, zu) {
|
||||
const { ok, zeile } = knopfZeile('Annehmen', zu)
|
||||
const form = frage('Anfrage annehmen?',
|
||||
h('p', null, b.gastEmail
|
||||
? `${b.gastName} bekommt eine Bestätigung per E-Mail. Mit dem Annehmen kommt der Termin zustande.`
|
||||
: 'Der Gast bekommt keine E-Mail, weil keine Adresse hinterlegt ist. Mit dem Annehmen kommt der Termin zustande.'),
|
||||
allgemeinerFehler(),
|
||||
zeile)
|
||||
const erfolg = b.gastEmail ? `Anfrage angenommen. ${b.gastName} bekommt eine Bestätigung per E-Mail.` : 'Anfrage angenommen.'
|
||||
form.addEventListener('submit', () => fuehreAus(t, b, { aktion: 'annehmen' }, erfolg, form, ok))
|
||||
return form
|
||||
}
|
||||
|
||||
function frageAblehnen(t, b, zu) {
|
||||
const nachricht = h('textarea', { rows: '3', maxlength: String(MAX.nachricht) })
|
||||
const { ok, zeile } = knopfZeile('Anfrage ablehnen', zu)
|
||||
|
||||
+12
-6
@@ -16,12 +16,18 @@ export function h(tag, attrs, ...kinder) {
|
||||
}
|
||||
|
||||
export async function request(path, { method = 'GET', body } = {}) {
|
||||
const response = await fetch(path, {
|
||||
method,
|
||||
credentials: 'same-origin',
|
||||
headers: body ? { 'Content-Type': 'application/json' } : {},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
})
|
||||
let response
|
||||
try {
|
||||
response = await fetch(path, {
|
||||
method,
|
||||
credentials: 'same-origin',
|
||||
headers: body ? { 'Content-Type': 'application/json' } : {},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
})
|
||||
} catch {
|
||||
// Netzwerkfehler (offline, Server nicht erreichbar): deutscher Text statt „Failed to fetch“; status 0 = nie eine Antwort bekommen
|
||||
throw Object.assign(new Error('Keine Verbindung. Bitte versuch es gleich noch einmal.'), { status: 0, fehler: {} })
|
||||
}
|
||||
const data = await response.json().catch(() => ({}))
|
||||
if (!response.ok) {
|
||||
const err = new Error(data.error || (response.status === 502 ? 'Server nicht erreichbar. Bitte kurz warten und noch einmal versuchen.' : `Fehler ${response.status}`))
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
Die Formulare kennen den Reiter nicht: sie bekommen ctx (siehe termine.js) und hooks = { startTag, erfolg(tag, text), abbrechen() }.
|
||||
erfolg wird nach dem Speichern aufgerufen (der Reiter zeigt dann den Tag und laedt die Liste neu), abbrechen schliesst das Formular. */
|
||||
|
||||
import { MAX, heuteBerlin, ressourcenWort, zeitNormal } from './termine-view.js'
|
||||
import { MAX, heuteBerlin, istTag, ressourcenWort, zeitNormal } from './termine-view.js'
|
||||
import { allgemeinerFehler, feld, h, setzeFehler, zeigeFehler } from './termine-dom.js'
|
||||
|
||||
/** Freie Uhrzeiten als Auswahl-Knoepfe: laden() holt sie fuer query() (leistung/ressource/tag oder ohne/tag), wert() ist die gewaehlte. */
|
||||
@@ -19,7 +19,7 @@ export function zeitWahl(ctx, query, { onWahl } = {}) {
|
||||
knoepfe.replaceChildren()
|
||||
fehler.textContent = ''
|
||||
const q = query()
|
||||
if (!q || !q.tag) { status.textContent = 'Wähle zuerst einen Tag.'; return }
|
||||
if (!q || !istTag(q.tag)) { status.textContent = 'Wähle zuerst einen Tag.'; return }
|
||||
status.textContent = 'Freie Zeiten werden geladen …'
|
||||
try {
|
||||
const { zeiten } = await ctx.api.get('/zeiten', q)
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
Rueckfrage stehen in termine-aktionen.js, die Formulare in termine-formulare.js. */
|
||||
|
||||
import {
|
||||
FILTER, anfragenText, bereichFuer, datumKurz, endeZeit, filterStatus, gruppiereNachTag, heuteBerlin, istSperre,
|
||||
FILTER, anfragenText, bereichFuer, datumKurz, endeZeit, fensterAufteilen, filterStatus, gruppiereNachTag, heuteBerlin, istSperre, istTag,
|
||||
moeglicheAktionen, statusLabel, statusTon, tagPlus, tagUeberschrift, verschiebeTag, zeitraumTitel,
|
||||
} from './termine-view.js'
|
||||
import { fehlerKarte, h } from './termine-dom.js'
|
||||
@@ -27,8 +27,9 @@ export function render(panel, ctx) {
|
||||
h('button', { type: 'button', class: 'kb-btn-outline tm-heute', onclick: () => setze(t, { tag: heuteBerlin() }) }, 'Heute'),
|
||||
h('button', { type: 'button', class: 'tm-pfeil', 'aria-label': 'Weiter', onclick: () => setze(t, { tag: verschiebeTag(t.auswahl.ansicht, s.tag, 1) }) }, h('span', { 'aria-hidden': 'true' }, '›')))
|
||||
t.datumFeld = h('input', { type: 'date', id: 'tm-sprung', 'aria-label': 'Zu einem Datum springen' })
|
||||
t.datumFeld.addEventListener('change', () => { if (t.datumFeld.value) setze(t, { tag: t.datumFeld.value }) })
|
||||
t.titel = h('h3', { class: 'tm-zeitraum', 'aria-live': 'polite' })
|
||||
// nur ein echter Tag springt an (ein Datumsfeld mit fuenfstelligem Jahr liefert anderes und wuerde die Berechnung der Ansicht festfahren)
|
||||
t.datumFeld.addEventListener('change', () => { if (istTag(t.datumFeld.value)) setze(t, { tag: t.datumFeld.value }) })
|
||||
t.titel = h('h3', { class: 'tm-zeitraum' })
|
||||
const filterKnoepfe = FILTER.map((f) => h('button', { type: 'button', class: 'tm-chip', 'data-filter': f.key, onclick: () => setze(t, { filter: f.key }) }, f.label))
|
||||
t.filterBox = h('div', { class: 'tm-filter', role: 'group', 'aria-label': 'Nach Status filtern' }, filterKnoepfe)
|
||||
t.ansichtBox = h('div', { class: 'tm-segment', role: 'group', 'aria-label': 'Ansicht' }, ansichtKnoepfe)
|
||||
@@ -36,9 +37,9 @@ export function render(panel, ctx) {
|
||||
h('button', { type: 'button', class: 'kb-btn-primary', 'data-werkzeug': 'manuell', onclick: () => oeffneFormular(t, 'manuell') }, '+ Termin eintragen'),
|
||||
h('button', { type: 'button', class: 'kb-btn-outline', 'data-werkzeug': 'sperre', onclick: () => oeffneFormular(t, 'sperre') }, 'Zeit sperren'))
|
||||
t.neuBtn = h('button', { type: 'button', class: 'kb-btn-outline', onclick: () => ladeListe(t, { auchZahl: true }) }, 'Aktualisieren')
|
||||
t.anfragenBox = h('div', { class: 'tm-box tm-box-info', role: 'status', hidden: true })
|
||||
t.anfragenBox = h('div', { class: 'tm-box tm-box-info', hidden: true })
|
||||
t.formularBox = h('div', { class: 'tm-formular-box' })
|
||||
t.liste = h('div', { class: 'tm-liste', 'aria-live': 'polite' })
|
||||
t.liste = h('div', { class: 'tm-liste' })
|
||||
|
||||
panel.replaceChildren(
|
||||
h('div', { class: 'tm-leiste' }, t.ansichtBox, t.nav, h('label', { class: 'tm-sprung', for: 'tm-sprung' }, h('span', null, 'Datum'), t.datumFeld), t.zurueck),
|
||||
@@ -91,16 +92,19 @@ async function ladeListe(t, { auchZahl = false } = {}) {
|
||||
const { s } = ctx
|
||||
const nr = ++t.nr
|
||||
const anfragen = t.auswahl.ansicht === 'anfragen'
|
||||
const { von, bis } = bereichFuer(t.auswahl.ansicht, s.tag, heuteBerlin())
|
||||
// Offene Anfragen: alle bis zum Ende des Buchungszeitraums; die Schnittstelle liefert hoechstens 62 Tage je Aufruf, darum mehrere Fenster
|
||||
const { von, bis } = bereichFuer(t.auswahl.ansicht, s.tag, heuteBerlin(), ctx.daten.einstellung?.regeln?.horizontTage)
|
||||
const fenster = anfragen ? fensterAufteilen(von, bis) : [{ von, bis }]
|
||||
const status = anfragen ? 'angefragt' : filterStatus(t.auswahl.filter)
|
||||
t.liste.setAttribute('aria-busy', 'true')
|
||||
try {
|
||||
const [antwort] = await Promise.all([
|
||||
ctx.api.get('/buchungen', { von, bis, ...(status ? { status } : {}) }),
|
||||
const [antworten] = await Promise.all([
|
||||
Promise.all(fenster.map((f) => ctx.api.get('/buchungen', { von: f.von, bis: f.bis, ...(status ? { status } : {}) }))),
|
||||
auchZahl ? ctx.neuLaden().then(() => aktualisiereKopf(t), () => {}) : null, // die Zahl der Anfragen ist Beiwerk: ein Fehler dabei kostet nicht die Liste
|
||||
])
|
||||
if (nr !== t.nr || !ctx.aktuell() || !t.liste.isConnected) return
|
||||
t.buchungen = antwort.buchungen || []
|
||||
// ein Termin ueber die Fenstergrenze (Mitternacht) steht in beiden Antworten: nach ID zusammenfuehren
|
||||
t.buchungen = [...new Map(antworten.flatMap((a) => a.buchungen || []).map((b) => [b.id, b])).values()]
|
||||
zeichneListe(t)
|
||||
} catch (err) {
|
||||
if (nr !== t.nr || !ctx.aktuell()) return
|
||||
@@ -220,8 +224,10 @@ function schliesseFormular(t) {
|
||||
|
||||
async function nachErfolg(t, tag, text) {
|
||||
schliesseFormular(t)
|
||||
if (!t.ctx.aktuell()) return
|
||||
t.ctx.s.tag = tag
|
||||
if (t.auswahl.ansicht === 'anfragen') t.auswahl.ansicht = 'tag'
|
||||
t.auswahl.filter = 'alle' // sonst bliebe der neue Eintrag unter einem Statusfilter (z. B. „Angefragt“) unsichtbar
|
||||
aktualisiereKopf(t)
|
||||
await ladeListe(t, { auchZahl: true })
|
||||
t.ctx.meldung(text)
|
||||
|
||||
+38
-7
@@ -40,16 +40,45 @@ export function monatVon(tag) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Zeitraum fuer die Abfrage der Buchungen. ansicht: 'tag' | 'woche' | 'monat' | 'anfragen' (ab heute, so weit die
|
||||
* Schnittstelle reicht).
|
||||
* Zeitraum der Liste. ansicht: 'tag' | 'woche' | 'monat' | 'anfragen' (ab heute bis zum Ende des Buchungszeitraums horizontTage
|
||||
* der Einstellung, Vorgabe 60, hoechstens 365). Fuer 'anfragen' ist der Zeitraum laenger als ein Aufruf der Schnittstelle:
|
||||
* fensterAufteilen macht daraus die einzelnen Abfragen.
|
||||
*/
|
||||
export function bereichFuer(ansicht, tag, heute) {
|
||||
export function bereichFuer(ansicht, tag, heute, horizontTage) {
|
||||
if (ansicht === 'woche') return wocheVon(tag)
|
||||
if (ansicht === 'monat') return monatVon(tag)
|
||||
if (ansicht === 'anfragen') return { von: heute, bis: tagPlus(heute, MAX_TAGE) }
|
||||
if (ansicht === 'anfragen') {
|
||||
const horizont = Number.isInteger(horizontTage) && horizontTage >= 1 ? Math.min(horizontTage, 365) : 60
|
||||
return { von: heute, bis: tagPlus(heute, horizont) }
|
||||
}
|
||||
return { von: tag, bis: tag }
|
||||
}
|
||||
|
||||
/**
|
||||
* Zerlegt von..bis (beide Tage eingeschlossen) in lueckenlose Fenster, die die Schnittstelle einzeln annimmt:
|
||||
* bis hoechstens 62 Tage nach von. 365 Tage ab heute sind sechs Aufrufe.
|
||||
*/
|
||||
export function fensterAufteilen(von, bis) {
|
||||
const fenster = []
|
||||
for (let anfang = von; anfang <= bis; anfang = tagPlus(fenster.at(-1).bis, 1)) {
|
||||
const ende = tagPlus(anfang, MAX_TAGE)
|
||||
fenster.push({ von: anfang, bis: ende < bis ? ende : bis })
|
||||
}
|
||||
return fenster
|
||||
}
|
||||
|
||||
/** Ein echter Kalendertag 'JJJJ-MM-TT' aus den Jahren 2000 bis 2100 (ein Datumsfeld mit fuenfstelligem Jahr liefert anderes). */
|
||||
export function istTag(text) {
|
||||
const m = typeof text === 'string' ? TAG_RE.exec(text) : null
|
||||
if (!m) return false
|
||||
const jahr = Number(m[1])
|
||||
const monat = Number(m[2])
|
||||
const tag = Number(m[3])
|
||||
if (jahr < 2000 || jahr > 2100) return false
|
||||
const datum = new Date(Date.UTC(jahr, monat - 1, tag))
|
||||
return datum.getUTCFullYear() === jahr && datum.getUTCMonth() === monat - 1 && datum.getUTCDate() === tag
|
||||
}
|
||||
|
||||
/** Naechster (richtung 1) oder vorheriger (-1) Zeitraum: Tag +-1, Woche +-7, Monat auf den 1. des Nachbarmonats. */
|
||||
export function verschiebeTag(ansicht, tag, richtung) {
|
||||
if (ansicht === 'woche') return tagPlus(tag, 7 * richtung)
|
||||
@@ -100,10 +129,12 @@ export function zeitraumTitel(ansicht, tag) {
|
||||
|
||||
const ENDE_FORMAT = new Intl.DateTimeFormat('de-DE', { timeZone: 'Europe/Berlin', hour: '2-digit', minute: '2-digit', hourCycle: 'h23' })
|
||||
|
||||
/** Ende des Termins als Berliner Wanduhr 'HH:MM' (aus endsAt); '' wenn kein Zeitpunkt. */
|
||||
/** Ende des Termins als Berliner Wanduhr 'HH:MM' (aus endsAt), Mitternacht als '24:00' (22:00–24:00); '' wenn kein Zeitpunkt. */
|
||||
export function endeZeit(buchung) {
|
||||
const ende = new Date(buchung?.endsAt)
|
||||
return Number.isNaN(ende.getTime()) ? '' : ENDE_FORMAT.format(ende)
|
||||
if (Number.isNaN(ende.getTime())) return ''
|
||||
const zeit = ENDE_FORMAT.format(ende)
|
||||
return zeit === '00:00' ? '24:00' : zeit
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------- Uhrzeit --- */
|
||||
@@ -182,7 +213,7 @@ export function filterStatus(key) {
|
||||
return FILTER.find((f) => f.key === key)?.status?.join(',') ?? ''
|
||||
}
|
||||
|
||||
/** „3 Anfragen warten“ / „1 Anfrage wartet“; bei 0 oder ohne Zahl ''. */
|
||||
/** „3 Anfragen warten“ / „1 Anfrage wartet“; bei 0 oder ohne Zahl ''. Gleicher Text steht als anfragenText in funktionen.js (Kachel) – beide gemeinsam aendern. */
|
||||
export function anfragenText(n) {
|
||||
if (!Number.isInteger(n) || n < 1) return ''
|
||||
return n === 1 ? '1 Anfrage wartet' : `${n} Anfragen warten`
|
||||
|
||||
+6
-2
@@ -12,8 +12,10 @@
|
||||
.tm-box[hidden] { display: none; }
|
||||
.tm-box-warn { background: rgba(251, 191, 36, 0.12); border: 1px solid rgba(251, 191, 36, 0.45); }
|
||||
.tm-box-info { background: var(--kb-purple-soft); border: 1px solid var(--kb-purple-line); }
|
||||
.tm-meldung { margin: 0 0 16px; padding: 12px 16px; border-radius: 12px; font-size: 14px; line-height: 1.5; }
|
||||
.tm-meldung[hidden] { display: none; }
|
||||
.tm-meldung { margin: 0 0 16px; padding: 12px 16px; border-radius: 12px; font-size: 14px; line-height: 1.5; scroll-margin-top: calc(var(--kb-appbar-h, 76px) + 12px); }
|
||||
/* leer bleibt die Live-Region im Dokument (sonst liest ein Screenreader die naechste Meldung nicht vor), nimmt aber keinen Platz */
|
||||
.tm-meldung:empty { margin: 0; padding: 0; border: 0; }
|
||||
.tm-meldung:focus { outline: none; }
|
||||
.tm-meldung-ok { background: rgba(74, 222, 128, 0.1); border: 1px solid rgba(74, 222, 128, 0.4); }
|
||||
.tm-meldung-fehler { background: rgba(248, 113, 113, 0.1); border: 1px solid rgba(248, 113, 113, 0.5); color: #fecaca; }
|
||||
.tm-info p { margin: 0 0 12px; line-height: 1.55; color: var(--kb-muted); }
|
||||
@@ -43,6 +45,8 @@
|
||||
}
|
||||
.tm-pfeil:hover { background: rgba(255, 255, 255, 0.07); }
|
||||
.tm-sprung { display: inline-flex; align-items: center; gap: 8px; font-size: 13px; color: var(--kb-muted); }
|
||||
#view-termine input, #view-termine select, #view-termine textarea { color-scheme: dark; } /* Kalender und Auswahllisten der Datumsfelder dunkel */
|
||||
.tm-sprung input { padding: 8px 10px; border-radius: 12px; border: 1px solid var(--kb-border-strong); background: rgba(255, 255, 255, 0.05); color: inherit; font: inherit; font-size: 14px; }
|
||||
.tm-zeitraum { margin: 18px 0 10px; font-size: 19px; }
|
||||
.tm-filter { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 14px; }
|
||||
.tm-chip {
|
||||
|
||||
+54
-30
@@ -22,7 +22,7 @@ export async function initTermine(root, { projectId } = {}) {
|
||||
delete root.dataset.wunschProjekt
|
||||
if (wunsch) auswahl.projectId = wunsch
|
||||
const { projects = [], termine } = await request('/api/funktionen')
|
||||
const s = { root, projects, projekt: null, daten: null, tag: heuteBerlin(), nurLesen: false, buehne: null, meldungEl: null }
|
||||
const s = { root, projects, projekt: null, daten: null, tag: heuteBerlin(), nurLesen: false, lauf: 0, buehne: null, meldungEl: null }
|
||||
S = s
|
||||
if (!termine?.verfuegbar) {
|
||||
root.replaceChildren(infoKarte('Der Terminplaner ist noch nicht verfügbar', 'Sobald er für dich bereit ist, findest du ihn hier.'))
|
||||
@@ -56,32 +56,48 @@ function projektwahl(s) {
|
||||
return h('div', { class: 'fn-project kb-panel' }, h('label', { for: 'termine-project' }, 'Website'), select)
|
||||
}
|
||||
|
||||
const ladeDaten = (s) => request(`/api/termine/einstellungen?projectId=${encodeURIComponent(s.projekt.projectId)}`)
|
||||
const ladeDaten = (projectId) => request(`/api/termine/einstellungen?projectId=${encodeURIComponent(projectId)}`)
|
||||
|
||||
/** Je nach Zustand der Funktion: nicht freigeschaltet, Vorlage waehlen oder die Reiter. */
|
||||
/** Hinweis fuer abgeschaltete Funktionen: nur noch ansehen und exportieren. */
|
||||
const hinweisAbgeschaltet = (projectId) => h('div', { class: 'tm-box tm-box-warn', role: 'note' },
|
||||
'Der Terminplaner ist für diese Website abgeschaltet. Du kannst Termine nur noch ansehen und ',
|
||||
h('a', { href: exportUrl(projectId), download: 'termine.csv' }, 'exportieren'),
|
||||
'.')
|
||||
|
||||
/**
|
||||
* Je nach Zustand der Funktion: nicht freigeschaltet, Vorlage waehlen oder die Reiter. Jeder Aufruf zaehlt s.lauf hoch: die Antwort
|
||||
* einer frueheren Website (langsam, dann zur anderen gewechselt) wird verworfen und erscheint nie unter der neuen.
|
||||
*/
|
||||
async function zeigeProjekt(s) {
|
||||
const zustand = s.projekt.features?.termine?.state || 'off'
|
||||
const projekt = s.projekt
|
||||
const lauf = ++s.lauf
|
||||
const aktuell = () => S === s && s.lauf === lauf
|
||||
const zustand = projekt.features?.termine?.state || 'off'
|
||||
s.nurLesen = zustand !== 'active'
|
||||
if (zustand !== 'active' && zustand !== 'disabled') {
|
||||
s.buehne.replaceChildren(infoKarte('Der Terminplaner ist für diese Website noch nicht freigeschaltet', 'Wir schalten den Terminplaner für dich frei.', { chat: true }))
|
||||
return
|
||||
}
|
||||
s.buehne.replaceChildren(h('p', { class: 'kb-empty' }, 'Lade…'))
|
||||
let daten
|
||||
try {
|
||||
s.daten = await ladeDaten(s)
|
||||
daten = await ladeDaten(projekt.projectId)
|
||||
} catch (err) {
|
||||
if (S === s) s.buehne.replaceChildren(fehlerKarte(err.message, () => zeigeProjekt(s)))
|
||||
if (aktuell()) s.buehne.replaceChildren(fehlerKarte(err.message, () => zeigeProjekt(s)))
|
||||
return
|
||||
}
|
||||
if (S !== s) return
|
||||
if (s.daten.einstellung) zeigeReiter(s)
|
||||
else if (s.nurLesen) s.buehne.replaceChildren(infoKarte('Der Terminplaner ist für diese Website abgeschaltet', 'Es wurden keine Termine eingerichtet.', { chat: true }))
|
||||
else zeigeVorlagen(s)
|
||||
if (!aktuell()) return
|
||||
s.daten = daten
|
||||
if (daten.einstellung) zeigeReiter(s, { projekt, aktuell })
|
||||
else if (s.nurLesen) {
|
||||
s.buehne.replaceChildren(
|
||||
hinweisAbgeschaltet(projekt.projectId),
|
||||
infoKarte('Keine Termine eingerichtet', 'Für diese Website wurden keine Einstellungen und Termine angelegt.', { chat: true }))
|
||||
} else zeigeVorlagen(s, { projekt, aktuell })
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------- Vorlage wählen --- */
|
||||
function zeigeVorlagen(s) {
|
||||
const projektId = s.projekt.projectId
|
||||
function zeigeVorlagen(s, { projekt, aktuell }) {
|
||||
const fehler = h('p', { class: 'tm-fehler', role: 'alert' })
|
||||
const start = h('button', { type: 'button', class: 'kb-btn-primary', disabled: true }, 'Mit dieser Vorlage starten')
|
||||
let gewaehlt = null
|
||||
@@ -95,10 +111,11 @@ function zeigeVorlagen(s) {
|
||||
start.disabled = true
|
||||
fehler.textContent = ''
|
||||
try {
|
||||
await request('/api/termine/einstellungen/vorlage', { method: 'POST', body: { projectId: projektId, vorlage: gewaehlt.key } })
|
||||
s.daten = await ladeDaten(s)
|
||||
if (S !== s) return
|
||||
zeigeReiter(s)
|
||||
await request('/api/termine/einstellungen/vorlage', { method: 'POST', body: { projectId: projekt.projectId, vorlage: gewaehlt.key } })
|
||||
const daten = await ladeDaten(projekt.projectId)
|
||||
if (!aktuell()) return
|
||||
s.daten = daten
|
||||
zeigeReiter(s, { projekt, aktuell })
|
||||
zeigeMeldung(s, `Die Vorlage „${gewaehlt.name}“ ist angelegt. Alles darin ist ein Vorschlag, den du anpassen kannst.`)
|
||||
} catch (err) {
|
||||
fehler.textContent = err.message
|
||||
@@ -126,27 +143,34 @@ const REITER = [
|
||||
{ key: 'einstellungen', label: 'Einstellungen', render: platzhalter('Einstellungen', 'Hier ändern wir bald Betriebsangaben, Regeln und Texte für deine Gäste.') },
|
||||
]
|
||||
|
||||
/** Meldung ueber der Reiterleiste: art 'ok' (Standard) oder 'fehler'; leerer Text blendet sie aus. */
|
||||
/**
|
||||
* Meldung ueber der Reiterleiste: art 'ok' (Standard) oder 'fehler'; leerer Text blendet sie aus. Die Meldung ist die einzige Live-Region
|
||||
* der Ansicht. Sie erscheint dort, wo man hinschaut: liegt sie ausserhalb des Bildes, wird sie hingescrollt (block: 'nearest'), eine
|
||||
* Fehlermeldung bekommt zusaetzlich den Fokus.
|
||||
*/
|
||||
function zeigeMeldung(s, text, art = 'ok') {
|
||||
const el = s.meldungEl
|
||||
if (!el) return
|
||||
el.textContent = text || ''
|
||||
el.hidden = !text
|
||||
el.className = `tm-meldung tm-meldung-${art}`
|
||||
el.setAttribute('role', art === 'fehler' ? 'alert' : 'status')
|
||||
el.setAttribute('aria-live', art === 'fehler' ? 'assertive' : 'polite')
|
||||
if (!text) return
|
||||
if (art === 'fehler') el.focus({ preventScroll: true })
|
||||
el.scrollIntoView({ block: 'nearest' })
|
||||
}
|
||||
|
||||
function zeigeReiter(s) {
|
||||
const projectId = s.projekt.projectId
|
||||
function zeigeReiter(s, { projekt, aktuell }) {
|
||||
const projectId = projekt.projectId
|
||||
const nurLesen = s.nurLesen
|
||||
s.meldungEl = h('div', { class: 'tm-meldung', hidden: true })
|
||||
s.meldungEl = h('div', { class: 'tm-meldung', role: 'status', 'aria-live': 'polite', tabindex: '-1' })
|
||||
const ctx = {
|
||||
s,
|
||||
projectId,
|
||||
daten: s.daten,
|
||||
nurLesen,
|
||||
auswahl,
|
||||
aktuell: () => S === s,
|
||||
aktuell,
|
||||
api: {
|
||||
get: (pfad, query = {}) => request(`/api/termine${pfad}?${new URLSearchParams({ projectId, ...query })}`),
|
||||
send: (method, pfad, body = {}) => (method === 'DELETE'
|
||||
@@ -156,18 +180,18 @@ function zeigeReiter(s) {
|
||||
meldung: (text, art) => zeigeMeldung(s, text, art),
|
||||
zumReiter: (key) => waehleReiter(key),
|
||||
neuLaden: async () => {
|
||||
s.daten = await ladeDaten(s)
|
||||
ctx.daten = s.daten
|
||||
return s.daten
|
||||
const daten = await ladeDaten(projectId)
|
||||
if (aktuell()) {
|
||||
s.daten = daten
|
||||
ctx.daten = daten
|
||||
}
|
||||
return daten
|
||||
},
|
||||
}
|
||||
|
||||
const hinweise = []
|
||||
if (nurLesen) {
|
||||
hinweise.push(h('div', { class: 'tm-box tm-box-warn', role: 'note' },
|
||||
'Der Terminplaner ist für diese Website abgeschaltet. Du kannst Termine nur noch ansehen und ',
|
||||
h('a', { href: exportUrl(projectId), download: 'termine.csv' }, 'exportieren'),
|
||||
'.'))
|
||||
hinweise.push(hinweisAbgeschaltet(projectId))
|
||||
} else if ((s.daten.fehlt || []).length) {
|
||||
hinweise.push(h('div', { class: 'tm-box tm-box-warn', role: 'note' },
|
||||
h('span', null, `Noch nicht buchbar: Es fehlt ${fehltText(s.daten.fehlt) || 'eine Angabe zu deinem Betrieb'}. Solange das fehlt, können Gäste über deine Website nicht buchen.`),
|
||||
@@ -202,7 +226,7 @@ function zeigeReiter(s) {
|
||||
panel.replaceChildren()
|
||||
Promise.resolve()
|
||||
.then(() => reiter.render(panel, ctx))
|
||||
.catch((err) => { if (S === s) panel.replaceChildren(fehlerKarte(err.message, () => waehleReiter(reiter.key))) })
|
||||
.catch((err) => { if (aktuell()) panel.replaceChildren(fehlerKarte(err.message, () => waehleReiter(reiter.key))) })
|
||||
}
|
||||
|
||||
s.buehne.replaceChildren(...hinweise, s.meldungEl, leiste, panel)
|
||||
|
||||
Reference in new issue
Block a user