/* 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) }