Files
Webklar-Kundenbereich/public/neu/ansicht-gutschrift.js
T
2026-10-16 17:11:15 +00:00

225 lines
15 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 (P2): Übernahmegutschrift beantragen (#gutschrift), Text und Logik 1:1 aus app.js (loadTakeoverCard und Helfer,
Fassung 5, Sie-Form, C14). Antrag stellen, Stand sehen, Rückfragen beantworten. Kundentext (Rückfrage, Entscheidung, Anbieter, Domain)
kommt nur als Text in die Seite. Das Wortlaut-Orakel prüft den sichtbaren Text, der Hash-Pin in legalTexts.test.js den Block der Bedingungen. */
import { api } from './api.js'
import { h, leeren } from './dom.js'
import { K, ersetze, zuAnsicht, ladeAbo, fmtDatum, fmtEuro, meldeZaehlerNeu, ABO_NICHT_GELADEN } from './ansicht-hilfen.js'
const laeufe = new WeakMap() // Wurzel → Nummer des jüngsten Aufrufs
const TEXT_ANSICHTSMODUS = 'Im Ansichtsmodus kann kein Antrag gestellt werden.'
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 laden Sie die Rechnung Ihres 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) {
const maxMb = Math.round(grenzen.maxTotalBytes / 1024 / 1024)
const bedingungen = h('details', { class: 'kn-tk-bedingungen' }, h('summary', null, 'Bedingungen der Übernahmegutschrift'), h('ol', null, TAKEOVER_CONDITIONS.map((c) => h('li', null, c))))
return h('form', { id: 'tk-form', class: 'kn-tk-form', novalidate: true, hidden: true },
h('fieldset', { class: 'kn-tk-feld' }, h('legend', null, 'Welche Leistung haben Sie 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: Laden Sie auch die Kündigungsbestätigung Ihres 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 })),
bedingungen,
h('label', { class: 'kn-check' }, h('input', { type: 'checkbox', id: 'tk-consent' }),
h('span', null, 'Ich versichere, dass meine Angaben stimmen und die Rechnung auf mich bzw. mein Unternehmen ausgestellt ist. Die Bedingungen der Übernahmegutschrift habe ich gelesen. Angaben und Nachweise verwendet WEBklar zur Prüfung und Abwicklung des Antrags (',
h('a', { href: '/datenschutz.html#uebernahmegutschrift', target: '_blank', rel: 'noopener' }, 'Datenschutz'), ').')),
h('p', { class: K.fehler, id: 'tk-error', hidden: true }),
h('div', { class: 'kn-aktionen' },
h('button', { type: 'submit', class: K.primaer, id: 'tk-submit' }, '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 Ihren 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: 'Ihre 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, 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 Ihre Angaben und antworten Ihnen 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
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 können Sie beantragen, sobald Ihr Abo läuft. ', h('a', zuAnsicht('vertraege'), 'Zum Abo')))))
return
}
const formular = aktiv ? antragsFormular(grenzen, email) : null
const start = aktiv ? 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' }, 'Sie haben Domain, Hosting oder E-Mail bei Ihrem bisherigen Anbieter schon im Voraus bezahlt? Den Anteil, den Sie ab Beginn Ihres Abos doppelt zahlen, schreiben wir Ihnen gut und verrechnen ihn mit Ihren nächsten Monatsgebühren. Schicken Sie uns dazu einfach die Rechnung.'),
notice ? h('div', { class: 'kn-meldung kn-meldung-ok', role: 'status' }, notice) : null,
aktiv ? h('div', { class: 'kn-aktionen' }, start, viewAs ? h('span', { class: K.meldung }, TEXT_ANSICHTSMODUS) : null) : 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) })
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) {
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'),
consent: form.querySelector('#tk-consent').checked,
}
try {
if (!payload.services.length) throw new Error('Bitte wählen Sie, welche Leistung Sie bisher woanders bezahlt haben.')
if (!payload.provider) throw new Error('Bitte nennen Sie Ihren bisherigen Anbieter.')
if (!payload.invoiceAmount || !payload.billingMonths || !payload.paidUntil) throw new Error('Bitte geben Sie Rechnungsbetrag, Zeitraum und „Bezahlt bis“ an.')
if (!payload.consent) throw new Error('Bitte bestätigen Sie Ihre Angaben und die Bedingungen.')
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! Ihr 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 schreiben Sie eine Antwort oder laden Sie 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 Ihre Antwort! Wir prüfen Ihren Antrag weiter und melden uns per E-Mail.')
} catch (f) {
zeigeFehler(fehler, f.message)
knopf.disabled = false
}
}