Kundenansicht neu P1: Konto mit Avatar, Sheet und Popover

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-16 16:09:40 +00:00
1 parent 3241aedc3c
commit 4953c8fd0f
3 files changed
+240

No files matched your search

+31
View File
@@ -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%); } }
+130
View File
@@ -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 }
}
+79
View File
@@ -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: '<img src=x onerror=alert(1)>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], ['<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([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() }
})