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 }
}