Navigation leichter: Handy-Leiste, Schwarz nur für die Hauptaktion, Verträge mit zwei Reitern, kürzeres Konto (V3, V5, V7, V8)

V3: In der Fahrstuhl-Zeile läuft unter dem aktiven Wort eine dünne Linie statt des schwarzen Blocks; das Wort wird dunkel und fett. Die weiße Overlay-Kopie bleibt im Baum, wird aber nicht mehr gezeigt.
V5: Terminplaner: Tag/Woche/Monat als hellgrauer Umschalter mit weißer Auswahl, Filter als leise Chips. „Einschalten“ in der Liste Entdecken ist ein heller Knopf; schwarz bleibt der Knopf der Bestätigungsseite.
V7: Verträge nur noch mit Abo und Rechnungen. Das Stundenkonto steht beim Wartungs-Abo als Abschnitt unter dem Abo (ohne doppelte Warnungen), #stunden springt dorthin. Der E-Mail-Speicher ist die Unterseite #speicher (Link „ändern“ in der Abo-Zeile), ohne Widerrufsstreifen wie die anderen Bestellseiten. Alte Links bleiben gültig.
V8: Konto mit vier Zeilen (Passwort, Verträge kündigen, Vertrag widerrufen, Abmelden), Datenschutz und Impressum klein darunter.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-29 17:27:30 +00:00
1 parent a270ff51da
commit 83ac903ab3
18 files changed
+152 -96

No files matched your search

+1 -1
View File
@@ -283,7 +283,7 @@ export async function zeigeFunktionen(wurzel, { route, me, nav } = {}, abh = {})
let wahl = null let wahl = null
const knopf = h('button', { const knopf = h('button', {
type: 'button', type: 'button',
class: 'kn-fn-knopf', class: 'kn-fn-knopf kn-fn-knopf--zweit', // Navigation leichter V5: jede Karte hat ihn, keiner ist die eine Hauptaktion; schwarz bleibt der Knopf der Bestätigungsseite
'data-aktion': 'einschalten', 'data-aktion': 'einschalten',
on: { on: {
click: () => { click: () => {
+2 -3
View File
@@ -1,4 +1,4 @@
/* Kundenansicht neu (P7): Reiter E-Mail in Verträge, der Zusatzspeicher, im schlanken Muster der Spec 5.6, Fassung 6. /* Kundenansicht neu (P7): Unterseite E-Mail-Speicher der Verträge (#speicher, bis „Navigation leichter“ V7 der Reiter E-Mail), der Zusatzspeicher, im schlanken Muster der Spec 5.6, Fassung 6.
Plus: Block „Was du bestellst“, eine Linkzeile zu den Regeln, der Knopf „Zahlungspflichtig bestellen“ (§ 312j BGB), darunter die Plus: Block „Was du bestellst“, eine Linkzeile zu den Regeln, der Knopf „Zahlungspflichtig bestellen“ (§ 312j BGB), darunter die
Kundenart-Zeile; kein Häkchen (der Klick auf den Knopf ist die Zustimmung). Minus: genau eine Warnzeile und der Knopf „Speichern“. Kundenart-Zeile; kein Häkchen (der Klick auf den Knopf ist die Zustimmung). Minus: genau eine Warnzeile und der Knopf „Speichern“.
Die Sätze kommen vom Server (GET /api/abo → bestelltexte). Der Server entscheidet die Kundenart selbst, der Client sendet nie kundenart. */ Die Sätze kommen vom Server (GET /api/abo → bestelltexte). Der Server entscheidet die Kundenart selbst, der Client sendet nie kundenart. */
@@ -84,8 +84,7 @@ function baueSpeicherPanel(daten, { viewAs, kundenart, notice, nachErfolg }) {
const status = [] const status = []
if (storage.pendingSetup) status.push(h('p', { class: 'kn-meldung kn-meldung-warn' }, 'Dein zusätzlicher Speicher wird eingerichtet. Das dauert in der Regel bis zum nächsten Werktag.')) if (storage.pendingSetup) status.push(h('p', { class: 'kn-meldung kn-meldung-warn' }, 'Dein zusätzlicher Speicher wird eingerichtet. Das dauert in der Regel bis zum nächsten Werktag.'))
if (pendingReduction) status.push(h('p', { class: 'kn-hinweis' }, `Geplant: ab ${fmtTagMonat(pendingReduction.effectiveAt)} ${gbOf(Number(pendingReduction.units) || 0)} GB.`)) if (pendingReduction) status.push(h('p', { class: 'kn-hinweis' }, `Geplant: ab ${fmtTagMonat(pendingReduction.effectiveAt)} ${gbOf(Number(pendingReduction.units) || 0)} GB.`))
ersetze(panel, ersetze(panel, // die Überschrift „E-Mail-Speicher“ trägt die Unterseite selbst
h('h3', null, 'E-Mail-Speicher'),
h('p', { class: 'kn-hinweis' }, `${includedGb} GB inklusive. Jedes Zusatzpaket bringt ${gbPer} GB für ${fmtEuro(unitCents / 100)} im Monat (gemäß § 19 UStG ohne Umsatzsteuer), bis zu ${maxUnits} Pakete.`), h('p', { class: 'kn-hinweis' }, `${includedGb} GB inklusive. Jedes Zusatzpaket bringt ${gbPer} GB für ${fmtEuro(unitCents / 100)} im Monat (gemäß § 19 UStG ohne Umsatzsteuer), bis zu ${maxUnits} Pakete.`),
notice ? h('p', { class: 'kn-meldung kn-meldung-ok', role: 'status' }, notice) : null, notice ? h('p', { class: 'kn-meldung kn-meldung-ok', role: 'status' }, notice) : null,
!canChange ? h('p', { class: 'kn-hinweis', id: 'storage-locked' }, `Änderungen sind derzeit nicht möglich – ${lockedReason}.`) : null, !canChange ? h('p', { class: 'kn-hinweis', id: 'storage-locked' }, `Änderungen sind derzeit nicht möglich – ${lockedReason}.`) : null,
+11 -6
View File
@@ -33,8 +33,12 @@ function gutschriftZeile(c) {
h('td', { 'data-label': 'Zeit', class: 'kn-num' }, `+${fmtStd(c.minutes)}`, h('span', { class: 'kn-zeile-sub' }, ` ${c.expired ? 'abgelaufen' : `nutzbar bis ${fmtDatum(c.expiresAt)}`}`))) h('td', { 'data-label': 'Zeit', class: 'kn-num' }, `+${fmtStd(c.minutes)}`, h('span', { class: 'kn-zeile-sub' }, ` ${c.expired ? 'abgelaufen' : `nutzbar bis ${fmtDatum(c.expiresAt)}`}`)))
} }
/** Reiter-Inhalt Stunden. daten (GET /api/abo) ist optional; ohne sie lädt die Funktion selbst. */ /**
export async function zeigeStunden(wurzel, { route, me, nav, daten } = {}) { * Stundenkonto. daten (GET /api/abo) ist optional; ohne sie lädt die Funktion selbst.
* eingebettet: Abschnitt unter dem Abo (Navigation leichter V7). Dann ohne die Warnungen und den Bedingungen-Link, die das Abo darüber schon zeigt,
* und ohne Hinweis, wenn es kein Wartungs-Abo gibt (der Abschnitt steht dann gar nicht da).
*/
export async function zeigeStunden(wurzel, { route, me, nav, daten, eingebettet = false } = {}) {
const nr = (laeufe.get(wurzel) ?? 0) + 1 const nr = (laeufe.get(wurzel) ?? 0) + 1
laeufe.set(wurzel, nr) laeufe.set(wurzel, nr)
const aktuell = () => laeufe.get(wurzel) === nr const aktuell = () => laeufe.get(wurzel) === nr
@@ -46,7 +50,8 @@ export async function zeigeStunden(wurzel, { route, me, nav, daten } = {}) {
const sub = abo.subscription const sub = abo.subscription
const l = abo.ledger const l = abo.ledger
if (!sub?.active || sub.productKey !== 'wartung' || !l) { if (!sub?.active || sub.productKey !== 'wartung' || !l) {
leeren(wurzel).append(h('p', { class: K.leer }, 'Ein Stundenkonto gibt es im Wartungs-Abo.')) leeren(wurzel)
if (!eingebettet) wurzel.append(h('p', { class: K.leer }, 'Ein Stundenkonto gibt es im Wartungs-Abo.'))
return return
} }
const gekuendigt = Boolean(sub.cancelAtPeriodEnd || sub.cancelAt) const gekuendigt = Boolean(sub.cancelAtPeriodEnd || sub.cancelAt)
@@ -54,16 +59,16 @@ export async function zeigeStunden(wurzel, { route, me, nav, daten } = {}) {
const zeilen = [...(l.usage || []).map(verlaufZeile), ...(l.credits || []).slice().reverse().map(gutschriftZeile)] const zeilen = [...(l.usage || []).map(verlaufZeile), ...(l.credits || []).slice().reverse().map(gutschriftZeile)]
ersetze(wurzel, ersetze(wurzel,
...aboWarnungen(sub), ...(eingebettet ? [] : aboWarnungen(sub)),
h('div', { class: 'kn-statistik' }, h('div', { class: 'kn-statistik' },
stat(fmtStd(l.balanceMinutes), 'verfügbare Zeit'), stat(fmtStd(l.balanceMinutes), 'verfügbare Zeit'),
stat(fmtStd(l.usedMinutes), 'bisher genutzt'), stat(fmtStd(l.usedMinutes), 'bisher genutzt'),
l.nextExpiry ? stat(fmtStd(l.nextExpiry.minutes), `verfallen am ${fmtDatum(l.nextExpiry.at)}`) : null), l.nextExpiry ? stat(fmtStd(l.nextExpiry.minutes), `verfallen am ${fmtDatum(l.nextExpiry.at)}`) : null),
vorschuss ? h('p', { class: 'kn-hinweis kn-vorschuss' }, vorschuss) : null, vorschuss ? h('p', { class: 'kn-hinweis kn-vorschuss' }, vorschuss) : null,
h('p', { class: 'kn-hinweis' }, '1 Stunde pro bezahltem Monat. Angerechnet wird in 15-Minuten-Schritten, die ältesten Stunden zuerst.'), h('p', { class: 'kn-hinweis' }, '1 Stunde pro bezahltem Monat. Angerechnet wird in 15-Minuten-Schritten, die ältesten Stunden zuerst.'),
h('p', { class: 'kn-links' }, h('a', { href: sub.termsUrl || '/abo-bedingungen.html', target: '_blank', rel: 'noopener noreferrer' }, 'Leistungen & Bedingungen')), eingebettet ? null : h('p', { class: 'kn-links' }, h('a', { href: sub.termsUrl || '/abo-bedingungen.html', target: '_blank', rel: 'noopener noreferrer' }, 'Leistungen & Bedingungen')),
h('section', { class: 'kn-sektion' }, h('section', { class: 'kn-sektion' },
h('h2', { class: K.abschnitt }, 'Verlauf'), h(eingebettet ? 'h3' : 'h2', { class: K.abschnitt }, 'Verlauf'),
zeilen.length zeilen.length
? h('div', { class: 'kn-tabelle' }, h('table', null, h('thead', null, h('tr', null, h('th', null, 'Datum'), h('th', null, 'Vorgang'), h('th', null, 'Zeit'))), h('tbody', null, zeilen))) ? h('div', { class: 'kn-tabelle' }, h('table', null, h('thead', null, h('tr', null, h('th', null, 'Datum'), h('th', null, 'Vorgang'), h('th', null, 'Zeit'))), h('tbody', null, zeilen)))
: h('p', { class: 'kn-hinweis' }, 'Noch keine Buchungen.'))) : h('p', { class: 'kn-hinweis' }, 'Noch keine Buchungen.')))
+28 -21
View File
@@ -1,6 +1,8 @@
/* Kundenansicht neu (P2): Bereich Verträge mit den Reitern Rechnungen, Abo, Stunden und E-Mail. Der Rahmen lädt Abo und Rechnungen /* Kundenansicht neu (P2): Bereich Verträge mit den Reitern Abo und Rechnungen (Navigation leichter V7, vorher dazu Stunden und E-Mail).
parallel (jede Quelle mit eigenem Fehlerweg), wählt den Reiter allein aus route.reiter und gibt die schon geladenen Antworten an Das Stundenkonto des Wartungs-Abos steht als Abschnitt unter dem Abo; #stunden öffnet das Abo und springt dorthin. Der E-Mail-Speicher ist
die Reiter-Inhalte weiter. Bestellen, Wechsel und Gutschrift sind Etagen, die die Hülle direkt ruft. */ die Etage #speicher (Link „ändern“ im Abo). Der Rahmen lädt Abo und Rechnungen parallel (jede Quelle mit eigenem Fehlerweg), wählt den
Reiter allein aus route.reiter und gibt die schon geladenen Antworten an die Reiter-Inhalte weiter. Bestellen, Wechsel, Gutschrift und
Speicher sind Etagen, die die Hülle direkt ruft. */
import { api } from './api.js' import { api } from './api.js'
import { h, leeren } from './dom.js' import { h, leeren } from './dom.js'
@@ -11,26 +13,24 @@ import { offeneRechnungen } from './rechnung-offen.js'
import { zeigeRechnungen } from './vertraege-rechnungen.js' import { zeigeRechnungen } from './vertraege-rechnungen.js'
import { zeigeAbo } from './vertraege-abo.js' import { zeigeAbo } from './vertraege-abo.js'
import { zeigeStunden } from './ansicht-stunden.js' import { zeigeStunden } from './ansicht-stunden.js'
import { zeigeSpeicher } from './ansicht-speicher.js'
/** Nach ?abo=success: so oft und in diesem Abstand wird auf ein laufendes Abo gewartet (Tests verkürzen es). */ /** Nach ?abo=success: so oft und in diesem Abstand wird auf ein laufendes Abo gewartet (Tests verkürzen es). */
export const AUF_ABO_WARTEN = { versuche: 10, pauseMs: 2000 } export const AUF_ABO_WARTEN = { versuche: 10, pauseMs: 2000 }
const LABEL = { rechnungen: 'Rechnungen', abo: 'Abo', stunden: 'Stunden', email: 'E-Mail' } const LABEL = { abo: 'Abo', rechnungen: 'Rechnungen' }
// Reiterklicks navigieren mit den ausdrücklichen Hashes (P1 schreibt #wartung und #abo kanonisch um) // Reiterklicks navigieren mit den ausdrücklichen Hashes (P1 schreibt #wartung und #abo kanonisch um)
const HASH = { rechnungen: '#rechnungen', abo: '#abo', stunden: '#stunden', email: '#speicher' } const HASH = { abo: '#abo', rechnungen: '#rechnungen' }
const laeufe = new WeakMap() // Wurzel → Nummer des jüngsten Aufrufs const laeufe = new WeakMap() // Wurzel → Nummer des jüngsten Aufrufs
let reiterWechsel = null // Reiterklick mit laufendem Router: der nächste Aufruf nimmt die schon geladenen Antworten let reiterWechsel = null // Reiterklick mit laufendem Router: der nächste Aufruf nimmt die schon geladenen Antworten
/** Sichtbare Reiter: ohne laufendes Abo Rechnungen und Abo; Hosting dazu E-Mail; Wartung dazu Stunden und E-Mail. */ /** Sichtbare Reiter: immer Abo und Rechnungen (Navigation leichter V7). Stunden stehen im Abo, der E-Mail-Speicher ist die Etage #speicher. */
export function sichtbareReiter(daten) { export function sichtbareReiter() {
const sub = daten?.subscription return ['abo', 'rechnungen']
const reiter = ['rechnungen', 'abo']
if (!sub?.active) return reiter
if (sub.productKey === 'hosting') return [...reiter, 'email']
return [...reiter, 'stunden', 'email']
} }
/** Gibt es ein Stundenkonto unter dem Abo? Nur mit laufendem Wartungs-Abo. */
export const mitStundenkonto = (daten) => Boolean(daten?.subscription?.active && daten.subscription.productKey === 'wartung')
/** Reiterwahl allein aus route.reiter (routeReiter) und den Daten, nie aus der Adressleiste. Nur der Bereichs-Einstieg (ohne Reiter) springt auf Rechnungen. */ /** Reiterwahl allein aus route.reiter (routeReiter) und den Daten, nie aus der Adressleiste. Nur der Bereichs-Einstieg (ohne Reiter) springt auf Rechnungen. */
export function waehleReiter({ routeReiter, sichtbar, offeneRechnungen: offen }) { export function waehleReiter({ routeReiter, sichtbar, offeneRechnungen: offen }) {
if (routeReiter) return sichtbar.includes(routeReiter) ? routeReiter : 'abo' if (routeReiter) return sichtbar.includes(routeReiter) ? routeReiter : 'abo'
@@ -79,7 +79,8 @@ export async function zeigeVertraege(wurzel, { route, me, nav } = {}) {
const sichtbar = sichtbareReiter(daten) const sichtbar = sichtbareReiter(daten)
const offen = offeneRechnungen(rechnungen).length const offen = offeneRechnungen(rechnungen).length
const gewaehlt = waehleReiter({ routeReiter: route?.reiter, sichtbar, offeneRechnungen: offen }) const gewaehlt = waehleReiter({ routeReiter: route?.reiter, sichtbar, offeneRechnungen: offen })
const stundenOhneWartung = route?.reiter === 'stunden' && !sichtbar.includes('stunden') const stundenGewuenscht = route?.reiter === 'stunden' // #stunden: Abo zeigen und zum Stundenkonto springen
const stundenkonto = mitStundenkonto(daten)
const hinweis = nimmHinweis() const hinweis = nimmHinweis()
const inhalt = h('div', { class: 'kn-reiter-inhalt' }) const inhalt = h('div', { class: 'kn-reiter-inhalt' })
@@ -87,14 +88,20 @@ export async function zeigeVertraege(wurzel, { route, me, nav } = {}) {
leeren(inhalt) leeren(inhalt)
if (key === 'rechnungen') return zeigeRechnungen(inhalt, { me, antwort: rechnungen ?? undefined }) if (key === 'rechnungen') return zeigeRechnungen(inhalt, { me, antwort: rechnungen ?? undefined })
if (!daten) return Promise.resolve() if (!daten) return Promise.resolve()
if (key === 'abo') { if (stundenGewuenscht && !stundenkonto) inhalt.append(h('p', { class: 'kn-hinweis' }, 'Ein Stundenkonto gibt es im Wartungs-Abo.'))
if (stundenOhneWartung) inhalt.append(h('p', { class: 'kn-hinweis' }, 'Ein Stundenkonto gibt es im Wartungs-Abo.')) const platz = h('div', { class: 'kn-reiter-abo' })
const platz = h('div', { class: 'kn-reiter-abo' }) inhalt.append(platz)
inhalt.append(platz) const fertig = [zeigeAbo(platz, { daten, me })]
return zeigeAbo(platz, { daten, me }) if (stundenkonto) {
const konto = h('div')
const abschnitt = h('section', { class: 'kn-sektion kn-stundenkonto', id: 'kn-stundenkonto', 'aria-labelledby': 'kn-stundenkonto-titel' },
h('h2', { class: K.abschnitt, id: 'kn-stundenkonto-titel' }, 'Stundenkonto'), konto)
inhalt.append(abschnitt)
fertig.push(zeigeStunden(konto, { route, me, nav, daten, eingebettet: true }).then(() => {
if (stundenGewuenscht && nav !== 'back' && typeof abschnitt.scrollIntoView === 'function') abschnitt.scrollIntoView({ block: 'start' })
}))
} }
if (key === 'stunden') return zeigeStunden(inhalt, { route, me, nav, daten }) return Promise.all(fertig)
return zeigeSpeicher(inhalt, { route, me, nav, daten })
} }
const bei = (key) => { const bei = (key) => {
+3
View File
@@ -423,3 +423,6 @@
.kn-rechnungen .kn-rechnung-aktionen { justify-content: flex-start; padding-top: 6px; } .kn-rechnungen .kn-rechnung-aktionen { justify-content: flex-start; padding-top: 6px; }
.kn-rechnungen .kn-rechnung-aktionen::before { content: none; } .kn-rechnungen .kn-rechnung-aktionen::before { content: none; }
} }
/* Navigation leichter (V7): Stundenkonto als Abschnitt unter dem Abo; #stunden springt hierher, der Kopf deckt die Überschrift nicht zu */
.kn-stundenkonto { scroll-margin-top: calc(var(--kn-kopf-h, 52px) + 16px); }
+13 -14
View File
@@ -64,7 +64,10 @@
.kn-konto-zeile { display: flex; align-items: center; justify-content: space-between; gap: 10px; width: 100%; min-height: 46px; padding: 0; border-bottom: 1px solid var(--kn-line); text-align: left; color: var(--kn-text); } .kn-konto-zeile { display: flex; align-items: center; justify-content: space-between; gap: 10px; width: 100%; min-height: 46px; padding: 0; border-bottom: 1px solid var(--kn-line); text-align: left; color: var(--kn-text); }
.kn-konto-zeile:hover .kn-konto-text { text-decoration: underline; text-decoration-color: var(--kn-line-strong); text-underline-offset: 3px; } .kn-konto-zeile:hover .kn-konto-text { text-decoration: underline; text-decoration-color: var(--kn-line-strong); text-underline-offset: 3px; }
.kn-konto-zeile--betont .kn-konto-text { font-weight: 600; } .kn-konto-zeile--betont .kn-konto-text { font-weight: 600; }
.kn-konto-zeile[data-konto="abmelden"] { border-bottom: 0; color: var(--kn-text-2); } .kn-konto-zeile[data-konto="abmelden"] { color: var(--kn-text-2); }
/* Navigation leichter (V8): Datenschutz und Impressum klein nebeneinander unter den vier Zeilen */
.kn-konto-liste { display: flex; flex-wrap: wrap; column-gap: 16px; }
.kn-konto-zeile--klein { width: auto; min-height: 36px; border-bottom: 0; font-size: 13px; line-height: 18px; color: var(--kn-text-2); }
.kn-konto-text { display: block; } .kn-konto-text { display: block; }
.kn-konto-hinweis { display: block; margin-top: 1px; font-size: 13px; line-height: 18px; font-weight: 400; color: var(--kn-text-2); } .kn-konto-hinweis { display: block; margin-top: 1px; font-size: 13px; line-height: 18px; font-weight: 400; color: var(--kn-text-2); }
@@ -73,6 +76,7 @@
.kn-konto { top: auto; right: 0; bottom: 0; left: 0; width: auto; max-height: 92vh; padding: 8px max(20px, env(safe-area-inset-right)) calc(22px + env(safe-area-inset-bottom)) max(20px, env(safe-area-inset-left)); border-radius: 12px 12px 0 0; animation: kn-sheet-hoch 500ms var(--kn-bremse); } .kn-konto { top: auto; right: 0; bottom: 0; left: 0; width: auto; max-height: 92vh; padding: 8px max(20px, env(safe-area-inset-right)) calc(22px + env(safe-area-inset-bottom)) max(20px, env(safe-area-inset-left)); border-radius: 12px 12px 0 0; animation: kn-sheet-hoch 500ms var(--kn-bremse); }
.kn-konto-zu { min-height: 44px; } .kn-konto-zu { min-height: 44px; }
.kn-konto-zeile { min-height: 46px; } .kn-konto-zeile { min-height: 46px; }
.kn-konto-zeile--klein { min-height: 44px; }
} }
@keyframes kn-sheet-hoch { from { transform: translateY(100%); } } @keyframes kn-sheet-hoch { from { transform: translateY(100%); } }
@@ -133,7 +137,9 @@
#kn-fehler { margin: 12px max(16px, env(safe-area-inset-right)) 0 max(16px, env(safe-area-inset-left)); } #kn-fehler { margin: 12px max(16px, env(safe-area-inset-right)) 0 max(16px, env(safe-area-inset-left)); }
} }
/* ---------- Leiste (Handy): Fahrstuhl-Zeile mit Balken ---------- */ /* ---------- Leiste (Handy): Fahrstuhl-Zeile mit Linie ----------
Unter dem aktiven Wort läuft eine dünne schwarze Linie (der Gleiter), das Wort selbst wird dunkel und fett. Schwarze Flächen bleiben den Knöpfen
vorbehalten. Die weiße Overlay-Kopie der Wörter aus reiter.js wird dafür nicht mehr gezeigt. */
.kn-leiste { display: none; } .kn-leiste { display: none; }
.kn-leiste-reel { height: var(--kn-zeile-h); overflow: hidden; } .kn-leiste-reel { height: var(--kn-zeile-h); overflow: hidden; }
@@ -144,25 +150,18 @@
.kn-balken { position: relative; flex: 1; min-width: 0; height: 44px; padding-right: 6px; overflow-x: auto; overflow-y: hidden; scrollbar-width: none; } /* 6 px Luft rechts: der Zähler am letzten Wort ragt über das Wort hinaus und würde sonst abgeschnitten */ .kn-balken { position: relative; flex: 1; min-width: 0; height: 44px; padding-right: 6px; overflow-x: auto; overflow-y: hidden; scrollbar-width: none; } /* 6 px Luft rechts: der Zähler am letzten Wort ragt über das Wort hinaus und würde sonst abgeschnitten */
.kn-balken::-webkit-scrollbar { display: none; } .kn-balken::-webkit-scrollbar { display: none; }
.kn-balken-basis { position: relative; z-index: 1; display: flex; width: max-content; min-width: 100%; height: 44px; } .kn-balken-basis { position: relative; z-index: 1; display: flex; width: max-content; min-width: 100%; height: 44px; }
.kn-gleiter { position: absolute; z-index: 2; top: 4px; left: 0; height: 36px; overflow: hidden; border-radius: 6px; background: var(--kn-accent); pointer-events: none; transition: transform 550ms var(--kn-feder), width 550ms var(--kn-feder); } .kn-gleiter { position: absolute; z-index: 2; bottom: 2px; left: 0; height: 2px; overflow: hidden; border-radius: 1px; background: var(--kn-accent); pointer-events: none; transition: transform 550ms var(--kn-feder), width 550ms var(--kn-feder); }
.kn-overlay { position: absolute; top: -4px; left: 0; display: flex; height: 44px; transition: transform 550ms var(--kn-feder); } .kn-overlay { display: none; }
.kn-fb .kn-overlay { display: none; }
.kn-fb .kn-gleiter { z-index: 0; }
.kn-fb .kn-t[aria-selected="true"] { color: #fff; }
.kn-t { flex: 1 1 auto; display: flex; align-items: center; justify-content: center; min-height: 44px; padding: 0 4px; font-size: 15px; font-weight: 600; color: var(--kn-text-2); white-space: nowrap; text-align: center; transition: color 250ms; } .kn-t { flex: 1 1 auto; display: flex; align-items: center; justify-content: center; min-height: 44px; padding: 0 4px; font-size: 15px; font-weight: 500; color: var(--kn-text-2); white-space: nowrap; text-align: center; transition: color 250ms; }
.kn-t[aria-selected="true"] { color: var(--kn-text); font-weight: 600; }
/* Fokusring: .kn [tabindex]:focus-visible und .kn button:focus-visible (module.css, 0-3-0 und 0-2-1, später geladen) setzen +2px und schlugen die alte Regel /* Fokusring: .kn [tabindex]:focus-visible und .kn button:focus-visible (module.css, 0-3-0 und 0-2-1, später geladen) setzen +2px und schlugen die alte Regel
(0-2-0); der Ring lag außerhalb und wurde vom Balken (overflow) abgeschnitten. Mit .kn und [role="tab"] (0-4-0) bleibt er innen. Der aktive Reiter liegt (0-2-0); der Ring lag außerhalb und wurde vom Balken (overflow) abgeschnitten. Mit .kn und [role="tab"] (0-4-0) bleibt er innen. */
auf dem schwarzen Gleiter: dort zeichnet der Gleiter einen hellen Innenring. */
.kn .kn-t[role="tab"]:focus-visible { outline-offset: -2px; border-radius: 6px; } .kn .kn-t[role="tab"]:focus-visible { outline-offset: -2px; border-radius: 6px; }
.kn .kn-balken:has(.kn-t:focus-visible) .kn-gleiter { outline: 2px solid #fff; outline-offset: -3px; }
.kn .kn-fb .kn-t[aria-selected="true"]:focus-visible { outline-color: #fff; }
.kn-schrift-klein .kn-t { font-size: 14px; } .kn-schrift-klein .kn-t { font-size: 14px; }
.kn-overlay .kn-t { color: #fff; }
.kn-l { position: relative; line-height: 20px; } .kn-l { position: relative; line-height: 20px; }
.kn-zahl { position: absolute; top: -9px; left: 100%; min-width: 16px; height: 16px; margin-left: -6px; padding: 0 4px; border-radius: 8px; background: #b3093c; color: #fff; font-size: 11px; line-height: 16px; font-weight: 700; text-align: center; box-shadow: 0 0 0 2px var(--kn-bg); animation: kn-zahl-ein 400ms cubic-bezier(.3,1.5,.5,1); } .kn-zahl { position: absolute; top: -9px; left: 100%; min-width: 16px; height: 16px; margin-left: -6px; padding: 0 4px; border-radius: 8px; background: #b3093c; color: #fff; font-size: 11px; line-height: 16px; font-weight: 700; text-align: center; box-shadow: 0 0 0 2px var(--kn-bg); animation: kn-zahl-ein 400ms cubic-bezier(.3,1.5,.5,1); }
.kn-overlay .kn-zahl { box-shadow: 0 0 0 2px var(--kn-accent); }
.kn-reiter-t .kn-zahl { top: -7px; } .kn-reiter-t .kn-zahl { top: -7px; }
.kn-zahl--weg { transform: scale(0); transition: transform 250ms; animation: none; } .kn-zahl--weg { transform: scale(0); transition: transform 250ms; animation: none; }
@keyframes kn-zahl-ein { from { transform: scale(0); } } @keyframes kn-zahl-ein { from { transform: scale(0); } }
+1
View File
@@ -30,6 +30,7 @@ export const ETAGE_ANSICHTEN = {
gutschrift: ['/neu/ansicht-gutschrift.js', 'zeigeGutschrift', 'etage-body'], gutschrift: ['/neu/ansicht-gutschrift.js', 'zeigeGutschrift', 'etage-body'],
wechsel: ['/neu/ansicht-wechsel.js', 'zeigeWechsel', 'etage-body'], wechsel: ['/neu/ansicht-wechsel.js', 'zeigeWechsel', 'etage-body'],
bestellen: ['/neu/ansicht-bestellen.js', 'zeigeBestellen', 'etage-body'], bestellen: ['/neu/ansicht-bestellen.js', 'zeigeBestellen', 'etage-body'],
speicher: ['/neu/ansicht-speicher.js', 'zeigeSpeicher', 'etage-body'], // Navigation leichter V7: Unterseite statt Reiter E-Mail
} }
// Gemeinsame Funktionsmodule, wörtlich aus app.js:1938-1945 (Wurzel: der Behälter mit der alten ID) // Gemeinsame Funktionsmodule, wörtlich aus app.js:1938-1945 (Wurzel: der Behälter mit der alten ID)
export const FUNKTIONS_MODULE = { export const FUNKTIONS_MODULE = {
+6 -6
View File
@@ -1,6 +1,6 @@
/* Kundenansicht neu: Konto. Das Profilbild (Avatar) öffnet am Handy ein Sheet von unten, am Desktop ein Popover rechts oben. /* Kundenansicht neu: Konto. Das Profilbild (Avatar) öffnet am Handy ein Sheet von unten, am Desktop ein Popover rechts oben.
Reihenfolge der Zeilen (V3): Passwort ändern, Datenschutz, Impressum, App installieren (nur wenn sinnvoll), Verträge kündigen, Reihenfolge der Zeilen (V3, geändert mit „Navigation leichter“ V8): Passwort ändern, App installieren (nur wenn sinnvoll), Verträge kündigen,
Vertrag widerrufen (immer da, in der Frist mit der Pille „Frist läuft“), Abmelden. Namen und Mails kommen nur als Text in die Seite. Vertrag widerrufen (immer da, in der Frist mit der Pille „Frist läuft“), Abmelden; darunter klein Datenschutz und Impressum. Namen und Mails kommen nur als Text in die Seite.
Das Sheet ist ein Menü: Esc, ein Tipp aufs Dunkle und „Schließen“ schließen es; Zurück schließt es über das Overlay des Routers. */ Das Sheet ist ein Menü: Esc, ein Tipp aufs Dunkle und „Schließen“ schließen es; Zurück schließt es über das Overlay des Routers. */
import { h, leeren } from './dom.js' import { h, leeren } from './dom.js'
@@ -22,17 +22,17 @@ export function installAngebot({ handy, standalone, prompt, ios }) {
const HINWEIS_IOS = 'Tippe auf Teilen und dann auf Zum Home-Bildschirm.' const HINWEIS_IOS = 'Tippe auf Teilen und dann auf Zum Home-Bildschirm.'
/** Zeilen des Kontos in der Reihenfolge von V3. */ /** Zeilen des Kontos: vier volle Zeilen, Datenschutz und Impressum klein darunter (klein: true). */
export function kontoEintraege({ fristen, install } = {}) { export function kontoEintraege({ fristen, install } = {}) {
const frist = fristen?.anzeigen === true const frist = fristen?.anzeigen === true
return [ return [
{ id: 'passwort', text: 'Passwort ändern', href: '#passwort' }, { id: 'passwort', text: 'Passwort ändern', href: '#passwort' },
{ id: 'datenschutz', text: 'Datenschutz', href: '/datenschutz.html', neuerTab: true },
{ id: 'impressum', text: 'Impressum', href: 'https://webklar.com/impressum', neuerTab: true },
...(install ? [{ id: 'installieren', text: 'App installieren', ...(install === 'ios' ? { hinweis: HINWEIS_IOS } : {}) }] : []), ...(install ? [{ id: 'installieren', text: 'App installieren', ...(install === 'ios' ? { hinweis: HINWEIS_IOS } : {}) }] : []),
{ id: 'kuendigen', text: 'Verträge kündigen', href: '/kuendigen.html', betont: true }, { id: 'kuendigen', text: 'Verträge kündigen', href: '/kuendigen.html', betont: true },
{ id: 'widerrufen', text: 'Vertrag widerrufen', href: '/widerrufen.html', betont: true, ...(frist ? { pille: 'Frist läuft' } : {}) }, { id: 'widerrufen', text: 'Vertrag widerrufen', href: '/widerrufen.html', betont: true, ...(frist ? { pille: 'Frist läuft' } : {}) },
{ id: 'abmelden', text: 'Abmelden' }, { id: 'abmelden', text: 'Abmelden' },
{ id: 'datenschutz', text: 'Datenschutz', href: '/datenschutz.html', neuerTab: true, klein: true },
{ id: 'impressum', text: 'Impressum', href: 'https://webklar.com/impressum', neuerTab: true, klein: true },
] ]
} }
@@ -63,7 +63,7 @@ export function baueKonto({ me, fristen, install, onAbmelden, onInstall, overlay
function zeile(e) { function zeile(e) {
const inhalt = [h('span', { class: 'kn-konto-text' }, e.text, e.hinweis ? h('span', { class: 'kn-konto-hinweis' }, e.hinweis) : null), e.pille ? h('span', { class: 'kn-pille kn-pille-orange' }, e.pille) : null] const inhalt = [h('span', { class: 'kn-konto-text' }, e.text, e.hinweis ? h('span', { class: 'kn-konto-hinweis' }, e.hinweis) : null), e.pille ? h('span', { class: 'kn-pille kn-pille-orange' }, e.pille) : null]
const klasse = `kn-konto-zeile${e.betont ? ' kn-konto-zeile--betont' : ''}` const klasse = `kn-konto-zeile${e.betont ? ' kn-konto-zeile--betont' : ''}${e.klein ? ' kn-konto-zeile--klein' : ''}`
if (e.href === undefined) return h('button', { class: klasse, type: 'button', 'data-konto': e.id, on: { click: () => (e.id === 'abmelden' ? onAbmelden() : onInstall?.()) } }, inhalt) if (e.href === undefined) return h('button', { class: klasse, type: 'button', 'data-konto': e.id, on: { click: () => (e.id === 'abmelden' ? onAbmelden() : onInstall?.()) } }, inhalt)
const intern = e.href.startsWith('#') const intern = e.href.startsWith('#')
return h('a', { class: klasse, href: e.href, 'data-konto': e.id, target: e.neuerTab ? '_blank' : null, rel: e.neuerTab ? 'noopener' : null, return h('a', { class: klasse, href: e.href, 'data-konto': e.id, target: e.neuerTab ? '_blank' : null, rel: e.neuerTab ? 'noopener' : null,
+1 -1
View File
@@ -1,4 +1,4 @@
/* Kundenansicht neu: Navigation der Hülle. Handy: Fahrstuhl-Zeile unten mit vier Wörtern und schwarzem Balken (reiter.js, art 'balken'). /* Kundenansicht neu: Navigation der Hülle. Handy: Fahrstuhl-Zeile unten mit vier Wörtern und einer dünnen Linie unter dem aktiven Wort (reiter.js, art 'balken').
Desktop: Menü links, Wörter ohne Icons. Beide zeigen dieselben Zähler. Dazu der reine Zustand der Bildschirmtastatur Desktop: Menü links, Wörter ohne Icons. Beide zeigen dieselben Zähler. Dazu der reine Zustand der Bildschirmtastatur
(Leiste und Streifen verschwinden nur bei Texteingabe) und seine Verdrahtung mit visualViewport, focusin und focusout. */ (Leiste und Streifen verschwinden nur bei Texteingabe) und seine Verdrahtung mit visualViewport, focusin und focusout. */
+12 -9
View File
@@ -109,14 +109,15 @@
.kn .tm-reiter:focus-visible { outline-offset: -2px; } .kn .tm-reiter:focus-visible { outline-offset: -2px; }
.kn .tm-panel:focus { outline: none; } .kn .tm-panel:focus { outline: none; }
/* ---- Werkzeugleiste: Ansicht, Pfeile, Sprung, Filter ---- */ /* ---- Werkzeugleiste: Ansicht, Pfeile, Sprung, Filter ----
Navigation leichter (V5): Schwarz nur für die Hauptaktion. Die Ansicht (Tag/Woche/Monat) ist ein hellgrauer Umschalter mit weißer Auswahl,
die Filter sind leise Chips mit grauer Auswahl; schwarz bleibt „+ Termin eintragen“. */
.kn .tm-leiste { display: flex; align-items: center; gap: 8px 12px; flex-wrap: wrap; } .kn .tm-leiste { display: flex; align-items: center; gap: 8px 12px; flex-wrap: wrap; }
.kn .tm-leiste [hidden], .kn .tm-filter[hidden] { display: none; } .kn .tm-leiste [hidden], .kn .tm-filter[hidden] { display: none; }
.kn .tm-segment { display: inline-flex; border-radius: var(--kn-radius); background: var(--kn-bg); box-shadow: var(--kn-ring); overflow: hidden; } .kn .tm-segment { display: inline-flex; padding: 2px; border-radius: 8px; background: var(--kn-bg-subtle); box-shadow: inset 0 0 0 1px var(--kn-line); overflow: hidden; }
.kn .tm-seg { min-height: 32px; padding: 0 14px; border: 0; background: none; color: var(--kn-text); font: inherit; font-weight: 500; cursor: pointer; transition: background-color .12s; } .kn .tm-seg { min-height: 28px; padding: 0 14px; border: 0; border-radius: 6px; background: none; color: var(--kn-text-2); font: inherit; font-weight: 500; cursor: pointer; transition: background-color .12s, color .12s; }
.kn .tm-seg + .tm-seg { box-shadow: -1px 0 0 var(--kn-line); } .kn .tm-seg:hover { color: var(--kn-text); }
.kn .tm-seg:hover { background: var(--kn-bg-hover); } .kn .tm-seg[aria-pressed='true'] { background: var(--kn-bg); color: var(--kn-text); font-weight: 600; box-shadow: var(--kn-ring); }
.kn .tm-seg[aria-pressed='true'] { background: var(--kn-accent); color: #fff; }
.kn .tm-nav { display: inline-flex; align-items: center; gap: 6px; } .kn .tm-nav { display: inline-flex; align-items: center; gap: 6px; }
.kn .tm-heute { min-width: 72px; } .kn .tm-heute { min-width: 72px; }
.kn .tm-pfeil { display: inline-grid; place-items: center; width: 32px; height: 32px; padding: 0; border: 0; border-radius: var(--kn-radius); background: var(--kn-bg); box-shadow: var(--kn-ring); color: var(--kn-text); font: inherit; font-size: 18px; line-height: 1; cursor: pointer; } .kn .tm-pfeil { display: inline-grid; place-items: center; width: 32px; height: 32px; padding: 0; border: 0; border-radius: var(--kn-radius); background: var(--kn-bg); box-shadow: var(--kn-ring); color: var(--kn-text); font: inherit; font-size: 18px; line-height: 1; cursor: pointer; }
@@ -124,9 +125,9 @@
.kn .tm-sprung { display: inline-flex; align-items: center; gap: 8px; font-size: 13px; color: var(--kn-text-2); } .kn .tm-sprung { display: inline-flex; align-items: center; gap: 8px; font-size: 13px; color: var(--kn-text-2); }
.kn .tm-zeitraum { margin: 20px 0 8px; font-size: 17px; line-height: 24px; font-weight: 600; } .kn .tm-zeitraum { margin: 20px 0 8px; font-size: 17px; line-height: 24px; font-weight: 600; }
.kn .tm-filter { display: flex; flex-wrap: wrap; gap: 8px; margin: 0 0 12px; } .kn .tm-filter { display: flex; flex-wrap: wrap; gap: 8px; margin: 0 0 12px; }
.kn .tm-chip { min-height: 28px; padding: 0 12px; border: 0; border-radius: var(--kn-radius); background: var(--kn-bg); box-shadow: var(--kn-ring); color: var(--kn-text); font: inherit; font-size: 13px; font-weight: 500; cursor: pointer; transition: background-color .12s; } .kn .tm-chip { min-height: 28px; padding: 0 10px; border: 0; border-radius: var(--kn-radius); background: none; color: var(--kn-text-2); font: inherit; font-size: 13px; font-weight: 500; cursor: pointer; transition: background-color .12s, color .12s; }
.kn .tm-chip:hover { background: var(--kn-bg-hover); } .kn .tm-chip:hover { background: var(--kn-bg-hover); color: var(--kn-text); }
.kn .tm-chip[aria-pressed='true'] { background: var(--kn-accent); color: #fff; } .kn .tm-chip[aria-pressed='true'] { background: var(--kn-grau-bg); color: var(--kn-text); font-weight: 600; }
.kn .tm-werkzeug-zeile { display: flex; flex-wrap: wrap; gap: 8px; margin: 0 0 16px; } .kn .tm-werkzeug-zeile { display: flex; flex-wrap: wrap; gap: 8px; margin: 0 0 16px; }
.kn .tm-werkzeuge { display: contents; } .kn .tm-werkzeuge { display: contents; }
.kn .tm-werkzeuge button[aria-expanded='true'] { background: var(--kn-bg-hover); } .kn .tm-werkzeuge button[aria-expanded='true'] { background: var(--kn-bg-hover); }
@@ -340,6 +341,8 @@
.kn .tm-leiste { gap: 8px; } .kn .tm-leiste { gap: 8px; }
.kn .tm-segment { width: 100%; } .kn .tm-segment { width: 100%; }
.kn .tm-seg { flex: 1; padding-left: 8px; padding-right: 8px; } .kn .tm-seg { flex: 1; padding-left: 8px; padding-right: 8px; }
.kn .tm-filter { gap: 2px; } /* die fünf leisen Chips passen auf 390 px in eine Zeile */
.kn .tm-chip { padding-left: 8px; padding-right: 8px; }
.kn .tm-nav { width: 100%; } .kn .tm-nav { width: 100%; }
.kn .tm-nav .tm-heute { flex: 1; } .kn .tm-nav .tm-heute { flex: 1; }
.kn .tm-sprung { width: 100%; } .kn .tm-sprung { width: 100%; }
+3 -2
View File
@@ -1,7 +1,8 @@
/* Kundenansicht neu: gemeinsame Reiter. Zwei Arten mit demselben Baustein: /* Kundenansicht neu: gemeinsame Reiter. Zwei Arten mit demselben Baustein:
'unterstrich' = Reiter einer Hauptseite (basis.css, .kn-reiter), 'balken' = Fahrstuhl-Zeile und Reiter in Etage 2: 'unterstrich' = Reiter einer Hauptseite (basis.css, .kn-reiter), 'balken' = Fahrstuhl-Zeile und Reiter in Etage 2:
ein schwarzer Gleiter fährt federnd unter das aktive Wort, darin eine Overlay-Kopie der Wörter in Weiß mit Gegenbewegung ein Gleiter fährt federnd unter das aktive Wort, darin eine Overlay-Kopie der Wörter in Weiß mit Gegenbewegung
(final-2-frei.html). Das Overlay ist aria-hidden und trägt keine Rollen. Zahlenfunktionen sind rein und werden mit node --test geprüft; (final-2-frei.html). Seit „Navigation leichter“ (V3) ist der Gleiter in huelle.css eine dünne Linie und das Overlay ausgeblendet;
Messen und Rückfall laufen unverändert weiter. Das Overlay ist aria-hidden und trägt keine Rollen. Zahlenfunktionen sind rein und werden mit node --test geprüft;
Messen und Bewegen braucht getBoundingClientRect und entfällt ohne Browser. */ Messen und Bewegen braucht getBoundingClientRect und entfällt ohne Browser. */
import { h } from './dom.js' import { h } from './dom.js'
+5 -5
View File
@@ -28,7 +28,7 @@ export const ROUTEN = Object.freeze(Object.fromEntries(Object.entries({
wartung: { bereich: 'vertraege', reiter: 'abo' }, wartung: { bereich: 'vertraege', reiter: 'abo' },
rechnungen: { bereich: 'vertraege', reiter: 'rechnungen' }, rechnungen: { bereich: 'vertraege', reiter: 'rechnungen' },
stunden: { bereich: 'vertraege', reiter: 'stunden' }, stunden: { bereich: 'vertraege', reiter: 'stunden' },
speicher: { bereich: 'vertraege', reiter: 'email' }, speicher: { bereich: 'vertraege', etage: 'speicher' },
gutschrift: { bereich: 'vertraege', etage: 'gutschrift' }, gutschrift: { bereich: 'vertraege', etage: 'gutschrift' },
wechsel: { bereich: 'vertraege', etage: 'wechsel' }, wechsel: { bereich: 'vertraege', etage: 'wechsel' },
'bestellen-hosting': { bereich: 'vertraege', etage: 'bestellen', produkt: 'hosting' }, 'bestellen-hosting': { bereich: 'vertraege', etage: 'bestellen', produkt: 'hosting' },
@@ -42,6 +42,7 @@ const TITEL_BEREICH = { website: 'Website', funktionen: 'Funktionen', chat: 'Cha
const TITEL_ETAGE = { const TITEL_ETAGE = {
projekt: 'Projekt', news: 'News', termine: 'Terminplaner', webshop: 'Webshop', buchungen: 'Buchungskalender', zeiten: 'Öffnungszeiten', projekt: 'Projekt', news: 'News', termine: 'Terminplaner', webshop: 'Webshop', buchungen: 'Buchungskalender', zeiten: 'Öffnungszeiten',
analyse: 'Analyse', gutschrift: 'Übernahmegutschrift', wechsel: 'Abo wechseln', bestellen: 'Abo bestellen', passwort: 'Passwort ändern', analyse: 'Analyse', gutschrift: 'Übernahmegutschrift', wechsel: 'Abo wechseln', bestellen: 'Abo bestellen', passwort: 'Passwort ändern',
speicher: 'E-Mail-Speicher',
} }
// Alter data-view-Name (VIEW_HASHES, app.js:1933) → Hash-Name // Alter data-view-Name (VIEW_HASHES, app.js:1933) → Hash-Name
@@ -107,8 +108,8 @@ export function titelFuer(route) {
return TITEL_ETAGE[route.etage] ?? TITEL_BEREICH[route.bereich] ?? '' return TITEL_ETAGE[route.etage] ?? TITEL_BEREICH[route.bereich] ?? ''
} }
/** Etagen, auf denen etwas bestellt wird: Abo bestellen, Wechsel und die Übernahmegutschrift. Dazu der Reiter E-Mail der Verträge (Zusatzspeicher). */ /** Etagen, auf denen etwas bestellt wird: Abo bestellen, Wechsel, die Übernahmegutschrift und der E-Mail-Speicher (Zusatzspeicher; bis „Navigation leichter“ V7 ein Reiter der Verträge). */
const BESTELL_ETAGEN = ['bestellen', 'wechsel', 'gutschrift'] const BESTELL_ETAGEN = ['bestellen', 'wechsel', 'gutschrift', 'speicher']
/** /**
* Steht auf dieser Seite der Widerrufsstreifen? Nicht auf den Bestellseiten: Dort müssen Kerninfo und Knopf „Zahlungspflichtig bestellen“ auf einem * Steht auf dieser Seite der Widerrufsstreifen? Nicht auf den Bestellseiten: Dort müssen Kerninfo und Knopf „Zahlungspflichtig bestellen“ auf einem
@@ -117,6 +118,5 @@ const BESTELL_ETAGEN = ['bestellen', 'wechsel', 'gutschrift']
*/ */
export function zeigtWiderrufsstreifen(route) { export function zeigtWiderrufsstreifen(route) {
if (!route) return true if (!route) return true
if (BESTELL_ETAGEN.includes(route.etage)) return false return !BESTELL_ETAGEN.includes(route.etage)
return !(route.bereich === 'vertraege' && route.reiter === 'email')
} }
+8 -2
View File
@@ -3,7 +3,7 @@
import { api } from './api.js' import { api } from './api.js'
import { h, leeren } from './dom.js' import { h, leeren } from './dom.js'
import { K, zuAnsicht, fmtEuro, fmtDatum, fmtTagMonat, sichereAdresse, beiRueckkehr } from './ansicht-hilfen.js' import { K, zuAnsicht, fmtEuro, fmtDatum, fmtTagMonat, sichereAdresse, beiRueckkehr, navigiere } from './ansicht-hilfen.js'
const laeufe = new WeakMap() // Reiter-Inhalt → Nummer des jüngsten Aufrufs const laeufe = new WeakMap() // Reiter-Inhalt → Nummer des jüngsten Aufrufs
const TEXT_UST = 'Gemäß § 19 UStG ohne Umsatzsteuer.' const TEXT_UST = 'Gemäß § 19 UStG ohne Umsatzsteuer.'
@@ -113,7 +113,7 @@ function vertragsBlock(daten, sub, viewAs) {
fakt(gekuendigt(sub) ? 'Leistungen' : 'Abrechnung', aboZeile(sub)), fakt(gekuendigt(sub) ? 'Leistungen' : 'Abrechnung', aboZeile(sub)),
fakt('Laufzeit', laufzeitText(sub)), fakt('Laufzeit', laufzeitText(sub)),
sub.offerText ? fakt('Angebot', sub.offerText) : null, sub.offerText ? fakt('Angebot', sub.offerText) : null,
fakt('E-Mail-Speicher', `${gb} GB${einheiten ? ` (${einheiten} Zusatzpaket${einheiten === 1 ? '' : 'e'})` : ''}`)), fakt('E-Mail-Speicher', [`${gb} GB${einheiten ? ` (${einheiten} Zusatzpaket${einheiten === 1 ? '' : 'e'})` : ''} · `, speicherLink()])),
leistungen(katalog?.features), leistungen(katalog?.features),
h('p', { class: 'kn-links' }, h('p', { class: 'kn-links' },
h('a', { href: sub.termsUrl || '/abo-bedingungen.html', target: '_blank', rel: 'noopener noreferrer' }, 'Vertragsbedingungen'), ' · ', h('a', { href: sub.termsUrl || '/abo-bedingungen.html', target: '_blank', rel: 'noopener noreferrer' }, 'Vertragsbedingungen'), ' · ',
@@ -122,6 +122,12 @@ function vertragsBlock(daten, sub, viewAs) {
sub.paymentProblem ? zahlungsKnopf(viewAs) : null) sub.paymentProblem ? zahlungsKnopf(viewAs) : null)
} }
/** „ändern“ am E-Mail-Speicher: öffnet die Unterseite #speicher (Navigation leichter V7). Mit Zusatztaste bleibt es ein normaler Link. */
function speicherLink() {
const ohneZusatz = (ev) => !(ev.defaultPrevented || ev.button > 0 || ev.metaKey || ev.ctrlKey || ev.shiftKey || ev.altKey)
return h('a', { href: '#speicher', 'data-aktion': 'speicher', on: { click: (ev) => { if (!ohneZusatz(ev)) return; ev.preventDefault(); navigiere('#speicher') } } }, 'ändern')
}
function gutschriftLink() { function gutschriftLink() {
const badge = h('span', { class: 'kn-badge', 'data-badge': true, hidden: true }) const badge = h('span', { class: 'kn-badge', 'data-badge': true, hidden: true })
const link = h('a', { class: `${K.knopf} kn-gutschrift-link`, ...zuAnsicht('takeover') }, 'Übernahmegutschrift beantragen') const link = h('a', { class: `${K.knopf} kn-gutschrift-link`, ...zuAnsicht('takeover') }, 'Übernahmegutschrift beantragen')
+38 -14
View File
@@ -3,24 +3,23 @@ import assert from 'node:assert/strict'
import { zeigeVertraege, sichtbareReiter, waehleReiter, AUF_ABO_WARTEN } from '../public/neu/ansicht-vertraege.js' import { zeigeVertraege, sichtbareReiter, waehleReiter, AUF_ABO_WARTEN } from '../public/neu/ansicht-vertraege.js'
import { merkeHinweis } from '../public/neu/ansicht-hilfen.js' import { merkeHinweis } from '../public/neu/ansicht-hilfen.js'
import { routeAusHash } from '../public/neu/routen.js' import { routeAusHash } from '../public/neu/routen.js'
import { mit, me, abo, aboAntwort, rechnung, neueWurzel, LIMITS } from './support/knDaten.js' import { mit, me, abo, aboAntwort, ledger, rechnung, neueWurzel, LIMITS } from './support/knDaten.js'
const VIER = ['rechnungen', 'abo', 'stunden', 'email'] const ZWEI = ['abo', 'rechnungen']
test('sichtbareReiter nach Abo', () => { test('sichtbareReiter: immer Abo und Rechnungen (Navigation leichter V7), unabhängig vom Abo', () => {
assert.deepEqual([null, aboAntwort(), aboAntwort({ subscription: abo({ active: false }) })].map((d) => sichtbareReiter(d)), [['rechnungen', 'abo'], ['rechnungen', 'abo'], ['rechnungen', 'abo']]) const faelle = [null, aboAntwort(), aboAntwort({ subscription: abo({ active: false }) }), aboAntwort({ subscription: abo({ produkt: 'hosting' }) }), aboAntwort({ subscription: abo() })]
assert.deepEqual(sichtbareReiter(aboAntwort({ subscription: abo({ produkt: 'hosting' }) })), ['rechnungen', 'abo', 'email']) for (const d of faelle) assert.deepEqual(sichtbareReiter(d), ZWEI)
assert.deepEqual(sichtbareReiter(aboAntwort({ subscription: abo() })), VIER)
}) })
test('Vorbedingung (VF8): der Bereichs-Hash hat keinen Reiter, die Reiter-Hashes haben einen', () => { test('Vorbedingung (VF8): der Bereichs-Hash hat keinen Reiter, die Reiter-Hashes haben einen', () => {
assert.equal(routeAusHash('#vertraege').reiter, undefined) assert.equal(routeAusHash('#vertraege').reiter, undefined)
for (const [hash, reiter] of [['#abo', 'abo'], ['#wartung', 'abo'], ['#rechnungen', 'rechnungen'], ['#stunden', 'stunden'], ['#speicher', 'email']]) assert.equal(routeAusHash(hash).reiter, reiter, hash) for (const [hash, reiter] of [['#abo', 'abo'], ['#wartung', 'abo'], ['#rechnungen', 'rechnungen'], ['#stunden', 'stunden']]) assert.equal(routeAusHash(hash).reiter, reiter, hash)
assert.deepEqual(routeAusHash('#speicher'), { bereich: 'vertraege', etage: 'speicher', query: '' }) // der Speicher ist eine Unterseite
}) })
test('waehleReiter: nur der Bereichs-Einstieg springt, jede ausdrückliche Wahl bleibt (Review Focus 2)', () => { test('waehleReiter: nur der Bereichs-Einstieg springt, jede ausdrückliche Wahl bleibt (Review Focus 2)', () => {
const w = (o) => waehleReiter({ routeReiter: undefined, sichtbar: VIER, offeneRechnungen: 1, ...o }) const w = (o) => waehleReiter({ routeReiter: undefined, sichtbar: ZWEI, offeneRechnungen: 1, ...o })
assert.equal(w({}), 'rechnungen'); assert.equal(w({ offeneRechnungen: 0 }), 'abo') assert.equal(w({}), 'rechnungen'); assert.equal(w({ offeneRechnungen: 0 }), 'abo')
assert.equal(w({ routeReiter: 'abo' }), 'abo') // #abo, #wartung und der Klick auf „Abo“ trotz offener Rechnung assert.equal(w({ routeReiter: 'abo' }), 'abo') // #abo, #wartung und der Klick auf „Abo“ trotz offener Rechnung
assert.equal(w({ routeReiter: 'stunden' }), 'stunden'); assert.equal(w({ routeReiter: 'email' }), 'email'); assert.equal(w({ routeReiter: 'rechnungen', offeneRechnungen: 0 }), 'rechnungen') assert.equal(w({ routeReiter: 'stunden' }), 'abo'); assert.equal(w({ routeReiter: 'rechnungen', offeneRechnungen: 0 }), 'rechnungen') // #stunden öffnet das Abo
assert.equal(w({ routeReiter: 'stunden', sichtbar: ['rechnungen', 'abo'] }), 'abo')
}) })
const SERVER = { 'GET /api/abo': aboAntwort(), 'GET /api/invoices': { invoices: [rechnung({ balance: 50 })], incomplete: false }, 'GET /api/auftraege': { aktiv: false }, 'GET /api/abo/takeover-credits': { requests: [], limits: LIMITS } } const SERVER = { 'GET /api/abo': aboAntwort(), 'GET /api/invoices': { invoices: [rechnung({ balance: 50 })], incomplete: false }, 'GET /api/auftraege': { aktiv: false }, 'GET /api/abo/takeover-credits': { requests: [], limits: LIMITS } }
const zeige = async (fx, route = {}) => { const w = neueWurzel(fx); await zeigeVertraege(w, { route: { bereich: 'vertraege', query: '', ...route }, me: me(), nav: 'tab' }); await fx.ruhe(); return w } const zeige = async (fx, route = {}) => { const w = neueWurzel(fx); await zeigeVertraege(w, { route: { bereich: 'vertraege', query: '', ...route }, me: me(), nav: 'tab' }); await fx.ruhe(); return w }
@@ -29,7 +28,7 @@ const WOHIN = (fx) => { fx.window.history.replaceState = (_s, _t, url) => { fx.w
test('Verträge: Reiter in fester Reihenfolge, Kündigen-Link unter jedem Reiter, ausdrückliche Reiterwahl bleibt trotz offener Rechnung', mit(SERVER, async (fx) => { test('Verträge: Reiter in fester Reihenfolge, Kündigen-Link unter jedem Reiter, ausdrückliche Reiterwahl bleibt trotz offener Rechnung', mit(SERVER, async (fx) => {
for (const [route, gewaehlt] of [[{ reiter: 'abo' }, 'Abo'], [{ reiter: 'rechnungen' }, 'Rechnungen'], [{}, 'Rechnungen']]) { // {} = Bereichs-Einstieg for (const [route, gewaehlt] of [[{ reiter: 'abo' }, 'Abo'], [{ reiter: 'rechnungen' }, 'Rechnungen'], [{}, 'Rechnungen']]) { // {} = Bereichs-Einstieg
const w = await zeige(fx, route) const w = await zeige(fx, route)
assert.deepEqual(fx.alle(w, '[role="tab"]').map((t) => t.textContent), ['Rechnungen', 'Abo']) assert.deepEqual(fx.alle(w, '[role="tab"]').map((t) => t.textContent), ['Abo', 'Rechnungen'])
assert.equal(aktiv(fx, w), gewaehlt) assert.equal(aktiv(fx, w), gewaehlt)
assert.equal(fx.finde(w, 'a[href="/kuendigen.html"]').textContent, 'Verträge hier kündigen') assert.equal(fx.finde(w, 'a[href="/kuendigen.html"]').textContent, 'Verträge hier kündigen')
} }
@@ -38,9 +37,10 @@ test('Verträge: die Route entscheidet, die Adressleiste nicht (keine zweite Wah
fx.window.location.hash = '#vertraege'; assert.equal(aktiv(fx, await zeige(fx, { reiter: 'abo' })), 'Abo') // widerspricht der Route absichtlich fx.window.location.hash = '#vertraege'; assert.equal(aktiv(fx, await zeige(fx, { reiter: 'abo' })), 'Abo') // widerspricht der Route absichtlich
fx.window.location.hash = '#abo'; assert.equal(aktiv(fx, await zeige(fx, {})), 'Rechnungen') fx.window.location.hash = '#abo'; assert.equal(aktiv(fx, await zeige(fx, {})), 'Rechnungen')
})) }))
test('Reiterklick navigiert ausdrücklich und ersetzt: #rechnungen, #abo, #stunden, #speicher', mit({ ...SERVER, 'GET /api/abo': aboAntwort({ subscription: abo() }) }, async (fx) => { test('Reiterklick navigiert ausdrücklich und ersetzt: #abo, #rechnungen; auch mit Wartungs-Abo nur zwei Reiter', mit({ ...SERVER, 'GET /api/abo': aboAntwort({ subscription: abo() }) }, async (fx) => {
WOHIN(fx); const w = await zeige(fx, { reiter: 'rechnungen' }) WOHIN(fx); const w = await zeige(fx, { reiter: 'rechnungen' })
for (const [label, hash] of [['Abo', '#abo'], ['Stunden', '#stunden'], ['E-Mail', '#speicher'], ['Rechnungen', '#rechnungen']]) { assert.deepEqual(fx.alle(w, '[role="tab"]').map((t) => t.textContent), ['Abo', 'Rechnungen'])
for (const [label, hash] of [['Abo', '#abo'], ['Rechnungen', '#rechnungen']]) {
await fx.klick(fx.finde(w, (e) => e.getAttribute?.('role') === 'tab' && e.textContent === label)); assert.equal(fx.window.location.hash, hash, label) await fx.klick(fx.finde(w, (e) => e.getAttribute?.('role') === 'tab' && e.textContent === label)); assert.equal(fx.window.location.hash, hash, label)
} }
})) }))
@@ -48,9 +48,33 @@ test('Verträge: /api/abo fällt aus, Reiter, Rechnungen und Kündigen bleiben',
const w = await zeige(fx, { reiter: 'rechnungen' }) const w = await zeige(fx, { reiter: 'rechnungen' })
assert.match(fx.text(w), /Deine Verträge konnten gerade nicht geladen werden\./); assert.ok(fx.finde(w, 'a[href="/kuendigen.html"]')); assert.ok(fx.finde(w, 'tbody tr')) assert.match(fx.text(w), /Deine Verträge konnten gerade nicht geladen werden\./); assert.ok(fx.finde(w, 'a[href="/kuendigen.html"]')); assert.ok(fx.finde(w, 'tbody tr'))
})) }))
test('route.reiter stunden ohne Wartung: Abo-Reiter mit Hinweis', mit({ ...SERVER, 'GET /api/abo': aboAntwort({ subscription: abo({ produkt: 'hosting' }) }) }, async (fx) => { test('route.reiter stunden ohne Wartung: Abo-Reiter mit Hinweis, kein Stundenkonto', mit({ ...SERVER, 'GET /api/abo': aboAntwort({ subscription: abo({ produkt: 'hosting' }) }) }, async (fx) => {
const w = await zeige(fx, { reiter: 'stunden' }) const w = await zeige(fx, { reiter: 'stunden' })
assert.equal(aktiv(fx, w), 'Abo'); assert.match(fx.text(w), /Ein Stundenkonto gibt es im Wartungs-Abo\./) assert.equal(aktiv(fx, w), 'Abo'); assert.match(fx.text(w), /Ein Stundenkonto gibt es im Wartungs-Abo\./)
assert.equal(fx.finde(w, '#kn-stundenkonto'), null)
}))
const LEDGER = ledger({ balanceMinutes: 90, usedMinutes: 120, usage: [], credits: [] })
test('Navigation leichter V7: Wartungs-Abo zeigt das Stundenkonto unter dem Abo, Warnungen und Bedingungen-Link nur einmal', mit({ ...SERVER, 'GET /api/abo': aboAntwort({ subscription: abo({ paymentProblem: true }), ledger: LEDGER }) }, async (fx) => {
for (const route of [{ reiter: 'abo' }, { reiter: 'stunden' }]) {
const w = await zeige(fx, route)
assert.equal(aktiv(fx, w), 'Abo')
const konto = fx.finde(w, '#kn-stundenkonto')
assert.ok(konto, 'Abschnitt Stundenkonto'); assert.equal(fx.finde(konto, 'h2').textContent, 'Stundenkonto')
assert.match(fx.text(konto), /verfügbare Zeit/); assert.match(fx.text(konto), /Verlauf/)
assert.equal(fx.text(w).split('Die letzte Abbuchung ist fehlgeschlagen').length - 1, 1, 'Zahlungswarnung genau einmal')
assert.doesNotMatch(fx.text(konto), /Leistungen & Bedingungen/)
assert.doesNotMatch(fx.text(w), /Ein Stundenkonto gibt es im Wartungs-Abo/)
}
}))
let speicherAbo = null
test('Navigation leichter V7: „ändern“ am E-Mail-Speicher führt auf #speicher, mit Hosting- und Wartungs-Abo', mit({ ...SERVER, 'GET /api/abo': () => ({ body: speicherAbo }) }, async (fx) => {
for (const produkt of ['hosting', 'wartung']) {
speicherAbo = aboAntwort({ subscription: abo({ produkt }), ledger: LEDGER })
WOHIN(fx); const w = await zeige(fx, { reiter: 'abo' })
const link = fx.finde(w, 'a[data-aktion="speicher"]')
assert.deepEqual([link.textContent, link.getAttribute('href')], ['ändern', '#speicher'], produkt)
await fx.klick(link); assert.equal(fx.window.location.hash, '#speicher', produkt)
}
})) }))
test('Hinweis aus einer Aktion steht genau einmal über den Reitern', mit(SERVER, async (fx) => { test('Hinweis aus einer Aktion steht genau einmal über den Reitern', mit(SERVER, async (fx) => {
merkeHinweis('Dein Wechsel ist abgeschlossen.'); assert.match(fx.text(await zeige(fx, { reiter: 'abo' })), /Dein Wechsel ist abgeschlossen\./) merkeHinweis('Dein Wechsel ist abgeschlossen.'); assert.match(fx.text(await zeige(fx, { reiter: 'abo' })), /Dein Wechsel ist abgeschlossen\./)
+2 -2
View File
@@ -33,8 +33,8 @@ test('F3: Fokusring der Shell-Reiter liegt innen (Spezifität über .kn [tabinde
// keine Regel mit niedrigerer Spezifität, die den Versatz setzt // keine Regel mit niedrigerer Spezifität, die den Versatz setzt
assert.doesNotMatch(HUELLE, /\n\.kn-t:focus-visible/) assert.doesNotMatch(HUELLE, /\n\.kn-t:focus-visible/)
assert.doesNotMatch(BASIS, /\n\.kn-reiter-t:focus-visible/) assert.doesNotMatch(BASIS, /\n\.kn-reiter-t:focus-visible/)
// der aktive Reiter liegt auf dem schwarzen Gleiter: heller Innenring dort // Navigation leichter V3: der Gleiter ist nur noch eine Linie unter dem Wort, der Ring des Reiters selbst genügt (kein heller Ring am Gleiter)
assert.match(HUELLE, /\.kn \.kn-balken:has\(\.kn-t:focus-visible\) \.kn-gleiter\s*\{[^}]*outline:\s*2px solid #fff[^}]*outline-offset:\s*-3px/) assert.doesNotMatch(HUELLE, /\.kn-gleiter\s*\{[^}]*outline:\s*2px solid #fff/)
}) })
test('F3: Pflicht- und Zahlungslinks haben am Handy eine Tippfläche von 44 px', () => { test('F3: Pflicht- und Zahlungslinks haben am Handy eine Tippfläche von 44 px', () => {
+9 -6
View File
@@ -5,10 +5,11 @@ import { h } from '../public/neu/dom.js'
import { initialenVon, installAngebot, kontoEintraege, baueAvatar, baueKonto } from '../public/neu/konto.js' import { initialenVon, installAngebot, kontoEintraege, baueAvatar, baueKonto } from '../public/neu/konto.js'
import { lies, handyTeil } from './support/knHuelle.js' import { lies, handyTeil } from './support/knHuelle.js'
test('Reihenfolge, Texte und Ziele des Kontos (V3); Widerrufen immer da, "Frist läuft" nur in der Frist', () => { test('Reihenfolge, Texte und Ziele des Kontos (V3 mit Navigation leichter V8); Widerrufen immer da, "Frist läuft" nur in der Frist', () => {
const ids = (o) => kontoEintraege(o).map((e) => e.id) const ids = (o) => kontoEintraege(o).map((e) => e.id)
assert.deepEqual(ids({ fristen: null, install: null }), ['passwort', 'datenschutz', 'impressum', 'kuendigen', 'widerrufen', 'abmelden']) assert.deepEqual(ids({ fristen: null, install: null }), ['passwort', 'kuendigen', 'widerrufen', 'abmelden', 'datenschutz', 'impressum'])
assert.deepEqual(ids({ fristen: null, install: 'ios' }), ['passwort', 'datenschutz', 'impressum', 'installieren', 'kuendigen', 'widerrufen', 'abmelden']) assert.deepEqual(ids({ fristen: null, install: 'ios' }), ['passwort', 'installieren', 'kuendigen', 'widerrufen', 'abmelden', 'datenschutz', 'impressum'])
assert.deepEqual(kontoEintraege({ fristen: null, install: null }).filter((e) => e.klein).map((e) => e.id), ['datenschutz', 'impressum'])
const e = Object.fromEntries(kontoEintraege({ fristen: null, install: 'prompt' }).map((x) => [x.id, x])) const e = Object.fromEntries(kontoEintraege({ fristen: null, install: 'prompt' }).map((x) => [x.id, x]))
assert.deepEqual([e.passwort.text, e.passwort.href, e.datenschutz.href, e.impressum.href, e.kuendigen.href, e.kuendigen.betont], ['Passwort ändern', '#passwort', '/datenschutz.html', 'https://webklar.com/impressum', '/kuendigen.html', true]) assert.deepEqual([e.passwort.text, e.passwort.href, e.datenschutz.href, e.impressum.href, e.kuendigen.href, e.kuendigen.betont], ['Passwort ändern', '#passwort', '/datenschutz.html', 'https://webklar.com/impressum', '/kuendigen.html', true])
assert.deepEqual([e.widerrufen.text, e.widerrufen.href, e.widerrufen.pille, e.installieren.text, e.abmelden.text], ['Vertrag widerrufen', '/widerrufen.html', undefined, 'App installieren', 'Abmelden']) assert.deepEqual([e.widerrufen.text, e.widerrufen.href, e.widerrufen.pille, e.installieren.text, e.abmelden.text], ['Vertrag widerrufen', '/widerrufen.html', undefined, 'App installieren', 'Abmelden'])
@@ -37,12 +38,13 @@ test('baueKonto: Kopf als Text, Sheet-Verhalten, Overlay-Kopplung, Navigation, A
const k = baueKonto({ me, fristen: null, install: null, onAbmelden: () => log.push('abmelden'), onInstall() {}, overlay: { oeffne: (o) => log.push(['overlay', typeof o.schliesse]), beende: () => log.push('beende') }, navigiere: (hash) => log.push(['geheZu', hash]) }) const k = baueKonto({ me, fristen: null, install: null, onAbmelden: () => log.push('abmelden'), onInstall() {}, overlay: { oeffne: (o) => log.push(['overlay', typeof o.schliesse]), beende: () => log.push('beende') }, navigiere: (hash) => log.push(['geheZu', hash]) })
fx.document.body.append(k.wurzel); const zeilen = () => fx.alle(k.wurzel, '.kn-konto-zeile') fx.document.body.append(k.wurzel); const zeilen = () => fx.alle(k.wurzel, '.kn-konto-zeile')
assert.deepEqual([fx.finde(k.wurzel, '.kn-konto-name').textContent, fx.finde(k.wurzel, '.kn-konto-mail').textContent], ['<img src=x onerror=alert(1)>Salon', 'a@b.de']) assert.deepEqual([fx.finde(k.wurzel, '.kn-konto-name').textContent, fx.finde(k.wurzel, '.kn-konto-mail').textContent], ['<img src=x onerror=alert(1)>Salon', 'a@b.de'])
assert.deepEqual(zeilen().map((z) => z.textContent), ['Passwort ändern', 'Datenschutz', 'Impressum', 'Verträge kündigen', 'Vertrag widerrufen', 'Abmelden']) assert.deepEqual(zeilen().map((z) => z.textContent), ['Passwort ändern', 'Verträge kündigen', 'Vertrag widerrufen', 'Abmelden', 'Datenschutz', 'Impressum'])
assert.deepEqual(zeilen().filter((z) => z.classList.contains('kn-konto-zeile--klein')).map((z) => z.textContent), ['Datenschutz', 'Impressum'])
assert.deepEqual([k.wurzel.getAttribute('role'), k.wurzel.getAttribute('aria-modal')], ['dialog', 'true']) assert.deepEqual([k.wurzel.getAttribute('role'), k.wurzel.getAttribute('aria-modal')], ['dialog', 'true'])
k.oeffne(trigger); assert.deepEqual([k.istOffen(), trigger.getAttribute('aria-expanded'), log[0]], [true, 'true', ['overlay', 'function']]) k.oeffne(trigger); assert.deepEqual([k.istOffen(), trigger.getAttribute('aria-expanded'), log[0]], [true, 'true', ['overlay', 'function']])
await fx.sende(k.wurzel, 'keydown', { key: 'Escape' }); assert.deepEqual([k.istOffen(), log.at(-1), fx.document.activeElement === trigger], [false, 'beende', true]) await fx.sende(k.wurzel, 'keydown', { key: 'Escape' }); assert.deepEqual([k.istOffen(), log.at(-1), fx.document.activeElement === trigger], [false, 'beende', true])
k.oeffne(trigger); await fx.klick(zeilen()[0]); assert.deepEqual(log.at(-1), ['geheZu', '#passwort']) k.oeffne(trigger); await fx.klick(zeilen()[0]); assert.deepEqual(log.at(-1), ['geheZu', '#passwort'])
await fx.klick(zeilen().at(-1)); assert.equal(log.at(-1), 'abmelden') await fx.klick(zeilen().find((z) => z.getAttribute('data-konto') === 'abmelden')); assert.equal(log.at(-1), 'abmelden')
k.aktualisiere({ fristen: { anzeigen: true }, install: 'ios' }); assert.ok(k.istOffen()); assert.match(fx.text(k.wurzel), /App installieren[\s\S]*Vertrag widerrufen.*Frist läuft/) k.aktualisiere({ fristen: { anzeigen: true }, install: 'ios' }); assert.ok(k.istOffen()); assert.match(fx.text(k.wurzel), /App installieren[\s\S]*Vertrag widerrufen.*Frist läuft/)
await fx.klick(fx.finde(k.wurzel, '.kn-konto-scrim')); assert.equal(k.istOffen(), false) await fx.klick(fx.finde(k.wurzel, '.kn-konto-scrim')); assert.equal(k.istOffen(), false)
} finally { fx.restore() } } finally { fx.restore() }
@@ -62,7 +64,8 @@ test('Konto: Router schließt ohne zweites Zurück, Links öffnen richtig, Tab b
overlay: { oeffne: (o) => { vomRouter = o.schliesse }, beende: () => log.push('beende') }, navigiere() {} }) overlay: { oeffne: (o) => { vomRouter = o.schliesse }, beende: () => log.push('beende') }, navigiere() {} })
fx.document.body.append(k.wurzel); const zeilen = () => fx.alle(k.wurzel, '.kn-konto-zeile') fx.document.body.append(k.wurzel); const zeilen = () => fx.alle(k.wurzel, '.kn-konto-zeile')
assert.equal(fx.finde(k.wurzel, '.kn-konto-name').textContent, 'a@b.de') // ohne Firma und Namen die E-Mail assert.equal(fx.finde(k.wurzel, '.kn-konto-name').textContent, 'a@b.de') // ohne Firma und Namen die E-Mail
const [pw, ds, im, inst] = zeilen() const zeile = (id) => zeilen().find((z) => z.getAttribute('data-konto') === id)
const [pw, ds, im, inst] = ['passwort', 'datenschutz', 'impressum', 'installieren'].map(zeile)
assert.deepEqual([ds.getAttribute('target'), ds.getAttribute('rel'), im.getAttribute('target'), pw.hasAttribute('target')], ['_blank', 'noopener', '_blank', false]) assert.deepEqual([ds.getAttribute('target'), ds.getAttribute('rel'), im.getAttribute('target'), pw.hasAttribute('target')], ['_blank', 'noopener', '_blank', false])
assert.match(inst.textContent, /Tippe auf Teilen und dann auf Zum Home-Bildschirm\./) assert.match(inst.textContent, /Tippe auf Teilen und dann auf Zum Home-Bildschirm\./)
k.oeffne(trigger); assert.equal(fx.document.activeElement, pw) // Fokus auf der ersten Zeile k.oeffne(trigger); assert.equal(fx.document.activeElement, pw) // Fokus auf der ersten Zeile
+6 -2
View File
@@ -36,12 +36,16 @@ test('tastaturZustand: öffnen unter voll-150, Android-Geste ohne Blur, Drehen,
test('huelle.css: Desktop ist Grundregel, Handy überschreibt (Abfrage wörtlich, 44 px, Safe-Area, Reel, Gleiter, Menü, Bewegung)', () => { test('huelle.css: Desktop ist Grundregel, Handy überschreibt (Abfrage wörtlich, 44 px, Safe-Area, Reel, Gleiter, Menü, Bewegung)', () => {
assert.equal(`@media ${HANDY_ABFRAGE}`, HANDY) assert.equal(`@media ${HANDY_ABFRAGE}`, HANDY)
const css = lies('huelle.css'); const handy = handyTeil(css) const css = lies('huelle.css'); const handy = handyTeil(css)
assert.match(css, /\.kn-t\s*\{(?=[^}]*min-height:\s*44px)(?=[^}]*font-size:\s*15px)(?=[^}]*font-weight:\s*600)/s) assert.match(css, /\.kn-t\s*\{(?=[^}]*min-height:\s*44px)(?=[^}]*font-size:\s*15px)(?=[^}]*font-weight:\s*500)/s)
assert.match(css, /\.kn-t\[aria-selected="true"\]\s*\{(?=[^}]*color:\s*var\(--kn-text\))(?=[^}]*font-weight:\s*600)/s) // Navigation leichter V3: aktives Wort dunkel und fett
assert.match(css, /\.kn-schrift-klein \.kn-t\s*\{[^}]*font-size:\s*14px/s) assert.match(css, /\.kn-schrift-klein \.kn-t\s*\{[^}]*font-size:\s*14px/s)
assert.match(css, /\.kn-menue\s*\{(?=[^}]*width:\s*var\(--kn-sidebar-w\))(?=[^}]*border-right:\s*1px solid var\(--kn-line\))/s) assert.match(css, /\.kn-menue\s*\{(?=[^}]*width:\s*var\(--kn-sidebar-w\))(?=[^}]*border-right:\s*1px solid var\(--kn-line\))/s)
assert.match(handy, /\.kn-menue\s*\{[^}]*display:\s*none/s); assert.match(handy, /\.kn-leiste\s*\{[^}]*padding-bottom:\s*env\(safe-area-inset-bottom\)/s) assert.match(handy, /\.kn-menue\s*\{[^}]*display:\s*none/s); assert.match(handy, /\.kn-leiste\s*\{[^}]*padding-bottom:\s*env\(safe-area-inset-bottom\)/s)
assert.match(css, /\[data-etage="offen"\][^{]*\{[^}]*translateY\(-56px\)/s) assert.match(css, /\[data-etage="offen"\][^{]*\{[^}]*translateY\(-56px\)/s)
assert.match(css, /\.kn-gleiter\s*\{(?=[^}]*top:\s*4px)(?=[^}]*height:\s*36px)(?=[^}]*border-radius:\s*6px)(?=[^}]*background:\s*var\(--kn-accent\))/s) // Navigation leichter V3: der Gleiter ist eine dünne Linie unter dem Wort, die weiße Overlay-Kopie wird nicht gezeigt
assert.match(css, /\.kn-gleiter\s*\{(?=[^}]*bottom:\s*2px)(?=[^}]*height:\s*2px)(?=[^}]*background:\s*var\(--kn-accent\))/s)
assert.match(css, /\.kn-overlay\s*\{[^}]*display:\s*none/s)
assert.doesNotMatch(css, /\.kn-t\[aria-selected="true"\]\s*\{[^}]*#fff/s) // das aktive Wort ist nie mehr weiß
assert.match(css, /\.kn-tastatur \.kn-leiste[^{]*\{[^}]*(translateY\(110%\)|display:\s*none)/s) assert.match(css, /\.kn-tastatur \.kn-leiste[^{]*\{[^}]*(translateY\(110%\)|display:\s*none)/s)
assert.match(css, /@media \(prefers-reduced-motion: reduce\)[\s\S]*150ms/) assert.match(css, /@media \(prefers-reduced-motion: reduce\)[\s\S]*150ms/)
}) })
+3 -2
View File
@@ -10,7 +10,8 @@ const FAELLE = [ // Alte Links (Review Focus 1)
['#news?bestellt=1', { bereich: 'funktionen', etage: 'news', query: 'bestellt=1' }], ['#news?bestellt=1', { bereich: 'funktionen', etage: 'news', query: 'bestellt=1' }],
['#funktionen', { bereich: 'funktionen', reiter: 'meine', query: '' }], ['#entdecken', { bereich: 'funktionen', reiter: 'entdecken', query: '' }], ['#funktionen', { bereich: 'funktionen', reiter: 'meine', query: '' }], ['#entdecken', { bereich: 'funktionen', reiter: 'entdecken', query: '' }],
['#rechnungen', { bereich: 'vertraege', reiter: 'rechnungen', query: '' }], ['#stunden', { bereich: 'vertraege', reiter: 'stunden', query: '' }], ['#rechnungen', { bereich: 'vertraege', reiter: 'rechnungen', query: '' }], ['#stunden', { bereich: 'vertraege', reiter: 'stunden', query: '' }],
['#speicher', { bereich: 'vertraege', reiter: 'email', query: '' }], ['#gutschrift', { bereich: 'vertraege', etage: 'gutschrift', query: '' }], ['#speicher', { bereich: 'vertraege', etage: 'speicher', query: '' }], // Navigation leichter V7: Unterseite statt Reiter E-Mail
['#gutschrift', { bereich: 'vertraege', etage: 'gutschrift', query: '' }],
['#wartung', { bereich: 'vertraege', reiter: 'abo', query: '' }], ['#abo', { bereich: 'vertraege', reiter: 'abo', query: '' }], ['#vertraege', { bereich: 'vertraege', query: '' }], // #vertraege ohne Reiter: die Ansicht wählt den Standardreiter ['#wartung', { bereich: 'vertraege', reiter: 'abo', query: '' }], ['#abo', { bereich: 'vertraege', reiter: 'abo', query: '' }], ['#vertraege', { bereich: 'vertraege', query: '' }], // #vertraege ohne Reiter: die Ansicht wählt den Standardreiter
['#fortschritt', { bereich: 'website', scroll: 'aktivitaet', query: '' }], ['#projekt?id=p1', { bereich: 'website', etage: 'projekt', query: 'id=p1' }], ['#fortschritt', { bereich: 'website', scroll: 'aktivitaet', query: '' }], ['#projekt?id=p1', { bereich: 'website', etage: 'projekt', query: 'id=p1' }],
['#Chat', { bereich: 'chat', query: '' }], ['#passwort', { bereich: 'website', etage: 'passwort', query: '' }], ['#Chat', { bereich: 'chat', query: '' }], ['#passwort', { bereich: 'website', etage: 'passwort', query: '' }],
@@ -69,5 +70,5 @@ test('zeigtWiderrufsstreifen: nicht auf Abo bestellen, Wechsel, Zusatzspeicher u
assert.equal(zeigtWiderrufsstreifen(undefined), true) // ohne Route nichts verstecken assert.equal(zeigtWiderrufsstreifen(undefined), true) // ohne Route nichts verstecken
// jede Etage der Tabelle ist entschieden: versteckt sind genau die Bestellseiten // jede Etage der Tabelle ist entschieden: versteckt sind genau die Bestellseiten
const versteckt = [...new Set(Object.values(ROUTEN).filter((r) => !zeigtWiderrufsstreifen(r)).map((r) => r.etage ?? `reiter:${r.reiter}`))].sort() const versteckt = [...new Set(Object.values(ROUTEN).filter((r) => !zeigtWiderrufsstreifen(r)).map((r) => r.etage ?? `reiter:${r.reiter}`))].sort()
assert.deepEqual(versteckt, ['bestellen', 'gutschrift', 'reiter:email', 'wechsel']) assert.deepEqual(versteckt, ['bestellen', 'gutschrift', 'speicher', 'wechsel'])
}) })