Kundenansicht neu P7: Gutschrift-Antrag schlank, Bedingungen als eigene Ansicht statt Klappe

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-16 20:20:57 +00:00
1 parent 9b00771d11
commit d17520c4f5
5 files changed
+235 -71

No files matched your search

+47 -33
View File
@@ -1,13 +1,19 @@
/* 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. */
/* 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, meldeZaehlerNeu, ABO_NICHT_GELADEN } from './ansicht-hilfen.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 = {
@@ -43,7 +49,7 @@ export const IO = { lies: leseAlsBase64 }
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 (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.`)
@@ -54,11 +60,10 @@ export async function sammleDateien(dateien, grenzen, { erforderlich }, lies = I
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) {
function antragsFormular(grenzen, email, T) {
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?'),
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' })),
@@ -69,15 +74,12 @@ function antragsFormular(grenzen, email) {
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.'),
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 })),
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'), ').')),
baueLinkzeile(T.gutschrift.satz, { id: 'tk-satz', neuerTab: true }),
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: 'submit', class: K.primaer, id: 'tk-submit' }, T.gutschrift.knopf || 'Antrag absenden'),
h('button', { type: 'button', class: K.knopf, id: 'tk-cancel' }, 'Abbrechen')))
}
@@ -90,7 +92,7 @@ function antrag(r, viewAs) {
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.'),
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)]
@@ -98,12 +100,12 @@ function antrag(r, viewAs) {
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('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, 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.')]
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)),
@@ -118,6 +120,14 @@ export async function zeigeGutschrift(wurzel, { route, me, nav } = {}) {
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('')
@@ -142,15 +152,18 @@ export async function zeigeGutschrift(wurzel, { route, me, nav } = {}) {
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')))))
h('p', { class: 'kn-hinweis' }, 'Die Übernahmegutschrift kannst du beantragen, sobald dein 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
// 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' }, '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.'),
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 ? h('div', { class: 'kn-aktionen' }, start, viewAs ? h('span', { class: K.meldung }, TEXT_ANSICHTSMODUS) : null) : 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)))
@@ -161,7 +174,7 @@ export async function zeigeGutschrift(wurzel, { route, me, nav } = {}) {
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) })
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) }))
}
}
@@ -172,7 +185,7 @@ function zeigeFehler(el, text) {
el.hidden = false
}
async function sendeAntrag(form, grenzen, neuLaden) {
async function sendeAntrag(form, grenzen, neuLaden, termsVersion) {
const fehler = form.querySelector('#tk-error')
const knopf = form.querySelector('#tk-submit')
fehler.hidden = true
@@ -186,18 +199,19 @@ async function sendeAntrag(form, grenzen, neuLaden) {
paidUntil: wert('#tk-paid-until'),
message: wert('#tk-message'),
email: wert('#tk-email'),
consent: form.querySelector('#tk-consent').checked,
// 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ä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.')
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! Ihr Antrag ist eingegangen. Die Eingangsbestätigung haben wir an ${payload.email} geschickt; unsere Antwort kommt ebenfalls per E-Mail.`)
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
@@ -214,9 +228,9 @@ async function sendeAntwort(form, grenzen, neuLaden, viewAs) {
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.')
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 Ihre Antwort! Wir prüfen Ihren Antrag weiter und melden uns per E-Mail.')
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
+6 -3
View File
@@ -52,10 +52,13 @@ export function linkTeile(text, urls) {
const ausserhalb = (href) => !href.startsWith('#')
/** <p id> mit den Links der Vorlage; '/…' und http(s) öffnen in neuem Tab (target _blank, rel noopener), '#…' bleibt im Fenster. klasse: Vorgabe 'kn-linkzeile'. */
export function baueLinkzeile({ text, urls } = {}, { id, klasse = 'kn-linkzeile' } = {}) {
/**
* <p id> mit den Links der Vorlage; '/…' und http(s) öffnen in neuem Tab (target _blank, rel noopener), '#…' bleibt im Fenster.
* klasse: Vorgabe 'kn-linkzeile'. neuerTab: auch '#…' öffnet in neuem Tab (Bedingungen der Gutschrift neben dem Formular).
*/
export function baueLinkzeile({ text, urls } = {}, { id, klasse = 'kn-linkzeile', neuerTab = false } = {}) {
const kinder = linkTeile(text, urls).map((t) =>
t.href ? h('a', { href: t.href, ...(ausserhalb(t.href) ? { target: '_blank', rel: 'noopener' } : {}) }, t.text) : t.text)
t.href ? h('a', { href: t.href, ...(neuerTab || ausserhalb(t.href) ? { target: '_blank', rel: 'noopener' } : {}) }, t.text) : t.text)
return h('p', { id, class: klasse }, kinder)
}