Terminplaner: Einbau-Snippet mit Knopf und Farbvorschlag nach WCAG verschoben
einbauSnippet und esc wandern aus routes/termine.js nach services/termine/einbauanleitung.js (Parameter statt ctx/config). Neu: Modus knopf mit data-knopf (nur bei gesetztem Text, hoechstens 40 Zeichen, Zeilenumbrueche zu Leerzeichen, maskiert; unbekannter Modus gilt als inline), WCAG- Helfer luminanz/kontrast/textFarbeAuf 1:1 aus dem Widget und cssVorschlag(farbe) mit Warnung, wenn der Akzent gegen Weiss unter 3 : 1 liegt. VORGABE_FARBE wird exportiert. Die Kundenansicht bleibt unveraendert (Modus inline). Farbparitaet Widget/Server ueber alle 4096 Farben im 17er-Raster getestet. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
06ebcd1b0a
commit
ee454017b0
5 files changed
+349
-16
No files matched your search
@@ -12,6 +12,7 @@ import { createWriteLimiter } from '../services/writeLimiter.js'
|
||||
import { verschiebeZeiten } from '../services/termine/ablauf.js'
|
||||
import { aktion, entsperre, manuell, sperre } from '../services/termine/ablaufBetrieb.js'
|
||||
import { EXPORT_SEITE, exportSeite, hole, imZeitraum, zaehleAnfragen, zaehleKuenftige } from '../services/termine/buchungen.js'
|
||||
import { einbauSnippet } from '../services/termine/einbauanleitung.js'
|
||||
import { fehlendePflichtangaben, neuerFeedToken, normalisiereEinstellung } from '../services/termine/einstellungen.js'
|
||||
import { loeschTag, loeschzeit } from '../services/termine/frist.js'
|
||||
import { BRANCHEN, bestellpruefung, bestellungAnsicht, leerFragebogen, normalisiereFragebogen } from '../services/termine/fragebogen.js'
|
||||
@@ -278,20 +279,6 @@ function feedLinks(ctx) {
|
||||
return links
|
||||
}
|
||||
|
||||
const esc = (text) => String(text).replace(/[&<>"]/g, (z) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[z])
|
||||
|
||||
/** Einbau-Code für die Website (Spezifikation Abschnitt 7): Platzhalter mit Ausweichtext, bleibt ohne JavaScript stehen. */
|
||||
function einbauSnippet(ctx) {
|
||||
const telefon = ctx.einstellung?.betrieb?.telefon
|
||||
const ausweich = telefon ? `Termine telefonisch unter ${esc(telefon)}.` : 'Termine telefonisch vereinbaren.'
|
||||
return [
|
||||
`<div data-webklar-termine="${esc(ctx.feature.siteKey)}" data-api="${esc(config.publicBaseUrl)}" data-modus="inline">`,
|
||||
` <p>${ausweich}</p>`,
|
||||
'</div>',
|
||||
`<script src="${esc(config.publicBaseUrl)}/embed/termine.js" defer></script>`,
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
router.use((_req, res, next) => {
|
||||
res.set('Cache-Control', 'no-store')
|
||||
next()
|
||||
@@ -341,7 +328,7 @@ router.get('/einstellungen', requireSession, withTermine({ entwurf: true }), rou
|
||||
bestellung: bestellbar ? await bestellungAnsicht(register) : null,
|
||||
feeds: e && state === 'active' ? feedLinks(ctx) : {},
|
||||
siteKey: ctx.feature.siteKey || null,
|
||||
einbau: laeuft ? { snippet: einbauSnippet(ctx) } : {},
|
||||
einbau: laeuft ? { snippet: einbauSnippet({ siteKey: ctx.feature.siteKey, apiBase: config.publicBaseUrl, telefon: ctx.einstellung?.betrieb?.telefon }) } : {},
|
||||
anfragenOffen: laeuft ? await zaehleAnfragen(ctx.feature.$id) : 0,
|
||||
fehlt: e ? fehlendePflichtangaben(e) : [], // Pflichtangaben für Gäste (Ruling R41): solange etwas fehlt, ist die Funktion nicht buchbar
|
||||
})
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import { VORGABE_FARBE } from './einstellungen.js'
|
||||
|
||||
/**
|
||||
* Einbau des Terminplaners auf der Website des Kunden: Bausteine fuer Einbau-Code und Farbvorschlag
|
||||
* (Spezifikation 2026-10-03-terminplaner-tp2-verkauf-design.md, Abschnitt Einbauanleitung; Anleitung fuer das Team:
|
||||
* docs/funktionen/termine-einbau.md, Abschnitte 1 und 3).
|
||||
*
|
||||
* Reine Funktionen ohne Appwrite und ohne config: die Adresse des Portals (`apiBase`) und alle Werte kommen als Parameter.
|
||||
* Der Einbau-Code stand bis TP2 in routes/termine.js; GET /api/termine/einstellungen ruft ihn von hier auf (Modus inline, die Kundenansicht bleibt unveraendert).
|
||||
*
|
||||
* Die Farbfunktionen (luminanz, textFarbeAuf) sind 1:1 die aus public/embed/termine.js (Marken @farben-anfang/@farben-ende),
|
||||
* bewusst kopiert und nicht importiert: das Widget ist ein eigenstaendiges Browser-Skript. test/termineEmbedStatic.test.js
|
||||
* haelt beide fuer 4096 Farben gleich; wer eine Seite aendert, aendert die andere mit.
|
||||
*/
|
||||
|
||||
const FARBE_RE = /^#[0-9a-fA-F]{6}$/
|
||||
const KNOPF_MAX = 40
|
||||
const MIN_RAHMEN = 3 // WCAG 2.1 AA fuer Bedienelemente und Rahmen (1.4.11): der Rahmen freier Tage steht in der Akzentfarbe
|
||||
|
||||
// ---------------------------------------------------------------- Text
|
||||
|
||||
/** Maskiert &, <, >, " fuer Text und Attributwerte in doppelten Anfuehrungszeichen. null und undefined werden zu ''. */
|
||||
export function esc(text) {
|
||||
return String(text ?? '').replace(/[&<>"]/g, (z) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[z])
|
||||
}
|
||||
|
||||
/** Knopf-Text fuer data-knopf: Zeilenumbrueche und Steuerzeichen zu Leerzeichen, hoechstens 40 Zeichen (nie mitten in einem Zeichen). */
|
||||
function knopfTextBereinigt(roh) {
|
||||
if (typeof roh !== 'string') return ''
|
||||
const eine = roh.replace(/\r\n/g, ' ').replace(/[\p{Cc}\p{Zl}\p{Zp}]/gu, ' ').trim()
|
||||
return Array.from(eine).slice(0, KNOPF_MAX).join('')
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Einbau-Code
|
||||
|
||||
/**
|
||||
* Einbau-Code fuer die Website: Platzhalter mit Ausweichtext (bleibt ohne JavaScript stehen) und das Skript.
|
||||
* modus: 'inline' (Standard, die Strecke steht in der Seite) | 'knopf' (ein Knopf oeffnet einen Dialog); jeder andere Wert gilt
|
||||
* als 'inline'. knopfText gilt nur im Modus knopf und nur, wenn er nicht leer ist; sonst nimmt das Widget "Termin buchen".
|
||||
*/
|
||||
export function einbauSnippet({ siteKey, apiBase, telefon, modus = 'inline', knopfText } = {}) {
|
||||
const knopf = modus === 'knopf' ? knopfTextBereinigt(knopfText) : ''
|
||||
const ausweich = telefon ? `Termine telefonisch unter ${esc(telefon)}.` : 'Termine telefonisch vereinbaren.'
|
||||
const attribute = [
|
||||
`data-webklar-termine="${esc(siteKey)}"`,
|
||||
`data-api="${esc(apiBase)}"`,
|
||||
`data-modus="${modus === 'knopf' ? 'knopf' : 'inline'}"`,
|
||||
...(knopf ? [`data-knopf="${esc(knopf)}"`] : []),
|
||||
]
|
||||
return [
|
||||
`<div ${attribute.join(' ')}>`,
|
||||
` <p>${ausweich}</p>`,
|
||||
'</div>',
|
||||
`<script src="${esc(apiBase)}/embed/termine.js" defer></script>`,
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Farben
|
||||
|
||||
/** Relative Leuchtdichte nach WCAG 2.1 fuer '#rrggbb' (wie im Widget; erwartet eine gueltige Farbe). */
|
||||
export function luminanz(farbe) {
|
||||
const rgb = [1, 3, 5].map((i) => {
|
||||
const c = parseInt(farbe.slice(i, i + 2), 16) / 255
|
||||
return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4)
|
||||
})
|
||||
return 0.2126 * rgb[0] + 0.7152 * rgb[1] + 0.0722 * rgb[2]
|
||||
}
|
||||
|
||||
/** Kontrastverhaeltnis zweier Leuchtdichten (1 bis 21), wie kontrast() im Widget. */
|
||||
const verhaeltnis = (a, b) => (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05)
|
||||
|
||||
/** Kontrastverhaeltnis zweier Farben '#rrggbb' (1 bis 21, Reihenfolge egal). */
|
||||
export function kontrast(hexA, hexB) {
|
||||
return verhaeltnis(luminanz(hexA), luminanz(hexB))
|
||||
}
|
||||
|
||||
/**
|
||||
* Schrift auf der Betriebsfarbe: Weiss oder das dunkle Grau, je nachdem was mehr Kontrast gibt; erreicht keines 4,5 : 1
|
||||
* (mittlere Toene), nimmt sie Schwarz (mindestens 4,58 : 1 fuer jede Farbe). 1:1 aus dem Widget.
|
||||
*/
|
||||
export function textFarbeAuf(farbe) {
|
||||
const l = luminanz(farbe)
|
||||
const weiss = verhaeltnis(l, 1)
|
||||
const dunkel = verhaeltnis(l, luminanz('#111827'))
|
||||
const beste = Math.max(weiss, dunkel)
|
||||
if (beste < 4.5 && verhaeltnis(l, 0) > beste) return '#000000'
|
||||
return weiss >= dunkel ? '#ffffff' : '#111827'
|
||||
}
|
||||
|
||||
/** Kontrast mit einer Nachkommastelle und deutschem Komma, abgerundet ("unter 3 : 1" bleibt auch im Text unter 3). */
|
||||
const kontrastAnzeige = (wert) => (Math.floor(wert * 10) / 10).toFixed(1).replace('.', ',')
|
||||
|
||||
/**
|
||||
* CSS-Vorschlag fuer die Website aus der Betriebsfarbe (Anleitung Abschnitt 3): Kommentar plus
|
||||
* `[data-webklar-termine]{ --wkt-akzent; --wkt-akzent-text }`. Nur eine gueltige '#rrggbb' (gross oder klein) wird uebernommen;
|
||||
* alles andere ersetzt VORGABE_FARBE, der Rohtext kommt nie in die Ausgabe.
|
||||
* Rueckgabe: css, akzent (die verwendete Farbe, klein), akzentText (Schrift darauf), kontrastText (Akzent gegen Schrift),
|
||||
* kontrastSeite (Akzent gegen Weiss), hinweise (Warnungen, leer wenn alles passt). Der Rahmen der freien Tage steht in der
|
||||
* Akzentfarbe und braucht gegen den Hintergrund mindestens 3 : 1; das Widget hat standardmaessig einen weissen Hintergrund.
|
||||
*/
|
||||
export function cssVorschlag(farbe) {
|
||||
const akzent = typeof farbe === 'string' && FARBE_RE.test(farbe) ? farbe.toLowerCase() : VORGABE_FARBE
|
||||
const akzentText = textFarbeAuf(akzent)
|
||||
const textKontrast = kontrast(akzent, akzentText)
|
||||
const seitenKontrast = kontrast(akzent, '#ffffff')
|
||||
const hinweise = []
|
||||
if (seitenKontrast < MIN_RAHMEN) {
|
||||
hinweise.push(
|
||||
`Kontrast gegen Weiß: nur ${kontrastAnzeige(seitenKontrast)} : 1. Der Rahmen der freien Tage steht in der Akzentfarbe ${akzent} und braucht ` +
|
||||
`mindestens ${MIN_RAHMEN} : 1 gegen den Hintergrund. Auf weißem Grund wähle eine dunklere Farbe, auf dunklem Grund setze --wkt-hintergrund passend.`
|
||||
)
|
||||
}
|
||||
const css = [
|
||||
`/* Terminplaner: Farbe der Knöpfe und Schrift darauf, aus der Betriebsfarbe. Kontrast der Schrift: ${kontrastAnzeige(textKontrast)} : 1. */`,
|
||||
'[data-webklar-termine]{',
|
||||
` --wkt-akzent:${akzent};`,
|
||||
` --wkt-akzent-text:${akzentText};`,
|
||||
'}',
|
||||
].join('\n')
|
||||
return { css, akzent, akzentText, kontrastText: textKontrast, kontrastSeite: seitenKontrast, hinweise }
|
||||
}
|
||||
@@ -12,7 +12,7 @@ import { heute, zuUtc } from './zeit.js'
|
||||
* Fehler sind { [pfad]: text } mit Pfaden wie 'betrieb.name' oder 'ressourcen.0.plan.1'; Texte in Du-Form (Portal).
|
||||
*/
|
||||
|
||||
const VORGABE_FARBE = '#7c3aed'
|
||||
export const VORGABE_FARBE = '#7c3aed'
|
||||
const GRENZEN = { ressourcen: 20, leistungen: 50, abwesenheiten: 100, sonderoeffnungen: 200, zusatzfelder: 5, optionen: 10, json: 200000 }
|
||||
const RASTER = [5, 10, 15, 20, 30, 60]
|
||||
const ERINNERUNG_STUNDEN = [2, 12, 24, 48]
|
||||
|
||||
Reference in new issue
Block a user