From 3241aedc3c2f6576044d7057ab664a49aa282545 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 16:06:51 +0000 Subject: [PATCH] Kundenansicht neu P1: Widerrufsstreifen mit GET /api/fristen Co-Authored-By: Claude Sonnet 5.5 --- public/neu/fristen-streifen.js | 52 ++++++++++++++++++++++++++++++++++ public/neu/huelle.css | 13 +++++++++ test/knHuelleFristen.test.js | 49 ++++++++++++++++++++++++++++++++ 3 files changed, 114 insertions(+) create mode 100644 public/neu/fristen-streifen.js create mode 100644 test/knHuelleFristen.test.js diff --git a/public/neu/fristen-streifen.js b/public/neu/fristen-streifen.js new file mode 100644 index 0000000..f4bb026 --- /dev/null +++ b/public/neu/fristen-streifen.js @@ -0,0 +1,52 @@ +/* Kundenansicht neu: Widerrufsstreifen. Läuft eine Widerrufsfrist, steht über der Leiste ein schmaler Streifen + „Widerrufsfrist bis TT.MM. · Widerrufen“. Er ist nicht wegtippbar. Die Daten kommen von GET /api/fristen; jeder Fehler + (404 auf einem Server ohne P5, 500, Netz weg) und anzeigen !== true ergeben keinen Streifen. Daten in Europe/Berlin. */ + +import { h, leeren } from './dom.js' + +/** Fristen vom Server holen; bei Fehlern jeder Art oder anzeigen !== true null. */ +export async function ladeFristen(api) { + try { + const daten = await api('/api/fristen') + return daten?.anzeigen === true ? daten : null + } catch { + return null + } +} + +const datumBerlin = new Intl.DateTimeFormat('de-DE', { timeZone: 'Europe/Berlin', day: '2-digit', month: '2-digit' }) + +/** 'Widerrufsfrist bis TT.MM.' in der Zeit von Berlin; ohne gültiges Datum 'Widerrufsfrist läuft'. */ +export function streifenText(iso) { + const zeit = new Date(iso) + if (!iso || Number.isNaN(zeit.getTime())) return 'Widerrufsfrist läuft' + const teile = Object.fromEntries(datumBerlin.formatToParts(zeit).map((t) => [t.type, t.value])) + return `Widerrufsfrist bis ${teile.day}.${teile.month}.` +} + +/** Frühestes Ende als ISO-Text: fruehestesEnde, sonst das kleinste bis, sonst ''. */ +export function fruehesteFrist(daten) { + if (daten?.fruehestesEnde) return daten.fruehestesEnde + const zeiten = (daten?.fristen ?? []).map((f) => f?.bis).filter((b) => b && !Number.isNaN(Date.parse(b))) + return zeiten.sort((a, b) => Date.parse(a) - Date.parse(b))[0] ?? '' +} + +/** Streifen in `wurzel` füllen: setze(daten) zeigt ihn, setze(null) versteckt ihn. Am Körper steht `kn-hat-streifen`, solange er sichtbar ist. */ +export function baueStreifen(wurzel, { koerper = document.body } = {}) { + wurzel.classList.add('kn-streifen') + wurzel.setAttribute('role', 'region') + wurzel.setAttribute('aria-label', 'Widerrufsfrist') + return { + setze(daten) { + leeren(wurzel) + if (!daten) { + wurzel.hidden = true + koerper.classList.remove('kn-hat-streifen') + return + } + wurzel.append(streifenText(fruehesteFrist(daten)), ' · ', h('a', { class: 'kn-streifen-link', href: '/widerrufen.html' }, 'Widerrufen')) + wurzel.hidden = false + koerper.classList.add('kn-hat-streifen') + }, + } +} diff --git a/public/neu/huelle.css b/public/neu/huelle.css index 8bf63a3..61d7535 100644 --- a/public/neu/huelle.css +++ b/public/neu/huelle.css @@ -30,3 +30,16 @@ .kn-haupt[inert] { display: block; } /* am Handy bleibt die Hauptseite unter dem Blatt stehen */ .kn-brot { display: none; } } + +/* ---------- Streifen (Widerrufsfrist) ---------- */ + +.kn-streifen { display: flex; align-items: center; min-height: 44px; padding: 0 48px; background: var(--kn-orange-bg); color: var(--kn-orange-fg); font-size: 14px; line-height: 20px; font-weight: 600; } +.kn-streifen-link { margin-left: 6px; color: inherit; text-decoration: underline; text-underline-offset: 3px; } +.kn-tastatur .kn-streifen { display: none; } + +@media (max-width: 880px), (pointer: coarse) and (max-height: 500px) { + .kn-streifen { position: fixed; left: 0; right: 0; bottom: calc(var(--kn-zeile-h) + env(safe-area-inset-bottom)); z-index: 30; padding: 0 16px; } + .kn-streifen-link { min-height: 44px; display: inline-flex; align-items: center; } + .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); } +} diff --git a/test/knHuelleFristen.test.js b/test/knHuelleFristen.test.js new file mode 100644 index 0000000..f15fd9e --- /dev/null +++ b/test/knHuelleFristen.test.js @@ -0,0 +1,49 @@ +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 { ladeFristen, streifenText, fruehesteFrist, baueStreifen } from '../public/neu/fristen-streifen.js' +import { lies, handyTeil } from './support/knHuelle.js' + +const FRIST = { fristen: [{ quelle: 'abschluss', bezeichnung: 'Abo', bis: '2026-10-21T21:59:59.999Z' }], fruehestesEnde: '2026-10-21T21:59:59.999Z', anzeigen: true } + +test('ladeFristen: 404, 500, Netzfehler und anzeigen:false ergeben null; sonst die Daten (Review Focus 5)', async () => { + for (const status of [404, 500, 0]) assert.equal(await ladeFristen(async () => { throw Object.assign(new Error('x'), { status }) }), null) + assert.equal(await ladeFristen(async () => ({ fristen: [], fruehestesEnde: null, anzeigen: false })), null) + assert.deepEqual(await ladeFristen(async (p) => { assert.equal(p, '/api/fristen'); return FRIST }), FRIST) +}) + +test('streifenText: Berliner Datum, nicht UTC; fruehesteFrist fällt auf das kleinste bis zurück', () => { + assert.equal(streifenText('2026-10-21T21:59:59.999Z'), 'Widerrufsfrist bis 21.10.'); assert.equal(streifenText('2026-10-21T22:30:00.000Z'), 'Widerrufsfrist bis 22.10.') + assert.equal(fruehesteFrist({ fruehestesEnde: null, fristen: [{ bis: '2026-11-03T10:00:00Z' }, { bis: '2026-10-30T10:00:00Z' }] }), '2026-10-30T10:00:00Z') +}) + +test('baueStreifen: Text mit Link, nicht wegtippbar, null versteckt', () => { + const fx = installFakeDom() + try { + const w = h('div', { hidden: true }); const s = baueStreifen(w); s.setze(FRIST) + assert.deepEqual([w.hidden, fx.text(w)], [false, 'Widerrufsfrist bis 21.10. · Widerrufen']) + assert.deepEqual([fx.finde(w, 'a').getAttribute('href'), fx.alle(w, 'button').length, fx.document.body.classList.contains('kn-hat-streifen')], ['/widerrufen.html', 0, true]) + s.setze(null); assert.deepEqual([w.hidden, fx.document.body.classList.contains('kn-hat-streifen')], [true, false]) + } finally { fx.restore() } +}) + +test('huelle.css (Streifen): 44 px, getönt, am Handy über der Leiste, bei Tastatur weg', () => { + const css = lies('huelle.css') + assert.match(css, /\.kn-streifen\s*\{(?=[^}]*min-height:\s*44px)(?=[^}]*background:\s*var\(--kn-orange-bg\))(?=[^}]*color:\s*var\(--kn-orange-fg\))/s) + assert.match(handyTeil(css), /\.kn-streifen\s*\{[^}]*position:\s*fixed/s) + assert.match(css, /\.kn-tastatur \.kn-streifen[^{]*\{[^}]*display:\s*none/s) +}) + +test('Streifen: ohne gültiges Datum steht ein allgemeiner Text, mehrere Fristen zeigen die früheste, ein neuer Stand ersetzt den alten', () => { + const fx = installFakeDom() + try { + assert.equal(streifenText(''), 'Widerrufsfrist läuft'); assert.equal(streifenText('kein Datum'), 'Widerrufsfrist läuft') + assert.equal(fruehesteFrist({ fristen: [] }), '') + const w = h('div', { hidden: true }); const s = baueStreifen(w) + s.setze({ ...FRIST, fruehestesEnde: null, fristen: [{ bis: '2026-11-03T10:00:00Z' }, { bis: '2026-10-30T10:00:00Z' }] }) + assert.equal(fx.text(w), 'Widerrufsfrist bis 30.10. · Widerrufen') + s.setze(FRIST); assert.deepEqual([fx.text(w), fx.alle(w, 'a').length], ['Widerrufsfrist bis 21.10. · Widerrufen', 1]) + assert.deepEqual([w.getAttribute('role'), w.getAttribute('aria-label'), w.classList.contains('kn-streifen')], ['region', 'Widerrufsfrist', true]) + } finally { fx.restore() } +})