Files
Webklar-Kundenbereich/public/neu/ansicht-gutschrift.js
T
ClaudeandClaude Sonnet 5.5 4c64edc057 Kundenansicht neu E-Fix M3: Fehlermeldungen der Bestellseiten und Formulare als Live-Region (role="alert")
abo-error, switch-error (und der Fehler der Wechsel-Vorschau), storage-error, tk-error samt Fehlerzeile der Rückfrage-Antwort, der Fehler von
"Zahlungsart aktualisieren" sowie login-error und form-error auf Login, Kündigen und Widerrufen waren einfache Absätze: Screenreader lasen
sie nicht vor, obwohl der Knopf wieder frei wurde. Jetzt role="alert", gepinnt in test/knEfixCss.test.js.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 21:42:07 +00:00

239 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* Kundenansicht neu (P7): Übernahmegutschrift beantragen (#gutschrift) im schlanken Muster der Spec 5.6, Fassung 6. Antrag stellen, Stand
sehen, Rückfragen beantworten. Über dem Knopf steht ein Satz mit den Links zu den Bedingungen und zum Datenschutz (der Klick auf den Knopf
ist die Zustimmung, kein Häkchen). Die Bedingungen sind eine eigene Ansicht (#gutschrift?bedingungen=1) mit unverändertem Rechtstext
(Block TAKEOVER_CONDITIONS, Hash-Pin in legalTexts.test.js, conditionsVersion 2026-09-30). Kundentext (Rückfrage, Entscheidung, Anbieter,
Domain) kommt nur als Text in die Seite. Der Titel der Bedingungen kommt von der Hülle (meldeTitel); keine eigene h1.
Die Sie-Form steht nur noch im Rechtstext TAKEOVER_CONDITIONS (hash-gepinnt), sonst gilt „du“. */
import { api } from './api.js'
import { h, leeren } from './dom.js'
import { K, ersetze, zuAnsicht, ladeAbo, fmtDatum, fmtEuro, meldeTitel, meldeZaehlerNeu, ABO_NICHT_GELADEN } from './ansicht-hilfen.js'
import { baueLinkzeile, hatWortlaut } from './bestell-bausteine.js'
const laeufe = new WeakMap() // Wurzel → Nummer des jüngsten Aufrufs
const TEXT_ANSICHTSMODUS = 'Im Ansichtsmodus kann kein Antrag gestellt werden.'
const TEXT_NICHT_VERFUEGBAR = 'Die Online-Bestellung wird gerade eingerichtet – schreib uns gern im Chat.'
const TITEL_BEDINGUNGEN = 'Bedingungen der Übernahmegutschrift'
const GRENZEN_VORGABE = { maxFiles: 3, maxFileBytes: 8388608, maxTotalBytes: 10485760 }
const TAKEOVER_STATUS = {
offen: ['In Prüfung', 'orange'],
rueckfrage: ['Rückfrage', 'violett'],
genehmigt: ['Genehmigt', 'gruen'],
abgelehnt: ['Nicht genehmigt', 'grau'],
}
// Bei Textänderungen config.takeover.conditionsVersion auf dem Server hochziehen
const TAKEOVER_CONDITIONS = [
'Die Übernahmegutschrift gibt es für Leistungen, die Ihr Abo ersetzt (Domain, Hosting bzw. Betrieb der Website, E-Mail-Postfächer) und die Sie bei Ihrem bisherigen Anbieter bereits im Voraus bezahlt haben.',
'Wir schreiben den Anteil der Rechnung gut, der auf die Zeit ab Leistungsbeginn Ihres Abos bis zum Ende des bezahlten Zeitraums entfällt, tagesgenau berechnet.',
'Nachweis ist die Rechnung des bisherigen Anbieters, ausgestellt auf Sie, mit Leistung, Zeitraum und Betrag. Wir können weitere Nachweise anfordern, etwa die Kündigungsbestätigung. Erstattet Ihr bisheriger Anbieter Beträge für diesen Zeitraum, teilen Sie uns das bitte mit.',
'Die Gutschrift wird mit Ihren nächsten Monatsgebühren verrechnet, auch nach einem Wechsel in ein anderes Abo. Sie wird nicht ausgezahlt und ist nicht übertragbar; endet Ihr Abo ohne einen solchen Wechsel, entfällt ein noch nicht verrechneter Rest.',
'Die Übernahmegutschrift ist eine freiwillige Leistung von WEBklar. Über Ihren Antrag entscheiden wir nach Prüfung und antworten Ihnen per E-Mail.',
]
const TAKEOVER_MONTHS = [[1, '1 Monat'], [3, '3 Monate'], [6, '6 Monate'], [12, '12 Monate (Jahresrechnung)'], [24, '24 Monate'], [36, '36 Monate']]
/** Datei als Base64 lesen (ohne den data:-Anfang). Tests ersetzen IO.lies, im Test gibt es keinen FileReader. */
function leseAlsBase64(datei) {
return new Promise((resolve, reject) => {
const leser = new FileReader()
leser.onload = () => resolve(String(leser.result).split(',')[1] || '')
leser.onerror = () => reject(new Error(`„${datei.name}“ konnte nicht gelesen werden.`))
leser.readAsDataURL(datei)
})
}
export const IO = { lies: leseAlsBase64 }
/** Dateien prüfen und lesen: → [{ name, data }]; wirft die Texte der Prüfungen aus app.js. */
export async function sammleDateien(dateien, grenzen, { erforderlich }, lies = IO.lies) {
const files = [...(dateien || [])]
const mb = (bytes) => Math.round(bytes / 1024 / 1024)
if (erforderlich && !files.length) throw new Error('Bitte lade die Rechnung deines bisherigen Anbieters hoch.')
if (files.length > grenzen.maxFiles) throw new Error(`Bitte höchstens ${grenzen.maxFiles} Dateien auf einmal hochladen.`)
const zuGross = files.find((f) => f.size > grenzen.maxFileBytes)
if (zuGross) throw new Error(`„${zuGross.name}“ ist größer als ${mb(grenzen.maxFileBytes)} MB.`)
if (files.reduce((s, f) => s + f.size, 0) > grenzen.maxTotalBytes) throw new Error(`Die Dateien sind zusammen größer als ${mb(grenzen.maxTotalBytes)} MB.`)
return Promise.all(files.map(async (f) => ({ name: f.name, data: await lies(f) })))
}
const feld = (label, steuerung, klein) => h('div', { class: 'kn-tk-feld' }, h('label', { for: steuerung.getAttribute('id') }, label, klein ? [' ', h('small', null, klein)] : null), steuerung)
const leistung = (wert, text) => h('label', null, h('input', { type: 'checkbox', name: 'services', value: wert }), ` ${text}`)
function antragsFormular(grenzen, email, T) {
const maxMb = Math.round(grenzen.maxTotalBytes / 1024 / 1024)
return h('form', { id: 'tk-form', class: 'kn-tk-form kn-bestellen', novalidate: true, hidden: true },
h('fieldset', { class: 'kn-tk-feld' }, h('legend', null, 'Welche Leistung hast du bisher woanders bezahlt?'),
h('div', { class: 'kn-tk-leistungen' }, leistung('domain', 'Domain'), leistung('hosting', 'Hosting / Website-Betrieb'), leistung('email', 'E-Mail-Postfächer'), leistung('sonstiges', 'Sonstiges'))),
h('div', { class: 'kn-tk-zeile' },
feld('Bisheriger Anbieter', h('input', { id: 'tk-provider', class: K.input, maxlength: 200, placeholder: 'z. B. IONOS, STRATO' })),
feld('Domain', h('input', { id: 'tk-domain', class: K.input, maxlength: 255, placeholder: 'meine-firma.de', autocapitalize: 'off', spellcheck: 'false' }), '(falls vorhanden)')),
h('div', { class: 'kn-tk-zeile' },
feld('Rechnungsbetrag', h('input', { id: 'tk-amount', class: K.input, type: 'number', min: '0.01', step: '0.01', inputmode: 'decimal', placeholder: '119,88' }), '(brutto, in €)'),
feld('Die Rechnung gilt für', h('select', { id: 'tk-months', class: K.select }, h('option', { value: '' }, 'bitte wählen'), TAKEOVER_MONTHS.map(([v, l]) => h('option', { value: String(v) }, l)))),
feld('Bezahlt bis', h('input', { id: 'tk-paid-until', class: K.input, type: 'date' }), '(letzter Tag)')),
feld('Grund / Nachricht an uns', h('textarea', { id: 'tk-message', class: K.textarea, maxlength: 4000, placeholder: 'z. B.: Domain und Postfach liegen noch bis Ende März bei IONOS, der Vertrag dort ist gekündigt.' })),
feld('Rechnungsnachweis', h('input', { id: 'tk-files', type: 'file', accept: 'application/pdf,image/jpeg,image/png', multiple: true }), `(PDF, JPG oder PNG · bis zu ${grenzen.maxFiles} Dateien · zusammen max. ${maxMb} MB)`),
h('p', { class: 'kn-hinweis' }, 'Tipp: Lade auch die Kündigungsbestätigung deines bisherigen Anbieters hoch – daraus sehen wir, wann der Vertrag dort endet.'),
feld('E-Mail-Adresse für unsere Antwort', h('input', { id: 'tk-email', class: K.input, type: 'email', maxlength: 255, autocomplete: 'email', value: email })),
baueLinkzeile(T.gutschrift.satz, { id: 'tk-satz', neuerTab: true }),
h('p', { class: K.fehler, id: 'tk-error', role: 'alert', hidden: true }),
h('div', { class: 'kn-aktionen' },
h('button', { type: 'submit', class: K.primaer, id: 'tk-submit' }, T.gutschrift.knopf || 'Antrag absenden'),
h('button', { type: 'button', class: K.knopf, id: 'tk-cancel' }, 'Abbrechen')))
}
function antrag(r, viewAs) {
const fakten = [
`eingereicht am ${fmtDatum(r.createdAt)}`,
`bisher bei ${r.provider}${r.domain ? ` (${r.domain})` : ''}`,
`${fmtEuro(r.invoiceAmountCents / 100)} für ${r.billingMonths === 1 ? '1 Monat' : `${r.billingMonths} Monate`}, bezahlt bis ${fmtDatum(r.paidUntil)}`,
]
const [pilleText, ton] = TAKEOVER_STATUS[r.status] || [r.status, 'grau']
let koerper
if (r.status === 'genehmigt') {
koerper = [h('p', null, 'Gutschrift: ', h('strong', null, fmtEuro(r.amountCents / 100)), ' – wird mit deinen nächsten Monatsgebühren verrechnet.'),
r.decisionNote ? h('p', { class: 'kn-hinweis kn-umbruch' }, r.decisionNote) : null]
} else if (r.status === 'abgelehnt') {
koerper = [h('p', { class: 'kn-hinweis kn-umbruch' }, r.decisionNote)]
} else if (r.status === 'rueckfrage') {
koerper = [
h('div', { class: 'kn-tk-frage' }, h('strong', null, 'Unsere Rückfrage:'), h('br'), r.question),
h('form', { class: 'kn-tk-antwort', 'data-reply-for': r.id, novalidate: true },
h('textarea', { name: 'message', class: K.textarea, maxlength: 4000, placeholder: 'Deine Antwort…' }),
h('input', { type: 'file', name: 'files', accept: 'application/pdf,image/jpeg,image/png', multiple: true, 'aria-label': 'Weitere Nachweise' }),
h('p', { class: K.fehler, role: 'alert', hidden: true }),
h('div', { class: 'kn-aktionen' }, h('button', { type: 'submit', class: K.primaer, disabled: viewAs }, 'Antwort senden')))]
} else {
koerper = [h('p', { class: 'kn-hinweis' }, 'Wir prüfen deine Angaben und antworten dir per E-Mail.')]
}
return h('div', { class: 'kn-tk-antrag' },
h('div', { class: 'kn-tk-kopf' }, h('strong', null, `Antrag ${r.ref} · ${r.services}`), h('span', { class: K.pille[ton] }, pilleText)),
h('div', { class: 'kn-hinweis' }, fakten.join(' · ')),
...koerper)
}
/** Seite Übernahmegutschrift. Lädt Abo und Anträge selbst; nach Absenden oder Antworten lädt sie neu und meldet den Zähler. */
export async function zeigeGutschrift(wurzel, { route, me, nav } = {}) {
const viewAs = me?.viewAs === true
const email = me?.customer?.email || ''
const nr = (laeufe.get(wurzel) ?? 0) + 1
laeufe.set(wurzel, nr)
const aktuell = () => laeufe.get(wurzel) === nr
// Die Bedingungen sind eine eigene Ansicht (#gutschrift?bedingungen=1): fünf Sätze, kein Abruf, kein Formular
if (new URLSearchParams(String(route?.query ?? '')).get('bedingungen') === '1') {
meldeTitel(wurzel, TITEL_BEDINGUNGEN)
leeren(wurzel).append(h('div', { class: 'kn-panel kn-gutschrift-bedingungen', 'data-karte': 'gutschrift-bedingungen' },
h('ol', { class: 'kn-tk-bedingungen' }, TAKEOVER_CONDITIONS.map((c) => h('li', null, c))),
h('p', { class: 'kn-links' }, h('a', { ...zuAnsicht('gutschrift'), href: '#gutschrift' }, 'Zurück zum Antrag'))))
return
}
leeren(wurzel).append(h('p', { class: K.leer }, 'Wird geladen …'))
await lade('')
async function lade(notice, { zaehler = false } = {}) {
const abo = await ladeAbo()
if (!aktuell()) return
if (!abo) { leeren(wurzel).append(h('p', { class: K.fehler }, ABO_NICHT_GELADEN)); return }
const aktiv = Boolean(abo.subscription?.active)
let daten = { requests: [], limits: null }
if (abo.subscription) {
try {
daten = await api('/api/abo/takeover-credits')
} catch (f) {
if (!aktuell()) return
leeren(wurzel).append(h('div', { 'data-karte': 'gutschrift' }, h('div', { class: 'kn-panel kn-leer' }, `Übernahmegutschrift konnte nicht geladen werden: ${f.message}`)))
return
}
if (!aktuell()) return
}
if (zaehler) meldeZaehlerNeu('vertraege')
const anfragen = Array.isArray(daten.requests) ? daten.requests : []
const grenzen = daten.limits || GRENZEN_VORGABE
if (!aktiv && !anfragen.length) {
leeren(wurzel).append(h('div', { 'data-karte': 'gutschrift' }, h('div', { class: 'kn-panel' },
h('p', { class: 'kn-hinweis' }, 'Die Übernahmegutschrift kannst du beantragen, sobald dein Abo läuft. ', h('a', zuAnsicht('vertraege'), 'Zum Abo')))))
return
}
// Ohne Wortlaut vom Server (der Satz über dem Knopf) gibt es kein Formular und keinen Knopf
const wortlaut = hatWortlaut(abo, 'gutschrift')
const formular = aktiv && wortlaut ? antragsFormular(grenzen, email, abo.bestelltexte) : null
const start = aktiv && wortlaut ? h('button', { type: 'button', class: K.knopf, id: 'tk-start', disabled: viewAs }, 'Gutschrift beantragen') : null
ersetze(wurzel, h('div', { 'data-karte': 'gutschrift' }, h('div', { class: 'kn-panel' },
h('p', { class: 'kn-hinweis kn-tk-einleitung' }, 'Hast du Domain, Hosting oder E-Mail bei deinem bisherigen Anbieter schon im Voraus bezahlt? Den Anteil, den du ab Beginn deines Abos doppelt zahlst, schreiben wir dir gut und verrechnen ihn mit deinen nächsten Monatsgebühren. Schick uns dazu einfach die Rechnung.'),
notice ? h('div', { class: 'kn-meldung kn-meldung-ok', role: 'status' }, notice) : null,
aktiv && wortlaut ? h('div', { class: 'kn-aktionen' }, start, viewAs ? h('span', { class: K.meldung }, TEXT_ANSICHTSMODUS) : null) : null,
aktiv && !wortlaut ? h('p', { class: 'kn-hinweis' }, TEXT_NICHT_VERFUEGBAR) : null,
formular,
anfragen.length ? h('div', { class: 'kn-tk-liste' }, anfragen.map((r) => antrag(r, viewAs))) : null)))
const neuLaden = (text) => lade(text, { zaehler: true })
start?.addEventListener('click', () => {
formular.hidden = false
start.hidden = true
formular.scrollIntoView?.({ behavior: 'smooth', block: 'nearest' })
})
formular?.querySelector('#tk-cancel')?.addEventListener('click', () => { formular.hidden = true; if (start) start.hidden = false })
formular?.addEventListener('submit', (ev) => { ev.preventDefault(); sendeAntrag(formular, grenzen, neuLaden, abo.termsVersion) })
wurzel.querySelectorAll('form[data-reply-for]').forEach((f) => f.addEventListener('submit', (ev) => { ev.preventDefault(); sendeAntwort(f, grenzen, neuLaden, viewAs) }))
}
}
function zeigeFehler(el, text) {
if (!el) return
el.textContent = text
el.hidden = false
}
async function sendeAntrag(form, grenzen, neuLaden, termsVersion) {
const fehler = form.querySelector('#tk-error')
const knopf = form.querySelector('#tk-submit')
fehler.hidden = true
const wert = (id) => form.querySelector(id).value.trim()
const payload = {
services: [...form.querySelectorAll('input[name="services"]:checked')].map((i) => i.value),
provider: wert('#tk-provider'),
domain: wert('#tk-domain'),
invoiceAmount: wert('#tk-amount'),
billingMonths: Number(wert('#tk-months')),
paidUntil: wert('#tk-paid-until'),
message: wert('#tk-message'),
email: wert('#tk-email'),
// Der Klick auf den Knopf ist die Zustimmung zu Satz und Bedingungen; die Kundenart gibt es hier nicht, der Server kennt sie
consent: true,
termsVersion,
}
try {
if (!payload.services.length) throw new Error('Bitte wähle, welche Leistung du bisher woanders bezahlt hast.')
if (!payload.provider) throw new Error('Bitte nenne deinen bisherigen Anbieter.')
if (!payload.invoiceAmount || !payload.billingMonths || !payload.paidUntil) throw new Error('Bitte gib Rechnungsbetrag, Zeitraum und „Bezahlt bis“ an.')
knopf.disabled = true
knopf.textContent = 'Wird gesendet…'
payload.files = await sammleDateien(form.querySelector('#tk-files').files, grenzen, { erforderlich: true })
await api('/api/abo/takeover-credits', { method: 'POST', body: payload })
await neuLaden(`Vielen Dank! Dein Antrag ist eingegangen. Die Eingangsbestätigung haben wir an ${payload.email} geschickt; unsere Antwort kommt ebenfalls per E-Mail.`)
} catch (f) {
zeigeFehler(fehler, f.message)
knopf.disabled = false
knopf.textContent = 'Antrag absenden'
}
}
async function sendeAntwort(form, grenzen, neuLaden, viewAs) {
if (viewAs) return
const fehler = form.querySelector('p')
const knopf = form.querySelector('button[type="submit"]')
fehler.hidden = true
knopf.disabled = true
try {
const message = form.querySelector('textarea').value.trim()
const files = await sammleDateien(form.querySelector('input[type="file"]').files, grenzen, { erforderlich: false })
if (!message && !files.length) throw new Error('Bitte schreib eine Antwort oder lade einen Nachweis hoch.')
await api(`/api/abo/takeover-credits/${encodeURIComponent(form.getAttribute('data-reply-for'))}/reply`, { method: 'POST', body: { message, files } })
await neuLaden('Danke für deine Antwort! Wir prüfen deinen Antrag weiter und melden uns per E-Mail.')
} catch (f) {
zeigeFehler(fehler, f.message)
knopf.disabled = false
}
}