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>
179 lines
8.7 KiB
JavaScript
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)
|
|
}
|