From 83ac903ab300e208c5463079a65f63d018457cd0 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 17:27:30 +0000 Subject: [PATCH] =?UTF-8?q?Navigation=20leichter:=20Handy-Leiste,=20Schwar?= =?UTF-8?q?z=20nur=20f=C3=BCr=20die=20Hauptaktion,=20Vertr=C3=A4ge=20mit?= =?UTF-8?q?=20zwei=20Reitern,=20k=C3=BCrzeres=20Konto=20(V3,=20V5,=20V7,?= =?UTF-8?q?=20V8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- public/neu/ansicht-funktionen.js | 2 +- public/neu/ansicht-speicher.js | 5 ++- public/neu/ansicht-stunden.js | 17 +++++++---- public/neu/ansicht-vertraege.js | 49 +++++++++++++++++------------- public/neu/ansichten.css | 3 ++ public/neu/huelle.css | 27 ++++++++--------- public/neu/huelle.js | 1 + public/neu/konto.js | 12 ++++---- public/neu/leiste.js | 2 +- public/neu/module.css | 21 +++++++------ public/neu/reiter.js | 5 +-- public/neu/routen.js | 10 +++--- public/neu/vertraege-abo.js | 10 ++++-- test/knAnsichtVertraege.test.js | 52 +++++++++++++++++++++++--------- test/knEfixCss.test.js | 4 +-- test/knHuelleKonto.test.js | 15 +++++---- test/knHuelleLeiste.test.js | 8 +++-- test/knHuelleRouten.test.js | 5 +-- 18 files changed, 152 insertions(+), 96 deletions(-) diff --git a/public/neu/ansicht-funktionen.js b/public/neu/ansicht-funktionen.js index 44fd5e4..5090348 100644 --- a/public/neu/ansicht-funktionen.js +++ b/public/neu/ansicht-funktionen.js @@ -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: () => { diff --git a/public/neu/ansicht-speicher.js b/public/neu/ansicht-speicher.js index 71a2929..25ca0ff 100644 --- a/public/neu/ansicht-speicher.js +++ b/public/neu/ansicht-speicher.js @@ -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, diff --git a/public/neu/ansicht-stunden.js b/public/neu/ansicht-stunden.js index 334a5eb..ca18786 100644 --- a/public/neu/ansicht-stunden.js +++ b/public/neu/ansicht-stunden.js @@ -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.'))) diff --git a/public/neu/ansicht-vertraege.js b/public/neu/ansicht-vertraege.js index 558f477..4b3afa1 100644 --- a/public/neu/ansicht-vertraege.js +++ b/public/neu/ansicht-vertraege.js @@ -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) => { diff --git a/public/neu/ansichten.css b/public/neu/ansichten.css index 7a48c8e..5e58653 100644 --- a/public/neu/ansichten.css +++ b/public/neu/ansichten.css @@ -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); } diff --git a/public/neu/huelle.css b/public/neu/huelle.css index 35fe0bd..4694888 100644 --- a/public/neu/huelle.css +++ b/public/neu/huelle.css @@ -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); } } diff --git a/public/neu/huelle.js b/public/neu/huelle.js index 3d6a280..8d1f2e2 100644 --- a/public/neu/huelle.js +++ b/public/neu/huelle.js @@ -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 = { diff --git a/public/neu/konto.js b/public/neu/konto.js index edcfca8..4bade80 100644 --- a/public/neu/konto.js +++ b/public/neu/konto.js @@ -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, diff --git a/public/neu/leiste.js b/public/neu/leiste.js index 077633e..973a08a 100644 --- a/public/neu/leiste.js +++ b/public/neu/leiste.js @@ -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. */ diff --git a/public/neu/module.css b/public/neu/module.css index edfe5e7..0f7ae10 100644 --- a/public/neu/module.css +++ b/public/neu/module.css @@ -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%; } diff --git a/public/neu/reiter.js b/public/neu/reiter.js index d15bcdd..1f77e84 100644 --- a/public/neu/reiter.js +++ b/public/neu/reiter.js @@ -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' diff --git a/public/neu/routen.js b/public/neu/routen.js index 8a055dd..ee83815 100644 --- a/public/neu/routen.js +++ b/public/neu/routen.js @@ -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) } diff --git a/public/neu/vertraege-abo.js b/public/neu/vertraege-abo.js index 86e35a6..0e3c9eb 100644 --- a/public/neu/vertraege-abo.js +++ b/public/neu/vertraege-abo.js @@ -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') diff --git a/test/knAnsichtVertraege.test.js b/test/knAnsichtVertraege.test.js index 9a3aefc..12baa74 100644 --- a/test/knAnsichtVertraege.test.js +++ b/test/knAnsichtVertraege.test.js @@ -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\./) diff --git a/test/knEfixCss.test.js b/test/knEfixCss.test.js index 37e157e..84a4e6d 100644 --- a/test/knEfixCss.test.js +++ b/test/knEfixCss.test.js @@ -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', () => { diff --git a/test/knHuelleKonto.test.js b/test/knHuelleKonto.test.js index 7982ba4..76e9a7f 100644 --- a/test/knHuelleKonto.test.js +++ b/test/knHuelleKonto.test.js @@ -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], ['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 diff --git a/test/knHuelleLeiste.test.js b/test/knHuelleLeiste.test.js index cb1b406..f2a20c7 100644 --- a/test/knHuelleLeiste.test.js +++ b/test/knHuelleLeiste.test.js @@ -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/) }) diff --git a/test/knHuelleRouten.test.js b/test/knHuelleRouten.test.js index 26b560c..59ce95e 100644 --- a/test/knHuelleRouten.test.js +++ b/test/knHuelleRouten.test.js @@ -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']) })