Kundenansicht neu P1: Konto mit Avatar, Sheet und Popover
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
3241aedc3c
commit
4953c8fd0f
3 files changed
+240
No files matched your search
@@ -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%); } }
|
||||
@@ -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 }
|
||||
}
|
||||
Reference in new issue
Block a user