From 4953c8fd0f5e783cb9c1eb0f0713a618c61deedc Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 16:09:40 +0000 Subject: [PATCH] Kundenansicht neu P1: Konto mit Avatar, Sheet und Popover Co-Authored-By: Claude Sonnet 5.5 --- public/neu/huelle.css | 31 +++++++++ public/neu/konto.js | 130 +++++++++++++++++++++++++++++++++++++ test/knHuelleKonto.test.js | 79 ++++++++++++++++++++++ 3 files changed, 240 insertions(+) create mode 100644 public/neu/konto.js create mode 100644 test/knHuelleKonto.test.js diff --git a/public/neu/huelle.css b/public/neu/huelle.css index 61d7535..a7e00d4 100644 --- a/public/neu/huelle.css +++ b/public/neu/huelle.css @@ -43,3 +43,34 @@ .kn-hat-streifen { --kn-unten: calc(var(--kn-zeile-h) + env(safe-area-inset-bottom) + 44px); } .kn-hat-streifen .kn-haupt { padding-bottom: calc(var(--kn-zeile-h) + env(safe-area-inset-bottom) + 28px + 44px); } } + +/* ---------- Konto (Popover am Desktop, Sheet am Handy) ---------- */ + +.kn-avatar { flex: none; display: grid; place-items: center; width: 44px; height: 44px; border-radius: 50%; } +.kn-avatar-kreis { display: grid; place-items: center; width: 32px; height: 32px; overflow: hidden; border-radius: 50%; background: var(--kn-grau-bg); color: var(--kn-grau-fg); font-size: 12px; line-height: 16px; font-weight: 600; } +.kn-avatar img { display: none; width: 100%; height: 100%; object-fit: cover; } +.kn-avatar.has-img img { display: block; } +.kn-avatar.has-img .kn-initialen { display: none; } + +.kn-konto-wurzel { position: fixed; inset: 0; z-index: 60; } +.kn-konto-wurzel:focus { outline: none; } /* tabindex -1: ein Tipp ins Leere behält die Tasten (Esc, Tab) im Sheet */ +.kn-konto-scrim { position: absolute; inset: 0; background: transparent; } +.kn-konto { position: absolute; top: 56px; right: 24px; width: 280px; max-height: calc(100vh - 72px); padding: 8px 20px; overflow-y: auto; overscroll-behavior: contain; background: var(--kn-bg); border-radius: var(--kn-radius-lg); box-shadow: var(--kn-shadow-pop); animation: kn-aufpoppen .14s ease-out; } +.kn-konto-kopf { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 8px 0 12px; border-bottom: 1px solid var(--kn-line); } +.kn-konto-name { display: block; font-size: 17px; line-height: 24px; font-weight: 600; overflow-wrap: anywhere; } +.kn-konto-mail { display: block; font-size: 13px; line-height: 18px; color: var(--kn-text-2); overflow-wrap: anywhere; } +.kn-konto-zu { flex: none; min-height: 32px; padding: 0 4px; font-weight: 600; } +.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-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); } + +@media (max-width: 880px), (pointer: coarse) and (max-height: 500px) { + .kn-konto-scrim { background: var(--kn-overlay); animation: kn-einblenden .3s ease-out; } + .kn-konto { top: auto; right: 0; bottom: 0; left: 0; width: auto; max-height: 92vh; padding: 8px 20px calc(22px + env(safe-area-inset-bottom)); 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; } +} +@keyframes kn-sheet-hoch { from { transform: translateY(100%); } } diff --git a/public/neu/konto.js b/public/neu/konto.js new file mode 100644 index 0000000..edcfca8 --- /dev/null +++ b/public/neu/konto.js @@ -0,0 +1,130 @@ +/* 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. + Das Sheet ist ein Menü: Esc, ein Tipp aufs Dunkle und „Schließen“ schließen es; Zurück schließt es über das Overlay des Routers. */ + +import { h, leeren } from './dom.js' +import { oeffneOverlay, beendeOverlay, geheZu } from './router.js' + +/** Initialen aus den ersten zwei Wörtern (nur Wörter, die mit Buchstabe oder Ziffer beginnen), sonst die ersten zwei Buchstaben. */ +export function initialenVon(name) { + const woerter = String(name || '').split(/\s+/).filter((w) => /^[\p{L}\p{N}]/u.test(w)) + const buchstaben = woerter.length > 1 ? woerter[0][0] + woerter[1][0] : (woerter[0] || '').slice(0, 2) + return buchstaben.toUpperCase() +} + +/** Installationsangebot: nur am Handy und nicht in der installierten App; Chrome per Dialog ('prompt'), iPhone über Teilen ('ios'). */ +export function installAngebot({ handy, standalone, prompt, ios }) { + if (!handy || standalone) return null + if (prompt) return 'prompt' + return ios ? 'ios' : null +} + +const HINWEIS_IOS = 'Tippe auf Teilen und dann auf Zum Home-Bildschirm.' + +/** Zeilen des Kontos in der Reihenfolge von V3. */ +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' }, + ] +} + +/** Profilbild-Knopf: Initialen sofort, das Bild von /api/avatar.svg erscheint erst nach dem Laden (sonst bleiben die Initialen). */ +export function baueAvatar({ name, kundenId }) { + const knopf = h('button', { class: 'kn-avatar', type: 'button', 'aria-label': 'Mein Konto', 'aria-haspopup': 'dialog', 'aria-expanded': 'false' }, + h('span', { class: 'kn-avatar-kreis' }, h('span', { class: 'kn-initialen' }, initialenVon(name)))) + if (kundenId) { + const bild = h('img', { alt: '', decoding: 'async', src: `/api/avatar.svg?c=${encodeURIComponent(kundenId)}`, on: { load: () => knopf.classList.add('has-img'), error: () => bild.remove() } }) + knopf.firstElementChild.append(bild) + } + return knopf +} + +const mitModifikator = (ev) => ev.defaultPrevented || ev.button > 0 || ev.metaKey || ev.ctrlKey || ev.shiftKey || ev.altKey + +export function baueKonto({ me, fristen, install, onAbmelden, onInstall, overlay = { oeffne: oeffneOverlay, beende: beendeOverlay }, navigiere = geheZu }) { + const kunde = me?.customer ?? {} + const stand = { fristen, install, offen: false, ausloeser: null } + + const liste = h('div', { class: 'kn-konto-liste' }) + const zu = h('button', { class: 'kn-konto-zu', type: 'button', on: { click: () => schliesse() } }, 'Schließen') + const panel = h('div', { class: 'kn-konto' }, + h('div', { class: 'kn-konto-kopf' }, h('div', null, h('b', { class: 'kn-konto-name' }, kunde.companyName || kunde.name || kunde.email || ''), h('span', { class: 'kn-konto-mail' }, kunde.email || '')), zu), + liste) + const wurzel = h('div', { class: 'kn-konto-wurzel', role: 'dialog', 'aria-modal': 'true', 'aria-label': 'Konto', tabindex: '-1', hidden: true, on: { keydown: beiTaste } }, + h('div', { class: 'kn-konto-scrim', on: { click: () => schliesse() } }), panel) + + 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' : ''}` + 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, + on: { click: (ev) => { if (!intern || mitModifikator(ev)) return; ev.preventDefault(); navigiere(e.href) } } }, inhalt) // der Router schließt das Overlay selbst + } + + function zeichne() { + const aktiv = globalThis.document?.activeElement?.closest?.('[data-konto]')?.getAttribute('data-konto') + leeren(liste) + for (const e of kontoEintraege({ fristen: stand.fristen, install: stand.install })) liste.append(zeile(e)) + if (stand.offen && aktiv) liste.querySelector(`[data-konto="${aktiv}"]`)?.focus() + } + + function fokussierbare() { + return wurzel.querySelectorAll('a[href], button:not([disabled])') + } + + // Tab bleibt im Sheet (aria-modal) + function beiTaste(ev) { + if (ev.key === 'Escape') { ev.preventDefault(); schliesse(); return } + if (ev.key !== 'Tab') return + const alle = fokussierbare() + const erstes = alle[0] + const letztes = alle.at(-1) + const aktiv = globalThis.document?.activeElement + if (!ev.shiftKey && aktiv === letztes) { ev.preventDefault(); erstes.focus() } + else if (ev.shiftKey && aktiv === erstes) { ev.preventDefault(); letztes.focus() } + } + + // Zu und Fokus zurück; vom Router (Zurück-Taste, Navigation) und von schliesse() genutzt. Mehrfachaufruf schadet nicht. + function schliesseAnsicht() { + if (!stand.offen) return + stand.offen = false + wurzel.hidden = true + stand.ausloeser?.setAttribute('aria-expanded', 'false') + stand.ausloeser?.focus?.({ preventScroll: true }) + } + + /** Vom Besitzer (Esc, Scrim, „Schließen“): zumachen und den Overlay-Eintrag des Routers zurücknehmen. */ + function schliesse() { + if (!stand.offen) return + schliesseAnsicht() + overlay.beende() + } + + function oeffne(ausloeser) { + if (stand.offen) return + stand.offen = true + stand.ausloeser = ausloeser ?? null + ausloeser?.setAttribute('aria-expanded', 'true') + wurzel.hidden = false + overlay.oeffne({ schliesse: schliesseAnsicht }) + liste.firstElementChild?.focus() + } + + function aktualisiere({ fristen: f, install: i } = {}) { + stand.fristen = f + stand.install = i + zeichne() + } + + zeichne() + return { wurzel, oeffne, schliesse, istOffen: () => stand.offen, aktualisiere } +} diff --git a/test/knHuelleKonto.test.js b/test/knHuelleKonto.test.js new file mode 100644 index 0000000..7982ba4 --- /dev/null +++ b/test/knHuelleKonto.test.js @@ -0,0 +1,79 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { installFakeDom } from './support/fakeDom.js' +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', () => { + 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']) + 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']) + assert.equal(kontoEintraege({ fristen: { anzeigen: true }, install: null }).find((x) => x.id === 'widerrufen').pille, 'Frist läuft') +}) + +test('installAngebot: nur am Handy, nicht in der installierten App (wie app.js:2267)', () => { + assert.deepEqual([{ handy: true, standalone: false, prompt: true, ios: false }, { handy: true, standalone: false, prompt: false, ios: true }, { handy: true, standalone: true, prompt: true, ios: true }, { handy: false, standalone: false, prompt: true, ios: true }].map((o) => installAngebot(o)), ['prompt', 'ios', null, null]) +}) + +test('initialenVon und Avatar: Bild erst nach load', async () => { + assert.deepEqual(['Salon Amin', 'Amin', '', ' ', 'Äsop Öl', '– Test'].map(initialenVon), ['SA', 'AM', '', '', 'ÄÖ', 'TE']) + const fx = installFakeDom() + try { + const a = baueAvatar({ name: 'Salon Amin', kundenId: 'c1' }); assert.deepEqual([a.textContent, a.getAttribute('aria-label')], ['SA', 'Mein Konto']) + const img = fx.finde(a, 'img'); assert.equal(img.getAttribute('src'), '/api/avatar.svg?c=c1') + await fx.sende(img, 'load'); assert.ok(a.classList.contains('has-img')) + } finally { fx.restore() } +}) + +test('baueKonto: Kopf als Text, Sheet-Verhalten, Overlay-Kopplung, Navigation, Abmelden, aktualisiere', async () => { + const fx = installFakeDom() + try { + const log = []; const trigger = h('button'); fx.document.body.append(trigger) + const me = { customer: { id: 'c1', name: 'Anna', companyName: 'Salon', email: 'a@b.de' } } + 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([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') + 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() } +}) + +test('huelle.css (Konto): Popover als Desktop-Grundregel, Sheet am Handy', () => { + const css = lies('huelle.css') + assert.match(css, /\.kn-konto\s*\{(?=[^}]*width:\s*280px)(?=[^}]*top:)/s) + assert.match(handyTeil(css), /\.kn-konto\s*\{[^}]*border-radius:\s*12px 12px 0 0/s) +}) + +test('Konto: Router schließt ohne zweites Zurück, Links öffnen richtig, Tab bleibt im Sheet, Installieren und Hinweis am iPhone, Avatar ohne Bild', async () => { + const fx = installFakeDom() + try { + const log = []; const trigger = h('button'); fx.document.body.append(trigger); let vomRouter + const k = baueKonto({ me: { customer: { id: 'c1', name: '', companyName: '', email: 'a@b.de' } }, fristen: null, install: 'ios', onAbmelden() {}, onInstall: () => log.push('installieren'), + 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() + 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 + await fx.klick(inst); assert.deepEqual(log, ['installieren']) + const letzte = zeilen().at(-1); const zu = fx.finde(k.wurzel, '.kn-konto-zu'); letzte.focus() + await fx.sende(letzte, 'keydown', { key: 'Tab' }); assert.equal(fx.document.activeElement, zu) // Tab vom letzten Element springt zum ersten + await fx.sende(zu, 'keydown', { key: 'Tab', shiftKey: true }); assert.equal(fx.document.activeElement, letzte) + vomRouter() // Zurück-Taste: der Router schließt und ruft schliesse des Besitzers + assert.deepEqual([k.istOffen(), log.includes('beende')], [false, false]) // kein zweites history.back() + k.oeffne(trigger); k.schliesse(); k.schliesse(); assert.deepEqual(log.filter((l) => l === 'beende').length, 1) // Schließen durch den Besitzer: einmal beende, zweites schliesse tut nichts + const a = baueAvatar({ name: 'Anna', kundenId: '' }); assert.equal(fx.alle(a, 'img').length, 0) + const b = baueAvatar({ name: 'Anna', kundenId: 'c1' }); await fx.sende(fx.finde(b, 'img'), 'error'); assert.deepEqual([fx.alle(b, 'img').length, b.classList.contains('has-img')], [0, false]) + } finally { fx.restore() } +})