Kundenansicht neu P7: Wechselseite schlank, Sofortbeginn-Häkchen nur privat

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-16 20:13:49 +00:00
1 parent c6ecb5242c
commit e7ac625f26
4 files changed
+270 -89

No files matched your search

+66 -47
View File
@@ -1,18 +1,22 @@
/* Kundenansicht neu (P2): Wechsel vom Hosting-Abo ins Wartungs-Abo (#wechsel), Text und Logik 1:1 aus app.js renderSwitchView
(Fassung 5, Sie-Form, C14). Vorschau vom Server, Bestellübersicht unmittelbar über dem Knopf (§ 312j BGB), Zustimmung, Bestellung.
Der Wechsel wirkt erst mit der Zahlung der Wechselrechnung (Bedingungen § 9 Abs. 3). Das Wortlaut-Orakel prüft den sichtbaren Text.
Neu: die gemerkte Funktion (E14, C5): kam der Kunde aus dem Einschalten einer Funktion, geht es danach zurück zu den Funktionen. */
/* Kundenansicht neu (P7): Wechsel vom Hosting-Abo ins Wartungs-Abo (#wechsel) im schlanken Muster der Spec 5.6, Fassung 6.
Reihenfolge ohne Klappe: Block „Was du bestellst“ (Vorschau vom Server), eine Linkzeile, bei Privatkunden das Sofortbeginn-Häkchen mit
Sperrzeile, der Knopf „Zahlungspflichtig bestellen“ (§ 312j BGB), darunter die Kundenart-Zeile. Der Wechsel wirkt erst mit der Zahlung
der Wechselrechnung (Bedingungen § 9 Abs. 3). Die Sätze kommen vom Server (GET /api/abo → bestelltexte); der Server entscheidet die
Kundenart selbst, der Client sendet nie kundenart. Ein Betrieb braucht kein Häkchen.
Die gemerkte Funktion (E14, C5): kam der Kunde aus dem Einschalten einer Funktion, geht es danach zurück zu den Funktionen.
Der Titel kommt von der Hülle (meldeTitel); die Seite hat keine eigene h1. */
import { api } from './api.js'
import { h, leeren } from './dom.js'
import { K, ladeAbo, wechselFunktionAus, sichereAdresse, merkeHinweis, navigiere, meldeTitel, meldeZaehlerNeu, zuAnsicht, fmtEuro, fmtDatum, berlinTag, ABO_NICHT_GELADEN } from './ansicht-hilfen.js'
import { baueBlock, baueKundenartZeile, baueLinkzeile, hatWortlaut, kundenartVon } from './bestell-bausteine.js'
const SCHLUESSEL = 'kn-wechsel-funktion'
const BEDINGUNGEN = '/abo-bedingungen.html'
const TEXT_SPERRE = 'Im Ansichtsmodus nicht möglich.'
const TEXT_NICHT_VERFUEGBAR = 'Die Online-Bestellung wird gerade eingerichtet – schreib uns gern im Chat.'
const KNOPF = 'Zahlungspflichtig bestellen'
const UST = 'gemäß § 19 UStG ohne Umsatzsteuer'
const laeufe = new WeakMap() // Wurzel → Nummer des jüngsten Aufrufs
const link = (href, text) => h('a', { href, target: '_blank', rel: 'noopener' }, text)
const reihe = (titel, ...inhalt) => h('tr', null, h('td', null, titel), h('td', null, ...inhalt))
/** Die von P3 gemerkte Funktion { key, projectId } oder null (kaputter oder fremder Wert, kein sessionStorage). */
export function gemerkteFunktion() {
@@ -52,56 +56,70 @@ export async function zeigeWechsel(wurzel, { route, me, nav, daten } = {}) {
const sub = stand.subscription
const wartung = (stand.products || []).find((p) => p.key === 'wartung') || null
if (!sub?.active || sub.productKey !== 'hosting' || !sub.canSwitch || !wartung) {
leeren(wurzel).append(h('div', { class: 'kn-panel' }, h('p', { class: 'kn-hinweis' }, 'Ein Wechsel ist derzeit nicht möglich. Details finden Sie unter ', h('a', zuAnsicht('vertraege'), 'Verträge'), '.')))
leeren(wurzel).append(h('div', { class: 'kn-panel' }, h('p', { class: 'kn-hinweis' }, 'Ein Wechsel ist derzeit nicht möglich. Details findest du unter ', h('a', zuAnsicht('vertraege'), 'Verträge'), '.')))
navigiere('#vertraege', { ersetzen: true })
return
}
// Ohne Wortlaut vom Server gibt es keinen Bestellknopf und keine Vorschau
if (!hatWortlaut(stand, 'wechsel')) {
leeren(wurzel).append(h('div', { class: 'kn-panel' }, h('p', { class: 'kn-hinweis' }, TEXT_NICHT_VERFUEGBAR)))
return
}
const T = stand.bestelltexte
const kundenart = kundenartVon(me, stand)
const privat = kundenart !== 'unternehmen'
const units = Number(sub.storage?.units) || 0
const mail = stand.mailStorage || {}
const unitCents = Number(mail.unitPriceCents) || 0
const lauf = { busy: false, preview: null, error: '', loading: true, token: 0, terms: false, early: false, fehler: '', zeichne: null }
const lauf = { busy: false, preview: null, error: '', loading: true, token: 0, early: false, fehler: '', fehlerKlasse: null, zeichne: null }
aktiv = lauf
/** Zeilen des Blocks aus der Vorschau des Servers (Leistung, Wartungs-Abo, Zusatzspeicher, Gutschrift, fällig, Abbuchungstag, Laufzeit, Beginn). */
const zeilenVon = (p) => {
const monthly = Number.isFinite(Number(p.monthlyCents)) && p.monthlyCents != null ? Number(p.monthlyCents) : wartung.amountCents
const tag = berlinTag(p.nextChargeAt)
return [
{ label: 'Leistung', text: 'Zusätzlich zum Hosting: Pflege deiner Website, beliebig viele Postfächer und 1 Stunde Arbeitszeit pro Monat. Das Hosting-Abo endet mit dem Wechsel im gegenseitigen Einvernehmen; Domain, Postfächer und gebuchter Speicher bleiben.' },
{ label: 'Wartungs-Abo', text: `${fmtEuro(monthly / 100)} pro Monat (Gesamtpreis, ${UST}); enthalten sind Pflege deiner Website, beliebig viele Postfächer und 1 Stunde Arbeitszeit pro Monat` },
...(units > 0
? [{ label: 'Zusatzspeicher', text: `${units} Paket${units === 1 ? '' : 'e'} (+${units * (Number(mail.gbPerUnit) || 0)} GB) bleiben gebucht: ${fmtEuro(units * unitCents / 100)} pro Monat zusätzlich (${UST}), insgesamt ${fmtEuro((monthly + units * unitCents) / 100)} pro Monat` }]
: []),
...((Number(p.creditCents) || 0) > 0
? [{ label: 'Gutschrift für nicht genutzte Tage', text: `−${fmtEuro(p.creditCents / 100)} aus dem bereits bezahlten Hosting-Abo (${UST})` }]
: []),
{ label: 'Heute fällig', text: `${fmtEuro(p.amountDueCents / 100)} (${UST})` },
{ label: 'Neuer Abbuchungstag', text: `${tag ? `Ab dem Wechsel jeweils am ${tag.t}. eines Monats; ` : ''}nächste Abbuchung am ${fmtDatum(p.nextChargeAt)}` },
{ label: 'Laufzeit', text: `Mindestlaufzeit bis ${fmtDatum(p.minTermEnd)} (12 Monate ab dem Wechsel), danach monatlich kündbar` },
{ label: 'Beginn', text: 'Der Wechsel wird erst mit der Zahlung wirksam. Bei Kreditkarte, sobald die Zahlung erfolgreich ist (ggf. nach Bestätigung bei deiner Bank); bei SEPA-Lastschrift sofort mit der Bestellung, wird die Lastschrift nicht eingelöst, gilt der Wechsel als nicht erfolgt. Einen späteren Beginn gibt es nicht.' },
]
}
const zeichne = () => {
const p = lauf.preview
const rows = []
if (p) {
const monthly = Number.isFinite(Number(p.monthlyCents)) && p.monthlyCents != null ? Number(p.monthlyCents) : wartung.amountCents
rows.push(reihe('Wartungs-Abo', h('strong', null, `${fmtEuro(monthly / 100)} pro Monat`), ' (Gesamtpreis, gemäß § 19 UStG ohne Umsatzsteuer); enthalten sind Pflege Ihrer Website, beliebig viele Postfächer und 1 Stunde Arbeitszeit pro Monat'))
if (units > 0) {
rows.push(reihe('Zusatzspeicher', `${units} Paket${units === 1 ? '' : 'e'} (+${units * (Number(mail.gbPerUnit) || 0)} GB) bleiben gebucht: ${fmtEuro(units * unitCents / 100)} pro Monat zusätzlich (gemäß § 19 UStG ohne Umsatzsteuer), insgesamt ${fmtEuro((monthly + units * unitCents) / 100)} pro Monat`))
}
if ((Number(p.creditCents) || 0) > 0) {
rows.push(reihe('Gutschrift für nicht genutzte Tage', `−${fmtEuro(p.creditCents / 100)} aus dem bereits bezahlten Hosting-Abo (gemäß § 19 UStG ohne Umsatzsteuer)`))
}
rows.push(reihe('Heute fällig', h('strong', null, fmtEuro(p.amountDueCents / 100)), ' (gemäß § 19 UStG ohne Umsatzsteuer)'))
const tag = berlinTag(p.nextChargeAt)
rows.push(reihe('Neuer Abbuchungstag', `${tag ? `Ab dem Wechsel jeweils am ${tag.t}. eines Monats; ` : ''}nächste Abbuchung am ${fmtDatum(p.nextChargeAt)}`))
rows.push(reihe('Laufzeit', h('strong', null, `Mindestlaufzeit bis ${fmtDatum(p.minTermEnd)}`), ' (12 Monate ab dem Wechsel), danach monatlich kündbar'))
rows.push(reihe('Beginn', 'Der Wechsel wird erst mit der Zahlung wirksam. Bei Kreditkarte, sobald die Zahlung erfolgreich ist (ggf. nach Bestätigung bei Ihrer Bank); bei SEPA-Lastschrift sofort mit der Bestellung, wird die Lastschrift nicht eingelöst, gilt der Wechsel als nicht erfolgt. Einen späteren Beginn gibt es nicht.'))
}
const uebersicht = lauf.error
? h('p', { class: K.fehler }, lauf.error)
: lauf.loading || !p ? h('p', { class: 'kn-hinweis' }, 'Ihre Übersicht wird berechnet …')
: h('table', { class: 'kn-zusammenfassung' }, h('tbody', null, rows))
: lauf.loading || !p ? h('p', { class: 'kn-hinweis' }, 'Deine Übersicht wird berechnet …')
: baueBlock(zeilenVon(p), { id: 'switch-block' })
const sperre = lauf.busy || viewAs
const terms = h('input', { type: 'checkbox', id: 'switch-terms', checked: lauf.terms, disabled: sperre, on: { change: (e) => { lauf.terms = e.currentTarget.checked } } })
const early = h('input', { type: 'checkbox', id: 'switch-early', checked: lauf.early, disabled: sperre, on: { change: (e) => { lauf.early = e.currentTarget.checked } } })
leeren(wurzel).append(h('div', { class: 'kn-panel', id: 'switch-order' },
h('h3', null, 'Ihre Bestellung'),
h('p', { class: 'kn-hinweis' }, 'Zusätzlich zum Hosting: Pflege Ihrer Website, beliebig viele Postfächer und 1 Stunde Arbeitszeit pro Monat. Das Hosting-Abo endet mit dem Wechsel im gegenseitigen Einvernehmen; Domain, Postfächer und gebuchter Speicher bleiben.'),
h('label', { class: 'kn-check' }, terms,
h('span', null, 'Ich habe die ', link(BEDINGUNGEN, 'Vertragsbedingungen mit Leistungsbeschreibung und Auftragsverarbeitungsvertrag'), ' gelesen und stimme ihnen zu. Die ', link(`${BEDINGUNGEN}#widerruf`, 'Widerrufsbelehrung'), ' habe ich zur Kenntnis genommen.')),
h('label', { class: 'kn-check' }, early,
h('span', null, h('strong', null, 'Sofortbeginn.'), ' Ich verlange ausdrücklich, dass WEBklar vor Ablauf der Widerrufsfrist mit dem Wartungs-Abo beginnt. Mir ist bekannt, dass ich bei einem Widerruf einen angemessenen Betrag für die bis dahin erbrachten Leistungen zahle (§ 357a Abs. 2 BGB; siehe Hinweise zum Widerruf). Firmenkunden haben kein Widerrufsrecht.')),
h('div', { class: 'kn-uebersicht' }, uebersicht,
h('p', { class: 'kn-hinweis' }, 'Es gilt das gesetzliche Mängelhaftungsrecht. Einzelheiten: ', link(BEDINGUNGEN, 'Vertragsbedingungen'), ' · ', link('/datenschutz.html', 'Datenschutz'))),
const haken = privat
? h('input', {
type: 'checkbox', id: 'switch-beginn', checked: lauf.early, disabled: sperre,
on: { change: (e) => { lauf.early = e.currentTarget.checked; zeichne(); wurzel.querySelector('#switch-beginn')?.focus() } },
})
: null
const gesperrt = privat && !lauf.early
leeren(wurzel).append(h('section', { class: 'kn-panel kn-bestellen', id: 'switch-order' },
h('h2', null, 'Was du bestellst'),
uebersicht,
baueLinkzeile(T.wechsel.linkzeile, { id: 'switch-linkzeile' }),
haken ? h('label', { class: K.check }, haken, h('span', { id: 'switch-beginn-text' }, T.wechsel.haken)) : null,
gesperrt ? h('p', { class: 'kn-sperrzeile', id: 'switch-sperre' }, T.wechsel.gesperrt) : null,
h('p', { class: lauf.fehlerKlasse ?? K.fehler, id: 'switch-error', hidden: !lauf.fehler }, lauf.fehler),
h('div', { class: 'kn-aktionen' },
h('button', { type: 'button', class: K.primaer, id: 'switch-buy', disabled: lauf.busy || viewAs || lauf.loading || !p, on: { click: absenden } }, lauf.busy ? 'Wird bestellt …' : 'Zahlungspflichtig bestellen'),
h('span', { class: 'kn-hinweis' }, 'Bezahlt wird mit der bei Stripe hinterlegten Zahlungsart. Bis zum Klick können Sie abbrechen.'),
viewAs ? h('span', { class: K.meldung }, TEXT_SPERRE) : null)))
h('button', { type: 'button', class: K.primaer, id: 'switch-buy', disabled: lauf.busy || viewAs || lauf.loading || !p || gesperrt, on: { click: absenden } }, lauf.busy ? 'Wird bestellt …' : (T.knopf || KNOPF)),
viewAs ? h('span', { class: K.meldung }, TEXT_SPERRE) : null),
baueKundenartZeile(kundenart, T.kundenart, { id: 'switch-kundenart' })))
}
lauf.zeichne = zeichne
@@ -127,16 +145,17 @@ export async function zeigeWechsel(wurzel, { route, me, nav, daten } = {}) {
async function absenden() {
if (viewAs || lauf.busy || !lauf.preview) return
if (privat && !lauf.early) return // ohne Häkchen kein Wechsel; die Sperrzeile steht da
lauf.fehler = ''
if (!lauf.terms) { zeigeFehler('Bitte stimmen Sie den Vertragsbedingungen zu.'); return }
if (!lauf.early) { zeigeFehler('Bitte bestätigen Sie den Sofortbeginn, sonst können wir den Wechsel nicht ausführen.'); return }
const p = lauf.preview
const funktion = gemerkteFunktion()
const gesendet = funktion !== null
lauf.busy = true
zeichne()
try {
const body = { acceptTerms: true, earlyStart: true, termsVersion: stand.termsVersion, prorationDate: p.prorationDate, amountDueCents: p.amountDueCents }
// Der Klick auf den Knopf ist die Zustimmung (acceptTerms). Nie kundenart: ob ein Häkchen nötig ist, entscheidet der Server.
const body = { acceptTerms: true, termsVersion: stand.termsVersion, prorationDate: p.prorationDate, amountDueCents: p.amountDueCents }
if (privat) body.earlyStart = true
if (gesendet) body.funktion = funktion
const res = await api('/api/abo/switch', { method: 'POST', body })
if (gesendet) vergissFunktion()
@@ -149,15 +168,15 @@ export async function zeigeWechsel(wurzel, { route, me, nav, daten } = {}) {
}
const knopf = wurzel.querySelector('#switch-buy')
if (knopf) knopf.textContent = 'Weiter zur Zahlung …'
zeigeFehler('Ihr Wechsel wartet auf die Zahlung. Wir leiten Sie zur Zahlung weiter.', 'kn-meldung')
zeigeFehler('Dein Wechsel wartet auf die Zahlung. Wir leiten dich zur Zahlung weiter.', 'kn-meldung')
window.location.href = ziel
return
}
lauf.busy = false
const sepa = Boolean(res.subscription?.switchPending)
const hinweis = sepa
? 'Ihr Wechsel ins Wartungs-Abo gilt ab sofort. Die Lastschrift wird noch eingezogen; wir bestätigen Ihnen den Wechsel per E-Mail.'
: 'Ihr Wechsel ins Wartungs-Abo ist abgeschlossen. Wir bestätigen ihn per E-Mail.'
? 'Dein Wechsel ins Wartungs-Abo gilt ab sofort. Die Lastschrift wird noch eingezogen; wir bestätigen dir den Wechsel per E-Mail.'
: 'Dein Wechsel ins Wartungs-Abo ist abgeschlossen. Wir bestätigen ihn per E-Mail.'
meldeZaehlerNeu('vertraege')
// Mit gemerkter Funktion zurück zu den Funktionen (dort öffnet P3 den Einschalt-Ablauf), sonst wie bisher zu den Verträgen
if (gesendet) {
+13 -4
View File
@@ -72,11 +72,20 @@ export function baueBlock(zeilen, { id } = {}) {
return h('dl', { id, class: 'kn-block' }, (zeilen || []).map((z) => h('div', { class: 'kn-block-zeile' }, h('dt', null, z.label), h('dd', { id: z.id }, z.text))))
}
/** true, wenn die Antwort den Wortlaut trägt: status, knopf und die Linkzeile des Abos. Ohne ihn zeigt keine Seite einen Bestellknopf. */
export function hatWortlaut(daten) {
const nichtLeer = (x) => typeof x === 'string' && x !== ''
/**
* true, wenn die Antwort den Wortlaut trägt: status, knopf und die Sätze der Seite (art 'abo' | 'wechsel' | 'speicher' | 'gutschrift').
* Ohne ihn zeigt keine Seite einen Bestellknopf.
*/
export function hatWortlaut(daten, art = 'abo') {
const t = daten?.bestelltexte
return Boolean(t && typeof t === 'object' && typeof t.status === 'string' && t.status && typeof t.knopf === 'string' && t.knopf
&& typeof t.abo?.linkzeile?.text === 'string' && t.abo.linkzeile.text)
if (!t || typeof t !== 'object' || !nichtLeer(t.status) || !nichtLeer(t.knopf)) return false
if (art === 'gutschrift') return nichtLeer(t.gutschrift?.satz?.text) && nichtLeer(t.gutschrift?.knopf)
if (!nichtLeer(t[art]?.linkzeile?.text)) return false
if (art === 'wechsel') return nichtLeer(t.wechsel.haken) && nichtLeer(t.wechsel.gesperrt)
if (art === 'abo') return nichtLeer(t.abo.haken)
return true
}
export { K }