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
const knopf = h('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',
on: {
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
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. */
@@ -84,8 +84,7 @@ function baueSpeicherPanel(daten, { viewAs, kundenart, notice, nachErfolg }) {
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 (pendingReduction) status.push(h('p', { class: 'kn-hinweis' }, `Geplant: ab ${fmtTagMonat(pendingReduction.effectiveAt)} ${gbOf(Number(pendingReduction.units) || 0)} GB.`))
ersetze(panel,
h('h3', null, 'E-Mail-Speicher'),
ersetze(panel, // die Überschrift „E-Mail-Speicher“ trägt die Unterseite selbst
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,
!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)}`}`)))
}
/** 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
laeufe.set(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 l = abo.ledger
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
}
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)]
ersetze(wurzel,
...aboWarnungen(sub),
...(eingebettet ? [] : aboWarnungen(sub)),
h('div', { class: 'kn-statistik' },
stat(fmtStd(l.balanceMinutes), 'verfügbare Zeit'),
stat(fmtStd(l.usedMinutes), 'bisher genutzt'),
l.nextExpiry ? stat(fmtStd(l.nextExpiry.minutes), `verfallen am ${fmtDatum(l.nextExpiry.at)}`) : 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-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('h2', { class: K.abschnitt }, 'Verlauf'),
h(eingebettet ? 'h3' : 'h2', { class: K.abschnitt }, 'Verlauf'),
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('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
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 und Gutschrift sind Etagen, die die Hülle direkt ruft. */
/* Kundenansicht neu (P2): Bereich Verträge mit den Reitern Abo und Rechnungen (Navigation leichter V7, vorher dazu Stunden und E-Mail).
Das Stundenkonto des Wartungs-Abos steht als Abschnitt unter dem Abo; #stunden öffnet das Abo und springt dorthin. Der E-Mail-Speicher ist
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 { h, leeren } from './dom.js'
@@ -11,26 +13,24 @@ import { offeneRechnungen } from './rechnung-offen.js'
import { zeigeRechnungen } from './vertraege-rechnungen.js'
import { zeigeAbo } from './vertraege-abo.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). */
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)
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
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. */
export function sichtbareReiter(daten) {
const sub = daten?.subscription
const reiter = ['rechnungen', 'abo']
if (!sub?.active) return reiter
if (sub.productKey === 'hosting') return [...reiter, 'email']
return [...reiter, 'stunden', 'email']
/** Sichtbare Reiter: immer Abo und Rechnungen (Navigation leichter V7). Stunden stehen im Abo, der E-Mail-Speicher ist die Etage #speicher. */
export function sichtbareReiter() {
return ['abo', 'rechnungen']
}
/** 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. */
export function waehleReiter({ routeReiter, sichtbar, offeneRechnungen: offen }) {
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 offen = offeneRechnungen(rechnungen).length
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 inhalt = h('div', { class: 'kn-reiter-inhalt' })
@@ -87,14 +88,20 @@ export async function zeigeVertraege(wurzel, { route, me, nav } = {}) {
leeren(inhalt)
if (key === 'rechnungen') return zeigeRechnungen(inhalt, { me, antwort: rechnungen ?? undefined })
if (!daten) return Promise.resolve()
if (key === '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' })
inhalt.append(platz)
return zeigeAbo(platz, { daten, me })
if (stundenGewuenscht && !stundenkonto) inhalt.append(h('p', { class: 'kn-hinweis' }, 'Ein Stundenkonto gibt es im Wartungs-Abo.'))
const platz = h('div', { class: 'kn-reiter-abo' })
inhalt.append(platz)
const fertig = [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 zeigeSpeicher(inhalt, { route, me, nav, daten })
return Promise.all(fertig)
}
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::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: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[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-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-zu { min-height: 44px; }
.kn-konto-zeile { min-height: 46px; }
.kn-konto-zeile--klein { min-height: 44px; }
}
@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)); }
}
/* ---------- 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-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::-webkit-scrollbar { display: none; }
.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-overlay { position: absolute; top: -4px; left: 0; display: flex; height: 44px; transition: transform 550ms var(--kn-feder); }
.kn-fb .kn-overlay { display: none; }
.kn-fb .kn-gleiter { z-index: 0; }
.kn-fb .kn-t[aria-selected="true"] { color: #fff; }
.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 { display: none; }
.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
(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
auf dem schwarzen Gleiter: dort zeichnet der Gleiter einen hellen Innenring. */
(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. */
.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-overlay .kn-t { color: #fff; }
.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-overlay .kn-zahl { box-shadow: 0 0 0 2px var(--kn-accent); }
.kn-reiter-t .kn-zahl { top: -7px; }
.kn-zahl--weg { transform: scale(0); transition: transform 250ms; animation: none; }
@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'],
wechsel: ['/neu/ansicht-wechsel.js', 'zeigeWechsel', '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)
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.
Reihenfolge der Zeilen (V3): Passwort ändern, Datenschutz, Impressum, 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.
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; 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. */
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.'
/** 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 } = {}) {
const frist = fristen?.anzeigen === true
return [
{ 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 } : {}) }] : []),
{ 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: '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) {
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)
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,
+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
(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-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 [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-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 + .tm-seg { box-shadow: -1px 0 0 var(--kn-line); }
.kn .tm-seg:hover { background: var(--kn-bg-hover); }
.kn .tm-seg[aria-pressed='true'] { background: var(--kn-accent); color: #fff; }
.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: 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:hover { color: var(--kn-text); }
.kn .tm-seg[aria-pressed='true'] { background: var(--kn-bg); color: var(--kn-text); font-weight: 600; box-shadow: var(--kn-ring); }
.kn .tm-nav { display: inline-flex; align-items: center; gap: 6px; }
.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; }
@@ -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-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-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:hover { background: var(--kn-bg-hover); }
.kn .tm-chip[aria-pressed='true'] { background: var(--kn-accent); color: #fff; }
.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); color: var(--kn-text); }
.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-werkzeuge { display: contents; }
.kn .tm-werkzeuge button[aria-expanded='true'] { background: var(--kn-bg-hover); }
@@ -340,6 +341,8 @@
.kn .tm-leiste { gap: 8px; }
.kn .tm-segment { width: 100%; }
.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 .tm-heute { flex: 1; }
.kn .tm-sprung { width: 100%; }
+3 -2
View File
@@ -1,7 +1,8 @@
/* 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:
ein schwarzer 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;
ein Gleiter fährt federnd unter das aktive Wort, darin eine Overlay-Kopie der Wörter in Weiß mit Gegenbewegung
(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. */
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' },
rechnungen: { bereich: 'vertraege', reiter: 'rechnungen' },
stunden: { bereich: 'vertraege', reiter: 'stunden' },
speicher: { bereich: 'vertraege', reiter: 'email' },
speicher: { bereich: 'vertraege', etage: 'speicher' },
gutschrift: { bereich: 'vertraege', etage: 'gutschrift' },
wechsel: { bereich: 'vertraege', etage: 'wechsel' },
'bestellen-hosting': { bereich: 'vertraege', etage: 'bestellen', produkt: 'hosting' },
@@ -42,6 +42,7 @@ const TITEL_BEREICH = { website: 'Website', funktionen: 'Funktionen', chat: 'Cha
const TITEL_ETAGE = {
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',
speicher: 'E-Mail-Speicher',
}
// 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] ?? ''
}
/** Etagen, auf denen etwas bestellt wird: Abo bestellen, Wechsel und die Übernahmegutschrift. Dazu der Reiter E-Mail der Verträge (Zusatzspeicher). */
const BESTELL_ETAGEN = ['bestellen', 'wechsel', 'gutschrift']
/** 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', 'speicher']
/**
* 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) {
if (!route) return true
if (BESTELL_ETAGEN.includes(route.etage)) return false
return !(route.bereich === 'vertraege' && route.reiter === 'email')
return !BESTELL_ETAGEN.includes(route.etage)
}
+8 -2
View File
@@ -3,7 +3,7 @@
import { api } from './api.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 TEXT_UST = 'Gemäß § 19 UStG ohne Umsatzsteuer.'
@@ -113,7 +113,7 @@ function vertragsBlock(daten, sub, viewAs) {
fakt(gekuendigt(sub) ? 'Leistungen' : 'Abrechnung', aboZeile(sub)),
fakt('Laufzeit', laufzeitText(sub)),
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),
h('p', { class: 'kn-links' },
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)
}
/** „ä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() {
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')
+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 { merkeHinweis } from '../public/neu/ansicht-hilfen.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']
test('sichtbareReiter nach Abo', () => {
assert.deepEqual([null, aboAntwort(), aboAntwort({ subscription: abo({ active: false }) })].map((d) => sichtbareReiter(d)), [['rechnungen', 'abo'], ['rechnungen', 'abo'], ['rechnungen', 'abo']])
assert.deepEqual(sichtbareReiter(aboAntwort({ subscription: abo({ produkt: 'hosting' }) })), ['rechnungen', 'abo', 'email'])
assert.deepEqual(sichtbareReiter(aboAntwort({ subscription: abo() })), VIER)
const ZWEI = ['abo', 'rechnungen']
test('sichtbareReiter: immer Abo und Rechnungen (Navigation leichter V7), unabhängig vom Abo', () => {
const faelle = [null, aboAntwort(), aboAntwort({ subscription: abo({ active: false }) }), aboAntwort({ subscription: abo({ produkt: 'hosting' }) }), aboAntwort({ subscription: abo() })]
for (const d of faelle) assert.deepEqual(sichtbareReiter(d), ZWEI)
})
test('Vorbedingung (VF8): der Bereichs-Hash hat keinen Reiter, die Reiter-Hashes haben einen', () => {
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)', () => {
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({ 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', sichtbar: ['rechnungen', 'abo'] }), 'abo')
assert.equal(w({ routeReiter: 'stunden' }), 'abo'); assert.equal(w({ routeReiter: 'rechnungen', offeneRechnungen: 0 }), 'rechnungen') // #stunden öffnet das 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 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) => {
for (const [route, gewaehlt] of [[{ reiter: 'abo' }, 'Abo'], [{ reiter: 'rechnungen' }, 'Rechnungen'], [{}, 'Rechnungen']]) { // {} = Bereichs-Einstieg
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(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 = '#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' })
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)
}
}))
@@ -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' })
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' })
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) => {
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
assert.doesNotMatch(HUELLE, /\n\.kn-t:focus-visible/)
assert.doesNotMatch(BASIS, /\n\.kn-reiter-t:focus-visible/)
// der aktive Reiter liegt auf dem schwarzen Gleiter: heller Innenring dort
assert.match(HUELLE, /\.kn \.kn-balken:has\(\.kn-t:focus-visible\) \.kn-gleiter\s*\{[^}]*outline:\s*2px solid #fff[^}]*outline-offset:\s*-3px/)
// 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.doesNotMatch(HUELLE, /\.kn-gleiter\s*\{[^}]*outline:\s*2px solid #fff/)
})
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 { 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)
assert.deepEqual(ids({ fristen: null, install: null }), ['passwort', 'datenschutz', 'impressum', 'kuendigen', 'widerrufen', 'abmelden'])
assert.deepEqual(ids({ fristen: null, install: 'ios' }), ['passwort', 'datenschutz', 'impressum', 'installieren', 'kuendigen', 'widerrufen', 'abmelden'])
assert.deepEqual(ids({ fristen: null, install: null }), ['passwort', 'kuendigen', 'widerrufen', 'abmelden', 'datenschutz', 'impressum'])
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]))
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'])
@@ -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]) })
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(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'])
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])
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/)
await fx.klick(fx.finde(k.wurzel, '.kn-konto-scrim')); assert.equal(k.istOffen(), false)
} 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() {} })
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
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.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
+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)', () => {
assert.equal(`@media ${HANDY_ABFRAGE}`, HANDY)
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-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(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, /@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' }],
['#funktionen', { bereich: 'funktionen', reiter: 'meine', query: '' }], ['#entdecken', { bereich: 'funktionen', reiter: 'entdecken', 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
['#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: '' }],
@@ -69,5 +70,5 @@ test('zeigtWiderrufsstreifen: nicht auf Abo bestellen, Wechsel, Zusatzspeicher u
assert.equal(zeigtWiderrufsstreifen(undefined), true) // ohne Route nichts verstecken
// 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()
assert.deepEqual(versteckt, ['bestellen', 'gutschrift', 'reiter:email', 'wechsel'])
assert.deepEqual(versteckt, ['bestellen', 'gutschrift', 'speicher', 'wechsel'])
})