Kundenansicht neu P7: gemeinsame Bausteine der Bestellseiten (Block, Linkzeile, Kundenart-Zeile)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
841bc76bbb
commit
fcf357c0b7
6 files changed
+186
-2
No files matched your search
@@ -316,6 +316,26 @@
|
||||
.kn .ab-portal .ab-knopf:focus-visible, .kn .ab-portal .ab-link:focus-visible { outline: 2px solid var(--ab-fokus); outline-offset: 2px; }
|
||||
.kn .ab-portal .ab-notiz, .kn .ab-portal .ab-gesperrt { margin: 12px 0 0; font-size: 13px; color: var(--ab-weich); }
|
||||
|
||||
/* ------------------------------------------------------------------ P7 Bestellseiten (Abo, Wechsel, Speicher, Gutschrift) --- */
|
||||
/* Muster der Spec 5.6: Block „Was du bestellst“, eine Linkzeile, ein Haken nur für Privatkunden, der Knopf, die Kundenart-Zeile.
|
||||
Alles liegt ohne Klappe und ohne Scrollbereich hintereinander. */
|
||||
.kn-bestellen { max-width: 640px; }
|
||||
.kn-block { margin: 0 0 16px; }
|
||||
.kn-block-zeile { display: grid; grid-template-columns: 9rem minmax(0, 1fr); gap: 4px 16px; padding: 10px 0; border-top: 1px solid var(--kn-line); }
|
||||
.kn-block-zeile:last-child { border-bottom: 1px solid var(--kn-line); }
|
||||
.kn-block dt { color: var(--kn-text-2); }
|
||||
.kn-block dd { margin: 0; min-width: 0; overflow-wrap: anywhere; font-variant-numeric: tabular-nums; }
|
||||
.kn-linkzeile { margin: 0 0 12px; font-size: 13px; line-height: 20px; color: var(--kn-text-2); }
|
||||
.kn-linkzeile a, .kn-kundenart-zeile a { color: var(--kn-text); text-decoration: underline; text-decoration-color: var(--kn-line-strong); text-underline-offset: 3px; }
|
||||
.kn-linkzeile a:hover, .kn-kundenart-zeile a:hover { text-decoration-color: currentColor; }
|
||||
.kn-kundenart-zeile { margin: 8px 0 0; font-size: 13px; line-height: 20px; color: var(--kn-text-2); }
|
||||
.kn-bestellen .kn-check { display: flex; align-items: flex-start; gap: 8px; min-height: 44px; margin: 0 0 12px; color: var(--kn-text); }
|
||||
.kn-bestellen .kn-check input { flex: 0 0 auto; width: 20px; height: 20px; margin: 2px 0 0; }
|
||||
.kn-bestellen .kn-aktionen { margin-top: 0; }
|
||||
.kn-sperrzeile { margin: 0 0 12px; font-size: 13px; line-height: 20px; color: var(--kn-danger); }
|
||||
.kn-speicher-warnung { margin: 0 0 12px; font-size: 13px; line-height: 20px; color: var(--kn-orange-fg); }
|
||||
.kn-gutschrift-bedingungen { max-width: 640px; }
|
||||
|
||||
/* ------------------------------------------------------------------ Handy --- */
|
||||
@media (max-width: 880px), (pointer: coarse) and (max-height: 500px) {
|
||||
.kn-projekt { grid-template-columns: minmax(0, 1fr); gap: 16px; padding: 16px 0; }
|
||||
@@ -331,6 +351,8 @@
|
||||
.kn-pw { max-width: none; }
|
||||
.kn-tk-feld input:not([type="checkbox"]):not([type="file"]) { height: 44px; font-size: 16px; }
|
||||
.kn-stepper .kn-knopf { min-width: 44px; }
|
||||
.kn-block-zeile { grid-template-columns: minmax(0, 1fr); gap: 0; }
|
||||
.kn-bestellen .kn-knopf { width: 100%; min-height: 44px; }
|
||||
.kn .kn-chat .chat-bubble { max-width: 88%; }
|
||||
.kn .kn-chat .chat-messages { min-height: 240px; max-height: 55vh; }
|
||||
.kn .kn-chat .chat-input-row textarea { font-size: 16px; }
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
/* Kundenansicht neu (P7): gemeinsame Bausteine der vier Bestellseiten (Abo bestellen, Wechsel, Zusatzspeicher, Gutschrift-Antrag).
|
||||
Die Sätze kommen vom Server (GET /api/abo → bestelltexte); hier werden sie nur gezeichnet. Link-Texte stehen in der Vorlage in
|
||||
eckigen Klammern, ihre Adressen daneben in urls. Jede Adresse läuft durch sicherLink; Kundentext wird nie als HTML gelesen.
|
||||
Keine h1: den Titel meldet die Ansicht mit meldeTitel. */
|
||||
|
||||
import { h } from './dom.js'
|
||||
import { K } from './ansicht-hilfen.js'
|
||||
|
||||
/** 'unternehmen' | 'privat': nur exakt 'unternehmen' (me.customer.kundenartWirksam, sonst daten.kundenart) ist Betrieb; alles andere privat (E4). */
|
||||
export function kundenartVon(me, daten = null) {
|
||||
return me?.customer?.kundenartWirksam === 'unternehmen' || daten?.kundenart === 'unternehmen' ? 'unternehmen' : 'privat'
|
||||
}
|
||||
|
||||
/** Adresse nur, wenn sie ein Pfad auf derselben Herkunft ('/…', nicht '//…'), ein Anker ('#name…') oder http(s) ohne Zugangsdaten ist; sonst ''. */
|
||||
export function sicherLink(url) {
|
||||
if (typeof url !== 'string' || url === '') return ''
|
||||
if (/[\s\u0000-\u001f\u007f\\]/.test(url)) return ''
|
||||
if (/^\/(?![/\\])/.test(url)) return url
|
||||
if (/^#[a-z][\w-]*(\?[\w=&.%-]*)?$/i.test(url)) return url
|
||||
if (/^https?:\/\/[^/?#]+/i.test(url)) {
|
||||
try {
|
||||
const u = new URL(url)
|
||||
return u.hostname !== '' && u.username === '' && u.password === '' ? url : ''
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 'Es gilt [A] und [B].' mit ['/a', '/b'] → Teile [{ text }, { text, href? }]. Die Markierungen bekommen der Reihe nach ihre Adresse;
|
||||
* eine unsichere oder fehlende Adresse macht den Teil zu einem Teil ohne href. Eine offene Klammer bleibt Text.
|
||||
*/
|
||||
export function linkTeile(text, urls) {
|
||||
const basis = typeof text === 'string' ? text : ''
|
||||
const adressen = Array.isArray(urls) ? urls : []
|
||||
const teile = []
|
||||
let ab = 0
|
||||
let nr = 0
|
||||
const muster = /\[([^\]]*)\]/g
|
||||
for (let m = muster.exec(basis); m; m = muster.exec(basis)) {
|
||||
if (m.index > ab) teile.push({ text: basis.slice(ab, m.index) })
|
||||
const href = sicherLink(adressen[nr])
|
||||
nr += 1
|
||||
teile.push(href ? { text: m[1], href } : { text: m[1] })
|
||||
ab = m.index + m[0].length
|
||||
}
|
||||
if (ab < basis.length) teile.push({ text: basis.slice(ab) })
|
||||
return teile
|
||||
}
|
||||
|
||||
const ausserhalb = (href) => !href.startsWith('#')
|
||||
|
||||
/** <p id> mit den Links der Vorlage; '/…' und http(s) öffnen in neuem Tab (target _blank, rel noopener), '#…' bleibt im Fenster. */
|
||||
export function baueLinkzeile({ text, urls } = {}, { id } = {}) {
|
||||
const kinder = linkTeile(text, urls).map((t) =>
|
||||
t.href ? h('a', { href: t.href, ...(ausserhalb(t.href) ? { target: '_blank', rel: 'noopener' } : {}) }, t.text) : t.text)
|
||||
return h('p', { id, class: 'kn-linkzeile' }, kinder)
|
||||
}
|
||||
|
||||
/** <p id>: „Du bestellst als Betrieb“ | „Du bestellst privat“, ' · ', dann der Hinweis zur Korrektur mit seinem Link. */
|
||||
export function baueKundenartZeile(kundenart, texte, { id } = {}) {
|
||||
const lage = kundenart === 'unternehmen' ? texte?.unternehmen : texte?.privat
|
||||
const korrektur = linkTeile(texte?.korrektur?.text, texte?.korrektur?.urls).map((t) =>
|
||||
t.href ? h('a', { href: t.href, ...(ausserhalb(t.href) ? { target: '_blank', rel: 'noopener' } : {}) }, t.text) : t.text)
|
||||
return h('p', { id, class: 'kn-kundenart-zeile' }, lage, ' · ', korrektur)
|
||||
}
|
||||
|
||||
/** Block „Was du bestellst“: zeilen [{ id?, label, text }] als Beschriftungsliste. Der Text ist immer Text. */
|
||||
export function baueBlock(zeilen, { id } = {}) {
|
||||
return h('dl', { id, class: 'kn-block' }, (zeilen || []).map((z) => h('div', { class: 'kn-block-zeile' }, h('dt', null, z.label), h('dd', { id: z.id }, z.text))))
|
||||
}
|
||||
|
||||
/** true, wenn die Antwort den Wortlaut trägt: status, knopf und die Linkzeile des Abos. Ohne ihn zeigt keine Seite einen Bestellknopf. */
|
||||
export function hatWortlaut(daten) {
|
||||
const t = daten?.bestelltexte
|
||||
return Boolean(t && typeof t === 'object' && typeof t.status === 'string' && t.status && typeof t.knopf === 'string' && t.knopf
|
||||
&& typeof t.abo?.linkzeile?.text === 'string' && t.abo.linkzeile.text)
|
||||
}
|
||||
|
||||
export { K }
|
||||
@@ -6,7 +6,7 @@ import { fileURLToPath } from 'node:url'
|
||||
|
||||
const WURZEL = path.join(path.dirname(fileURLToPath(import.meta.url)), '..')
|
||||
const NEU = path.join(WURZEL, 'public', 'neu')
|
||||
const MODULE = ['ansicht-hilfen', 'rechnung-offen', 'install-hinweis', 'website-details', 'ansicht-website', 'ansicht-chat', 'ansicht-vertraege', 'vertraege-rechnungen', 'vertraege-abo', 'ansicht-stunden', 'ansicht-speicher', 'ansicht-bestellen', 'ansicht-wechsel', 'ansicht-gutschrift', 'ansicht-passwort']
|
||||
const MODULE = ['ansicht-hilfen', 'rechnung-offen', 'install-hinweis', 'website-details', 'ansicht-website', 'ansicht-chat', 'ansicht-vertraege', 'vertraege-rechnungen', 'vertraege-abo', 'ansicht-stunden', 'ansicht-speicher', 'ansicht-bestellen', 'ansicht-wechsel', 'ansicht-gutschrift', 'ansicht-passwort', 'bestell-bausteine']
|
||||
const WORTLAUT = ['ansicht-speicher', 'ansicht-bestellen', 'ansicht-wechsel', 'ansicht-gutschrift']
|
||||
const vorhanden = MODULE.filter((n) => existsSync(path.join(NEU, `${n}.js`)))
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import { K } from '../public/neu/ansicht-hilfen.js'
|
||||
const css = (f) => readFileSync(new URL(`../public/neu/${f}`, import.meta.url), 'utf8')
|
||||
const lese = (p) => readFileSync(new URL(`../public/${p}`, import.meta.url), 'utf8')
|
||||
const A = css('ansichten.css'); const O = css('oeffentlich/oeffentlich.css'); const C = css('chat-composer.css'); const BASIS = css('basis.css'); const TOKENS = css('tokens.css')
|
||||
const MODULE = ['ansicht-hilfen', 'rechnung-offen', 'install-hinweis', 'website-details', 'ansicht-website', 'ansicht-chat', 'ansicht-vertraege', 'vertraege-rechnungen', 'vertraege-abo', 'ansicht-stunden', 'ansicht-speicher', 'ansicht-bestellen', 'ansicht-wechsel', 'ansicht-gutschrift', 'ansicht-passwort']
|
||||
const MODULE = ['ansicht-hilfen', 'rechnung-offen', 'install-hinweis', 'website-details', 'ansicht-website', 'ansicht-chat', 'ansicht-vertraege', 'vertraege-rechnungen', 'vertraege-abo', 'ansicht-stunden', 'ansicht-speicher', 'ansicht-bestellen', 'ansicht-wechsel', 'ansicht-gutschrift', 'ansicht-passwort', 'bestell-bausteine']
|
||||
const existiert = (n) => existsSync(new URL(`../public/neu/${n}.js`, import.meta.url))
|
||||
const lies = (n) => css(`${n}.js`).replace(/\/\*[\s\S]*?\*\//g, '')
|
||||
const VERBOTEN = /linear-gradient|radial-gradient|conic-gradient|backdrop-filter|prefers-color-scheme:\s*dark|font-weight:\s*800|#9d3dfe|rgba?\(\s*157\s*,\s*61/i
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { mitDom } from './support/knDaten.js'
|
||||
import { TERMS_URL, meMit, aboNeu } from './support/knBestellDaten.js'
|
||||
import { kundenartVon, sicherLink, linkTeile, baueLinkzeile, baueKundenartZeile, baueBlock, hatWortlaut } from '../public/neu/bestell-bausteine.js'
|
||||
|
||||
test('kundenartVon: nur exakt unternehmen ist Betrieb (E4)', () => {
|
||||
for (const m of [undefined, null, {}, { customer: {} }, meMit(''), meMit('privat'), { customer: { kundenartWirksam: 'Unternehmen' } }, { customer: { kundenart: 'unternehmen' } }]) {
|
||||
assert.equal(kundenartVon(m), 'privat')
|
||||
}
|
||||
assert.equal(kundenartVon(meMit('unternehmen')), 'unternehmen')
|
||||
assert.equal(kundenartVon({}, { kundenart: 'unternehmen' }), 'unternehmen')
|
||||
assert.equal(kundenartVon({}, { kundenart: 'Unternehmen' }), 'privat')
|
||||
})
|
||||
|
||||
test('sicherLink', () => {
|
||||
for (const ok of ['/datenschutz.html', `${TERMS_URL}#zusatzspeicher`, '#chat', '#gutschrift?bedingungen=1', 'https://example.invalid/x']) assert.equal(sicherLink(ok), ok)
|
||||
for (const schlecht of ['javascript:alert(1)', '//evil.example/x', '/\\evil.example', 'data:text/html,x', 'http:/x', 'abo.html', '', undefined, null, 42, '/a b', '#', '# chat', 'https://', 'https://user:pw@example.invalid/']) {
|
||||
assert.equal(sicherLink(schlecht), '', String(schlecht))
|
||||
}
|
||||
})
|
||||
|
||||
test('linkTeile: Markierungen werden der Reihe nach Links, Unsicheres bleibt Text', () => {
|
||||
assert.deepEqual(linkTeile('Es gilt [A] und [B].', ['/a', '/b']), [{ text: 'Es gilt ' }, { text: 'A', href: '/a' }, { text: ' und ' }, { text: 'B', href: '/b' }, { text: '.' }])
|
||||
assert.deepEqual(linkTeile('Siehe [A].', ['javascript:x']), [{ text: 'Siehe ' }, { text: 'A' }, { text: '.' }])
|
||||
assert.deepEqual(linkTeile('Nur Text', []), [{ text: 'Nur Text' }])
|
||||
assert.deepEqual(linkTeile('Offen [A', ['/a']), [{ text: 'Offen [A' }])
|
||||
assert.deepEqual(linkTeile('[A] [B]', ['/a']), [{ text: 'A', href: '/a' }, { text: ' ' }, { text: 'B' }], 'fehlende Adresse: Text')
|
||||
assert.deepEqual(linkTeile(undefined, undefined), [])
|
||||
})
|
||||
|
||||
test('baueLinkzeile, baueKundenartZeile, baueBlock', () =>
|
||||
mitDom((fx) => {
|
||||
const p = baueLinkzeile({ text: 'Siehe [A] und [B].', urls: ['/a', '#chat'] }, { id: 'x' })
|
||||
const [a, b] = fx.alle(p, 'a')
|
||||
assert.deepEqual([p.id, fx.text(p), a.getAttribute('target'), a.getAttribute('rel'), b.getAttribute('target')], ['x', 'Siehe A und B.', '_blank', 'noopener', null])
|
||||
const T = aboNeu().bestelltexte.kundenart
|
||||
assert.equal(fx.text(baueKundenartZeile('unternehmen', T, { id: 'k' })), 'Du bestellst als Betrieb · Stimmt nicht? Schreib uns im Chat')
|
||||
assert.equal(fx.text(baueKundenartZeile('privat', T, { id: 'k' })), 'Du bestellst privat · Stimmt nicht? Schreib uns im Chat')
|
||||
assert.equal(fx.text(baueKundenartZeile('kaputt', T, { id: 'k' })), 'Du bestellst privat · Stimmt nicht? Schreib uns im Chat')
|
||||
assert.equal(fx.finde(baueKundenartZeile('privat', T, { id: 'k' }), 'a').getAttribute('href'), '#chat')
|
||||
const dl = baueBlock([{ id: 'r1', label: 'Preis', text: '<b>50</b>' }], { id: 'blk' })
|
||||
assert.deepEqual([fx.alle(dl, 'dt').length, fx.finde(dl, 'dd').textContent, fx.finde(dl, 'b'), fx.finde(dl, '#r1').textContent], [1, '<b>50</b>', null, '<b>50</b>'])
|
||||
assert.deepEqual([dl.tagName, dl.id, dl.className], ['DL', 'blk', 'kn-block'])
|
||||
// eine unsichere Adresse macht keinen Link
|
||||
assert.equal(fx.alle(baueLinkzeile({ text: 'Siehe [A].', urls: ['javascript:alert(1)'] }, { id: 'y' }), 'a').length, 0)
|
||||
// keine h1 in den Bausteinen
|
||||
for (const el of [p, dl, baueKundenartZeile('privat', T, { id: 'k' })]) assert.equal(fx.alle(el, 'h1').length, 0)
|
||||
}))
|
||||
|
||||
test('hatWortlaut', () => {
|
||||
assert.equal(hatWortlaut(aboNeu()), true)
|
||||
for (const d of [null, undefined, {}, { bestelltexte: {} }, { bestelltexte: { status: 'entwurf', knopf: 'x' } }, { bestelltexte: { status: 'entwurf', abo: { linkzeile: { text: 'a' } } } }]) assert.equal(hatWortlaut(d), false)
|
||||
})
|
||||
|
||||
test('Quelltext: keine HTML-Einsetzung, Du-Form, keine h1', () => {
|
||||
const q = readFileSync(new URL('../public/neu/bestell-bausteine.js', import.meta.url), 'utf8').replace(/\/\*[\s\S]*?\*\//g, '')
|
||||
assert.doesNotMatch(q, /innerHTML|insertAdjacentHTML|outerHTML|document\.write/)
|
||||
assert.doesNotMatch(q, /\b(Sie|Ihr(e[nmrs]?)?|Ihnen)\b/)
|
||||
assert.doesNotMatch(q, /'h1'/)
|
||||
})
|
||||
@@ -0,0 +1,18 @@
|
||||
// Testdaten der Bestellseiten (P7, Fassung 6): die Fassung und die Adresse der Bedingungen kommen aus Konfiguration und Tabelle,
|
||||
// nie als Literal; der Wortlaut aus bestelltexteFuerClient (wie GET /api/abo ihn in der neuen Ansicht liefert).
|
||||
import { config } from '../../server/config.js'
|
||||
import { termsDocFor } from '../../server/services/products.js'
|
||||
import { bestelltexteFuerClient } from '../../server/services/bestelltexte.js'
|
||||
import { me, aboAntwort } from './knDaten.js'
|
||||
|
||||
export const NEU = config.abo.termsVersionNeu
|
||||
export const TERMS_URL = termsDocFor(NEU).url
|
||||
|
||||
/** me(over) mit customer.kundenart und customer.kundenartWirksam; bei '' bleibt die gespeicherte Angabe leer. */
|
||||
export const meMit = (kundenartWirksam, over = {}) => {
|
||||
const { customer = {}, ...rest } = over
|
||||
return me({ ...rest, customer: { kundenart: kundenartWirksam === '' ? '' : kundenartWirksam, kundenartWirksam, ...customer } })
|
||||
}
|
||||
|
||||
/** Ganze Antwort von GET /api/abo in der neuen Ansicht: Fassung 6 und Wortlaut. */
|
||||
export const aboNeu = (over = {}) => aboAntwort({ termsVersion: NEU, bestelltexte: bestelltexteFuerClient({ fassung: NEU, termsUrl: TERMS_URL }), ...over })
|
||||
Reference in new issue
Block a user