Files
Webklar-Kundenbereich/public/shop-dom.js
T
ClaudeandClaude Opus 5.5 738afa2d0e Shop G2: Hülle #webshop mit Anfrage, Bestellseite, Statuskarte und fünf Reitern
Eigene Helfer in shop-dom.js (inkl. euroEingabe, datumText), Anfrageformular und -karten
in shop-anfrage.js, Reiter als lazy geladene Platzhalter-Module für G3 bis G6.
Die Bestellseite zeigt den Hinweis zu „Ändern“ nur noch, wenn es Schritte gibt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 09:05:01 +00:00

179 lines
8.7 KiB
JavaScript

/* Webshop im Kundenbereich: Helfer ohne Zustand (Elemente bauen, Schnittstelle, Formularfelder, Hinweiskarten, Euro und Datum).
Eigene Kopie der Helfer aus termine-dom.js (die Webshop-Dateien importieren nichts aus termine*.js); Klassen mit sh-.
Alle Module der Ansicht (shop.js, shop-anfrage.js, shop-tab-*.js) bauen die Seite nur hiermit: Kundentext kommt ausschliesslich
ueber textContent in die Seite (h), nie ueber innerHTML. */
/** Element bauen: Texte nur als Textknoten, Attribute mit on… als Ereignis, true setzt ein Attribut ohne Wert, false/null/undefined lassen es weg. */
export function h(tag, attrs, ...kinder) {
const el = document.createElement(tag)
for (const [name, wert] of Object.entries(attrs || {})) {
if (wert === undefined || wert === null || wert === false) continue
if (name === 'class') el.className = wert
else if (name.startsWith('on')) el.addEventListener(name.slice(2), wert)
else el.setAttribute(name, wert === true ? '' : wert)
}
for (const kind of kinder.flat()) if (kind !== undefined && kind !== null && kind !== false) el.append(kind)
return el
}
/** Schnittstelle: JSON hin und her; Fehler als Error mit status, fehler (Feldfehler), code und body (ganze Antwort), Netzwerkfehler mit status 0. */
export async function request(path, { method = 'GET', body } = {}) {
let response
try {
response = await fetch(path, {
method,
credentials: 'same-origin',
headers: body ? { 'Content-Type': 'application/json' } : {},
body: body ? JSON.stringify(body) : undefined,
})
} catch {
throw Object.assign(new Error('Keine Verbindung. Bitte versuch es gleich noch einmal.'), { status: 0, fehler: {} })
}
const data = await response.json().catch(() => ({}))
if (!response.ok) {
const err = new Error(data.error || (response.status === 502 ? 'Server nicht erreichbar. Bitte kurz warten und noch einmal versuchen.' : `Fehler ${response.status}`))
err.status = response.status
err.fehler = data.fehler || {}
err.code = data.code
err.body = data !== null && typeof data === 'object' ? data : {}
throw err
}
return data
}
let idZaehler = 0
const neueId = () => `sh-f${++idZaehler}`
/** Formularfeld mit Beschriftung, Hinweis und Platz fuer den Fehler (data-feld = Feldname der Schnittstelle). */
export function feld(name, label, steuer, hinweis) {
const id = steuer.id || (steuer.id = neueId())
steuer.dataset.feld = name
return h('div', { class: 'sh-feld' },
h('label', { for: id }, label),
steuer,
hinweis ? h('p', { class: 'sh-hinweis' }, hinweis) : null,
h('p', { class: 'sh-fehler', 'data-fehler': name, role: 'alert' }))
}
/** Ankreuzfeld (input type=checkbox) mit Beschriftung, Hinweis und Platz fuer den Fehler. */
export function hakenFeld(name, steuer, text, hinweis) {
steuer.dataset.feld = name
return h('div', { class: 'sh-feld sh-feld-haken' },
h('label', { class: 'sh-haken' }, steuer, h('span', null, text, hinweis ? h('small', { class: 'sh-hinweis' }, hinweis) : null)),
h('p', { class: 'sh-fehler', 'data-fehler': name, role: 'alert' }))
}
/** Platz fuer Fehler, die zu keinem Feld gehoeren (Feldname „_“). */
export const allgemeinerFehler = () => h('p', { class: 'sh-fehler', 'data-fehler': '_', role: 'alert' })
export function setzeFehler(form, fehler) {
for (const el of form.querySelectorAll('[data-fehler]')) el.textContent = ''
for (const el of form.querySelectorAll('[aria-invalid]')) el.removeAttribute('aria-invalid')
const rest = []
for (const [name, text] of Object.entries(fehler || {})) {
// „_“ und Namen ohne Platz im Formular werden allgemein gezeigt
const ziel = name === '_' ? null : form.querySelector(`[data-fehler="${CSS.escape(name)}"]`)
if (ziel) {
ziel.textContent = text
form.querySelector(`[data-feld="${CSS.escape(name)}"]`)?.setAttribute('aria-invalid', 'true')
} else {
rest.push(text)
}
}
const allgemein = form.querySelector('[data-fehler="_"]')
if (allgemein) allgemein.textContent = rest.join(' ')
}
/** Fehler der Schnittstelle am Formular zeigen (Feldfehler am Feld, sonst allgemein); der Fokus geht auf das erste fehlerhafte Feld. */
export function zeigeFehler(form, err) {
const fehler = err.fehler && Object.keys(err.fehler).length ? err.fehler : { _: err.message }
setzeFehler(form, fehler)
form.querySelector('[aria-invalid]')?.focus()
}
/** Rueckfrage in der Seite (kein confirm()): Formular mit Titel; der Fokus gehoert dem Aufrufer. */
export function frage(titel, ...inhalt) {
return h('form', { class: 'sh-frage-box', novalidate: true, tabindex: '-1', 'aria-label': titel, onsubmit: (ev) => ev.preventDefault() }, h('strong', null, titel), ...inhalt)
}
/** Abschnitt eines Reiters: Karte mit Ueberschrift und einem erklaerenden Satz (hinweis darf fehlen). */
export const abschnitt = (titel, hinweis, ...inhalt) => h('section', { class: 'kb-panel sh-abschnitt' },
h('h3', null, titel),
hinweis ? h('p', { class: 'sh-hinweis sh-abschnitt-text' }, hinweis) : null,
...inhalt)
export const infoKarte = (titel, text, { chat = false } = {}) => h('div', { class: 'kb-panel sh-info' },
h('h3', null, titel),
text ? h('p', null, text) : null,
chat ? h('button', { type: 'button', class: 'kb-btn-outline', 'data-view': 'chat' }, 'Chat öffnen') : null)
export const fehlerKarte = (text, nochmal) => h('div', { class: 'kb-panel sh-info', role: 'alert' },
h('p', null, text),
nochmal ? h('button', { type: 'button', class: 'kb-btn-outline', onclick: nochmal }, 'Noch einmal versuchen') : null)
/* ------------------------------------------------------------------ Euro und Datum --- */
/**
* Euro aus einem Feld in Cent (nur Anzeige und Vorpruefung, den Betrag prueft der Server). Deutsche Schreibweise: ',' trennt die Cent,
* '.' die Tausender ('1.234,50'); '45.50' und '12.5' gelten als Euro und Cent. null bei allem anderen (leer, Buchstaben, mehr als zwei
* Nachkommastellen).
*/
export function euroZuCent(text) {
if (typeof text !== 'string') return null
const s = text.replace(/€/g, '').trim()
if (!/^[\d.,]+$/.test(s)) return null
let ganz = s
let rest = ''
const komma = s.indexOf(',')
if (komma >= 0) {
ganz = s.slice(0, komma)
rest = s.slice(komma + 1)
if (/[.,]/.test(rest)) return null
if (ganz.includes('.') && !/^\d{1,3}(\.\d{3})+$/.test(ganz)) return null
ganz = ganz.replaceAll('.', '')
} else if (/^\d{1,3}(\.\d{3})+$/.test(s)) {
ganz = s.replaceAll('.', '')
} else if (s.includes('.')) {
const teile = s.split('.')
if (teile.length !== 2) return null
;[ganz, rest] = teile
}
if (!/^\d{1,9}$/.test(ganz) || !/^\d{0,2}$/.test(rest)) return null
return Number(ganz) * 100 + Number(rest.padEnd(2, '0'))
}
/** 1250 ⇒ '12,50' (Feldwert); unbrauchbar ⇒ ''. */
export const centZuEuro = (cent) => (Number.isInteger(cent) && cent >= 0 ? `${Math.floor(cent / 100)},${String(cent % 100).padStart(2, '0')}` : '')
/** 1250 ⇒ '12,50 €' (mit Tausenderpunkt, geschuetztes Leerzeichen vor €); unbrauchbar ⇒ ''. */
export function euroText(cent) {
if (!Number.isInteger(cent) || cent < 0) return ''
return `${String(Math.floor(cent / 100)).replace(/\B(?=(\d{3})+(?!\d))/g, '.')},${String(cent % 100).padStart(2, '0')} €`
}
/**
* Eingabefeld fuer einen Euro-Betrag (Text, nie type=number: Komma statt Punkt). Der Wert steht als '12,50' im Feld; beim Verlassen wird eine
* lesbare Eingabe ('12,5', '12.50 €') in diese Schreibweise gebracht. steuer.inCent() liefert die Cent (null bei leer oder unlesbar),
* steuer.istLeer() und steuer.istLesbar() erklaeren warum. Gesendet wird, was der Aufrufer daraus macht; der Server prueft den Betrag.
*/
export function euroEingabe({ cent = null, id, placeholder = '0,00', label } = {}) {
const steuer = h('input', { type: 'text', inputmode: 'decimal', autocomplete: 'off', placeholder, id, 'aria-label': label })
steuer.value = centZuEuro(cent)
steuer.istLeer = () => String(steuer.value).trim() === ''
steuer.inCent = () => (steuer.istLeer() ? null : euroZuCent(String(steuer.value)))
steuer.istLesbar = () => steuer.istLeer() || steuer.inCent() !== null
steuer.addEventListener('change', () => {
const c = steuer.inCent()
if (c !== null) steuer.value = centZuEuro(c)
})
return steuer
}
/** Datum und Uhrzeit in Berlin als '05.10.2026, 14:30'; nur das Datum mit { zeit: false }; bei fehlendem oder ungueltigem Wert ''. */
export function datumText(wert, { zeit = true } = {}) {
const tag = new Date(wert)
if (!wert || Number.isNaN(tag.getTime())) return ''
const optionen = { timeZone: 'Europe/Berlin', day: '2-digit', month: '2-digit', year: 'numeric' }
if (zeit) Object.assign(optionen, { hour: '2-digit', minute: '2-digit', hourCycle: 'h23' })
return tag.toLocaleString('de-DE', optionen)
}