227 lines
13 KiB
JavaScript
227 lines
13 KiB
JavaScript
/* 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 Block ist knapp gehalten (Leistung als eine Zeile mit Link auf die Leistungsbeschreibung), damit er mit dem Knopf in den nutzbaren Bereich
|
||
zwischen Kopf und Leiste passt (375 x 667); die ausführlichen Sätze stehen unter dem Knopf (baueEinzelheiten), ohne Klappe.
|
||
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, baueEinzelheiten, baueKundenartZeile, baueLinkzeile, hatWortlaut, kundenartVon, leistungLink } from './bestell-bausteine.js'
|
||
|
||
const SCHLUESSEL = 'kn-wechsel-funktion'
|
||
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
|
||
|
||
/** Die von P3 gemerkte Funktion { key, projectId } oder null (kaputter oder fremder Wert, kein sessionStorage). */
|
||
export function gemerkteFunktion() {
|
||
try {
|
||
const wert = JSON.parse(globalThis.sessionStorage?.getItem(SCHLUESSEL) ?? 'null')
|
||
return wert && typeof wert.key === 'string' && wert.key && typeof wert.projectId === 'string' && wert.projectId ? { key: wert.key, projectId: wert.projectId } : null
|
||
} catch {
|
||
return null
|
||
}
|
||
}
|
||
function vergissFunktion() {
|
||
try { globalThis.sessionStorage?.removeItem(SCHLUESSEL) } catch { /* ohne Speicher bleibt nichts liegen */ }
|
||
}
|
||
|
||
// Zurück aus dem Bfcache (z. B. von der Zahlungsseite): eine hängende Bestellung nicht gesperrt lassen
|
||
let aktiv = null
|
||
const mitPageshow = new WeakSet()
|
||
function registrierePageshow() {
|
||
const win = window
|
||
if (mitPageshow.has(win)) return
|
||
mitPageshow.add(win)
|
||
win.addEventListener('pageshow', () => { if (aktiv?.busy) { aktiv.busy = false; aktiv.zeichne() } })
|
||
}
|
||
|
||
export async function zeigeWechsel(wurzel, { route, me, nav, daten } = {}) {
|
||
const nr = (laeufe.get(wurzel) ?? 0) + 1
|
||
laeufe.set(wurzel, nr)
|
||
const aktuell = () => laeufe.get(wurzel) === nr
|
||
const viewAs = me?.viewAs === true
|
||
meldeTitel(wurzel, 'Wechsel ins Wartungs-Abo')
|
||
leeren(wurzel).append(h('p', { class: K.leer }, 'Wird geladen …'))
|
||
registrierePageshow()
|
||
|
||
const stand = daten ?? await ladeAbo()
|
||
if (!aktuell()) return
|
||
if (!stand) { leeren(wurzel).append(h('p', { class: K.fehler }, ABO_NICHT_GELADEN)); return }
|
||
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 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, early: false, fehler: '', fehlerKlasse: null, zeichne: null }
|
||
aktiv = lauf
|
||
|
||
const paketText = `${units} Paket${units === 1 ? '' : 'e'}` // Nominativ: „2 Pakete bleiben gebucht“
|
||
const paketMit = `${units} Paket${units === 1 ? '' : 'en'}` // Dativ nach „mit“: „mit 1 Paket“, „mit 2 Paketen“
|
||
const speicherGb = units * (Number(mail.gbPerUnit) || 0)
|
||
|
||
/**
|
||
* Zeilen des Blocks aus der Vorschau des Servers, knapp: Leistung, Preis (mit gebuchtem Zusatzspeicher), Fällig (mit der Gutschrift und dem Abbuchungstag),
|
||
* Laufzeit, Beginn. Die Einzelposten stehen ausführlich unter dem Knopf (einzelheitenVon); so passt der Block auch mit Zusatzspeicher in 375 x 667.
|
||
*/
|
||
const zeilenVon = (p) => {
|
||
const monthly = Number.isFinite(Number(p.monthlyCents)) && p.monthlyCents != null ? Number(p.monthlyCents) : wartung.amountCents
|
||
const tag = berlinTag(p.nextChargeAt)
|
||
const gutschrift = (Number(p.creditCents) || 0) > 0 ? ` (nach Gutschrift von ${fmtEuro(p.creditCents / 100)} für nicht genutzte Tage des Hosting-Abos)` : ''
|
||
return [
|
||
{ label: 'Leistung', text: 'Wartungs-Abo statt Hosting-Abo', link: { text: 'Leistungen ansehen', href: leistungLink(T.wechsel.linkzeile, 'wartung') } },
|
||
{ label: 'Preis', text: units > 0
|
||
? `${fmtEuro((monthly + units * unitCents) / 100)} pro Monat mit ${paketMit} Zusatzspeicher (+${speicherGb} GB) (Gesamtpreis, ${UST})`
|
||
: `${fmtEuro(monthly / 100)} pro Monat (Gesamtpreis, ${UST})` },
|
||
{ label: 'Fällig', text: `heute ${fmtEuro(p.amountDueCents / 100)}${gutschrift}; danach ab ${fmtDatum(p.nextChargeAt)}${tag ? ` jeweils am ${tag.t}.` : ''}` },
|
||
{ label: 'Laufzeit', text: `Mindestlaufzeit bis ${fmtDatum(p.minTermEnd)} (12 Monate), danach monatlich kündbar` },
|
||
{ label: 'Beginn', text: 'sofort mit der Zahlung; einen späteren Beginn gibt es nicht' },
|
||
]
|
||
}
|
||
|
||
/** Die ausführlichen Sätze unter dem Knopf (Wortlaut wie bisher im Block). */
|
||
const einzelheitenVon = (p) => {
|
||
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.' },
|
||
...(units > 0
|
||
? [{ label: 'Zusatzspeicher', text: `${paketText} (+${speicherGb} GB) bleiben gebucht: ${fmtEuro(units * unitCents / 100)} pro Monat zusätzlich (${UST})` }]
|
||
: []),
|
||
...((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 uebersicht = lauf.error
|
||
? h('p', { class: K.fehler, role: 'alert' }, lauf.error)
|
||
: lauf.loading || !p ? h('p', { class: 'kn-hinweis' }, 'Deine Übersicht wird berechnet …')
|
||
: baueBlock(zeilenVon(p), { id: 'switch-block' })
|
||
const sperre = lauf.busy || viewAs
|
||
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', role: 'alert', 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 || 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' }),
|
||
p ? baueEinzelheiten(einzelheitenVon(p), { id: 'switch-einzelheiten' }) : null))
|
||
}
|
||
lauf.zeichne = zeichne
|
||
|
||
const ladeVorschau = async () => {
|
||
const token = ++lauf.token
|
||
lauf.loading = true
|
||
lauf.error = ''
|
||
zeichne()
|
||
try {
|
||
const p = await api('/api/abo/switch/preview', { method: 'POST', body: {} })
|
||
if (token !== lauf.token || !aktuell()) return
|
||
lauf.preview = p
|
||
} catch (f) {
|
||
if (token !== lauf.token || !aktuell()) return
|
||
lauf.preview = null
|
||
lauf.error = f.message
|
||
}
|
||
lauf.loading = false
|
||
zeichne()
|
||
}
|
||
|
||
const zeigeFehler = (text, klasse) => { lauf.fehler = text; lauf.fehlerKlasse = klasse; zeichne() }
|
||
|
||
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 = ''
|
||
const p = lauf.preview
|
||
const funktion = gemerkteFunktion()
|
||
const gesendet = funktion !== null
|
||
lauf.busy = true
|
||
zeichne()
|
||
try {
|
||
// 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()
|
||
if (res.paymentUrl) {
|
||
const ziel = sichereAdresse(res.paymentUrl)
|
||
if (!ziel) {
|
||
lauf.busy = false
|
||
zeigeFehler('Die Zahlungsseite konnte nicht geöffnet werden. Der Wechsel wartet auf die Zahlung; Näheres steht unter Verträge.')
|
||
return
|
||
}
|
||
const knopf = wurzel.querySelector('#switch-buy')
|
||
if (knopf) knopf.textContent = 'Weiter zur Zahlung …'
|
||
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
|
||
? '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) {
|
||
let zurueck = wechselFunktionAus(res.subscription)
|
||
if (!zurueck) zurueck = wechselFunktionAus((await ladeAbo())?.subscription)
|
||
if (zurueck) { navigiere('#funktionen', { ersetzen: true }); return }
|
||
}
|
||
merkeHinweis(hinweis)
|
||
navigiere('#vertraege')
|
||
} catch (f) {
|
||
lauf.busy = false
|
||
zeigeFehler(f.message)
|
||
// Betrag oder Stichtag haben sich geändert: neue Vorschau laden, Fehlerhinweis bleibt stehen
|
||
if (f.status === 409) {
|
||
const msg = f.message
|
||
await ladeVorschau()
|
||
zeigeFehler(msg)
|
||
}
|
||
}
|
||
}
|
||
|
||
await ladeVorschau()
|
||
}
|