diff --git a/public/termin.css b/public/termin.css index 6e63a87..64540fc 100644 --- a/public/termin.css +++ b/public/termin.css @@ -40,7 +40,6 @@ body { .t-karte { background: var(--t-karte); border: 1px solid var(--t-rand); border-radius: var(--t-radius); padding: 22px 20px; } .t-titel { margin: 0 0 .75rem; font-size: 1.45rem; line-height: 1.25; font-weight: 700; } -.t-titel:focus, .t-titel:focus-visible { outline: none; } /* nur zum Anspringen per Skript, kein Bedienelement */ .t-karte p { margin: 0 0 .85rem; } .t-laden { opacity: .8; } @@ -76,9 +75,9 @@ body { .t-btn-sek:hover { background: var(--t-weich); filter: none; } .t-btn-warn { background: #b91c1c; color: #ffffff; border-color: #b91c1c; } .t-btn[disabled] { opacity: .6; cursor: default; } -.t-link { font: inherit; color: inherit; background: none; border: 0; padding: .35rem 0; min-height: 44px; text-decoration: underline; cursor: pointer; } -.t-weitere { margin: 1.1rem 0 0; } .t-karte :focus-visible, .t-fuss :focus-visible { outline: 3px solid var(--t-text); outline-offset: 2px; } +/* Die Ueberschrift wird nur per Skript angesprungen (tabindex -1), sie ist kein Bedienelement: kein Rahmen */ +.t-karte .t-titel:focus, .t-karte .t-titel:focus-visible { outline: none; } /* Kontakt (abgelaufen) */ .t-kontakt { margin: 0 0 .85rem; padding: 0; list-style: none; display: grid; gap: .4rem; } @@ -127,7 +126,7 @@ body { .t-karte { padding: 18px 16px; } .t-zeile { grid-template-columns: 1fr; gap: 0; } .t-zeile dd { margin-bottom: .35rem; } - .t-nav { flex-direction: column-reverse; align-items: stretch; } + .t-nav { flex-direction: column; align-items: stretch; } /* Reihenfolge im DOM = Reihenfolge auf dem Bildschirm */ .t-nav .t-btn { width: 100%; } .t-raster { border-spacing: 2px; } .t-tag { height: 40px; } @@ -140,7 +139,7 @@ body { } @media print { - .t-kopf, .t-nav, .t-weitere { display: none; } + .t-kopf, .t-nav { display: none; } body { background: #fff; } .t-karte { border: 0; } } diff --git a/public/termin.html b/public/termin.html index 55dd464..bcd1c6c 100644 --- a/public/termin.html +++ b/public/termin.html @@ -27,7 +27,7 @@ diff --git a/public/termin.js b/public/termin.js index ad02d85..b648304 100644 --- a/public/termin.js +++ b/public/termin.js @@ -161,12 +161,6 @@ return m } - function absaetze (text, cls) { - const wrap = el('div', cls || '') - String(text).split(/\n{2,}/).forEach(function (a) { if (a.trim()) wrap.appendChild(el('p', '', a.trim())) }) - return wrap - } - // Zusammenfassung des Termins (alles vom Server, nur Texte) function zusammenfassung (t) { const dl = el('dl', 't-zusammen') @@ -246,11 +240,22 @@ return k.length ? fehler[k[0]] : vorgabe } + // Kopf und Fuss einer Fehleransicht: Ist die Vorschau schon geladen (der Link laeuft mitten in der Sitzung ab), bleiben + // Farbe, Anrede und Fusslinks, wie der Gast sie gesehen hat; sonst neutraler Kopf, Sie-Form, kein Betriebsfuss. + function fehlerKopf (name) { + if (S.v) { + setzeKopf(name || S.v.betrieb.name, S.v.farbe) + setzeFuss(S.v.betrieb) + return + } + S.du = false + setzeKopf(name, '') + setzeFuss(null) + } + // Link unbekannt, unvollstaendig oder ohne Hash function zeigeUngueltig (text, hinweis) { - S.du = false - setzeKopf('', '') - setzeFuss(null) + fehlerKopf('') zeichne(function () { add(titel(text)) add(el('p', '', hinweis)) @@ -260,8 +265,7 @@ // Abgelaufen: die Antwort traegt Name, Telefon und Website des Betriebs function zeigeAbgelaufen (err) { const b = err.betrieb || {} - setzeKopf(b.name, '') - setzeFuss(null) + fehlerKopf(b.name) zeichne(function () { add(titel(ersterText(err.fehler, 'Diese Reservierung ist abgelaufen.'))) add(el('p', '', T('Bitte buchen Sie Ihren Termin noch einmal oder rufen Sie uns an.', 'Bitte buche deinen Termin noch einmal oder ruf uns an.'))) @@ -318,8 +322,15 @@ function start () { const roh = window.location.hash - // Der Token steht nur im Hash (wird nie an einen Server gesendet) und verschwindet sofort aus der Adresszeile - if (roh) window.history.replaceState(null, '', window.location.pathname + window.location.search) + S.v = null + S.verschieben = null + // Der Token steht nur im Hash (wird nie an einen Server gesendet) und verschwindet sofort aus der Adresszeile; + // wirft der Browser dabei (eingeschraenkte Umgebung), laeuft die Seite trotzdem weiter + if (roh) { + try { + window.history.replaceState(null, '', window.location.pathname + window.location.search) + } catch (e) { /* Token bleibt dann in der Adresszeile, die Seite funktioniert */ } + } if (!roh) { zeigeUngueltig('Bitte öffnen Sie den Link aus Ihrer E-Mail', 'Diese Seite wird über den Link in der E-Mail zu Ihrem Termin aufgerufen. Bitte öffnen Sie diese E-Mail und klicken Sie dort noch einmal auf den Link.') return @@ -330,8 +341,6 @@ return } S.token = m[1] - S.v = null - S.verschieben = null lade() } @@ -408,11 +417,11 @@ break case 'angefragt': ok = true - t = neu ? T('Ihre Anfrage ist eingegangen.', 'Deine Anfrage ist eingegangen.') : 'Diese Anfrage ist bereits bestätigt.' + t = neu ? T('Ihre Anfrage ist eingegangen.', 'Deine Anfrage ist eingegangen.') : T('Ihre Anfrage wurde bereits abgesendet.', 'Deine Anfrage wurde bereits abgesendet.') text = neu ? T(betrieb + ' prüft Ihre Anfrage und meldet sich bei Ihnen. Eine Eingangsbestätigung haben wir Ihnen per E-Mail geschickt.', betrieb + ' prüft deine Anfrage und meldet sich bei dir. Eine Eingangsbestätigung haben wir dir per E-Mail geschickt.') - : T('Sie müssen nichts weiter tun. ' + betrieb + ' meldet sich bei Ihnen.', 'Du musst nichts weiter tun. ' + betrieb + ' meldet sich bei dir.') + : T('Sie erhalten eine E-Mail, sobald ' + betrieb + ' sie annimmt.', 'Du erhältst eine E-Mail, sobald ' + betrieb + ' sie annimmt.') break case 'abgesagt': t = 'Dieser Termin wurde abgesagt.' @@ -465,12 +474,9 @@ const nav = el('div', 't-nav') if (a.verschieben) nav.appendChild(knopf('t-btn', 'Termin verschieben', function () { zeigeVerschieben() })) if (a.absagen) nav.appendChild(knopf('t-btn t-btn-sek', 'Termin absagen', zeigeAbsageFrage)) + // § 356a BGB: die Widerrufsfunktion ist gut sichtbar und leicht erreichbar, ein gleichrangiger Knopf in derselben Reihe + if (a.widerrufen) nav.appendChild(knopf('t-btn t-btn-sek', 'Vertrag widerrufen', zeigeWiderruf)) if (nav.childNodes.length) add(nav) - if (a.widerrufen) { - const w = el('p', 't-weitere') - w.appendChild(knopf('t-link', 'Vertrag widerrufen', zeigeWiderruf)) - add(w) - } const offen = v.status === 'bestaetigt' || v.status === 'angefragt' if (offen && !a.absagen && !a.verschieben && !a.widerrufen && !(istText(v.hinweis) && v.hinweis)) { add(el('p', 't-box', 'Dieser Termin kann online nicht mehr geändert werden.' + diff --git a/test/termineSeiteStatic.test.js b/test/termineSeiteStatic.test.js index 06882dc..ba6ef77 100644 --- a/test/termineSeiteStatic.test.js +++ b/test/termineSeiteStatic.test.js @@ -65,13 +65,32 @@ test('Pflichtbausteine der Aufgabe (Ansichten, Knopftexte, Fuss, Farbe)', () => for (const noetig of [ 'Widerruf bestätigen', 'Vertrag widerrufen', 'Termin absagen', 'Termin verschieben', 'Ja, Termin absagen', 'Nein, Termin behalten', 'bereits bestätigt', 'Eingang: ', 'eingang.datum', 'eingang.uhrzeit', 'websiteUrl', 'telefon', 'impressumUrl', 'datenschutzUrl', - 'noopener noreferrer', 'httpsUrl', 'v.knopf', 'd.anrede', 'd.farbe', '§ 356a BGB', 'role', + 'noopener noreferrer', 'httpsUrl', 'v.knopf', 'bereits abgesendet', 'd.anrede', 'd.farbe', '§ 356a BGB', 'role', ]) { assert.ok(QUELLE.includes(noetig), `${noetig} fehlt in termin.js`) } assert.ok(!/\bconfirm\(|\balert\(|\bprompt\(/.test(OHNE_KOMMENTARE), 'keine Browser-Dialoge: die Rueckfrage steht in der Seite') }) +test('Token: replaceState steht in try/catch (wirft der Browser, laeuft die Seite weiter) und vor dem ersten Aufruf', () => { + assert.match(OHNE_KOMMENTARE, /try\s*\{\s*window\.history\.replaceState\([^)]*\)\s*\}\s*catch/) + assert.ok(OHNE_KOMMENTARE.indexOf('history.replaceState') < OHNE_KOMMENTARE.indexOf('lade()', OHNE_KOMMENTARE.indexOf('function start')), 'replaceState vor lade()') +}) + +test('Widerruf ist ein gleichrangiger Knopf (§ 356a BGB), kein Textlink; kein totes absaetze()', () => { + assert.ok(OHNE_KOMMENTARE.includes("knopf('t-btn t-btn-sek', 'Vertrag widerrufen'"), 'Widerruf als sekundaerer Knopf') + assert.doesNotMatch(OHNE_KOMMENTARE, /['"\s]t-link['"\s]/, 'kein Textlink-Knopf') + assert.doesNotMatch(CSS, /\.t-link\b(?!s)/, 'kein Textlink-Knopf in termin.css') + assert.ok(!OHNE_KOMMENTARE.includes('absaetze'), 'totes absaetze() entfernt') +}) + +test('termin.css: Reihenfolge im DOM = Anzeige (kein *-reverse), Ueberschrift ohne Rahmen, Bedienelemente mit Rahmen', () => { + assert.doesNotMatch(CSS, /-reverse/, 'kein row-reverse/column-reverse: Tab-Reihenfolge muss der Anzeige entsprechen') + // die Regel fuer die Ueberschrift muss spezifischer sein als `.t-karte :focus-visible` + assert.match(CSS, /\.t-karte \.t-titel:focus-visible\s*\{[^}]*outline:\s*none/) + assert.match(CSS, /\.t-karte :focus-visible[^{]*\{[^}]*outline:\s*3px solid/) +}) + test('termin.html: noindex, kein Referrer, Stile und Skript von der eigenen Herkunft, Fuss mit Impressum WEBklar', () => { assert.match(HTML, //) assert.match(HTML, /