Files
Webklar-Kundenbereich/public/neu/ansicht-bestellen.js
T

176 lines
10 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): Bestellseite je Abo (#bestellen-hosting, #bestellen-wartung) im schlanken Muster der Spec 5.6, Fassung 6.
Reihenfolge ohne Klappe und ohne Scrollbereich: Block „Was du bestellst“, eine Linkzeile, bei Privatkunden ein Häkchen, der Knopf
„Zahlungspflichtig bestellen“ (§ 312j BGB: Kerninfo und Knopf auf demselben Bildschirm), darunter die Kundenart-Zeile.
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.
Die Sätze (Linkzeile, Häkchen, Kundenart-Zeile) kommen vom Server (GET /api/abo → bestelltexte). Der Server entscheidet die Kundenart selbst:
der Client sendet nie kundenart oder startChoice, bei Privatkunden nur earlyStart (das Häkchen) und den gezeigten Leistungsbeginn.
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, sichereAdresse, meldeTitel, beiRueckkehr, fmtEuro, fmtDatum, ABO_NICHT_GELADEN } from './ansicht-hilfen.js'
import { baueBlock, baueEinzelheiten, baueKundenartZeile, baueLinkzeile, hatWortlaut, kundenartVon, leistungLink } from './bestell-bausteine.js'
const laeufe = new WeakMap() // Wurzel → Nummer des jüngsten Aufrufs
const TEXT_SPERRE = 'Im Ansichtsmodus nicht möglich.'
const TEXT_NICHT_VERFUEGBAR = 'Die Online-Bestellung wird gerade eingerichtet – schreib uns gern im Chat.'
const TEXT_ZAHLUNGSSEITE = 'Die Zahlungsseite konnte nicht geöffnet werden. Bitte versuche es später noch einmal.'
const KNOPF = 'Zahlungspflichtig bestellen'
const UST = 'gemäß § 19 UStG ohne Umsatzsteuer'
/** 'hosting' | 'wartung' aus der Route, sonst aus dem Hash (#bestellen-hosting), sonst ''. */
export function produktAusRoute(route, hash = (typeof window !== 'undefined' ? window.location?.hash : '') || '') {
if (route?.produkt === 'hosting' || route?.produkt === 'wartung') return route.produkt
const treffer = /bestellen-(hosting|wartung)/.exec(String(hash || ''))
return treffer ? treffer[1] : ''
}
const produktVon = (daten, key) => (daten?.products || []).find((p) => p.key === key) || null
const labelVon = (daten, key) => (produktVon(daten, key)?.name || (key === 'hosting' ? 'Hosting-Abo' : 'Wartungs-Abo')).replace(/^WEBklar\s+/, '')
/** Preiszeile und Betrag der ersten Abbuchung aus Katalog und Angebot (während des Rabatts der Angebotspreis, sonst der Katalogpreis). */
function preise(product, offer) {
const offerMonthly = Number(offer?.monthlyPriceCents)
const offerRegular = Number(offer?.regularPriceCents) || product.amountCents
const offerMonths = Number(offer?.months)
const mitZahlen = Boolean(offer) && Number.isFinite(offerMonthly) && offer.monthlyPriceCents != null && offerMonths > 0
const preis = fmtEuro(product.amountCents / 100)
const preisZeile = !offer
? `${preis} pro Monat (Gesamtpreis, ${UST})`
: mitZahlen
? `${fmtEuro(offerMonthly / 100)} pro Monat für die ersten ${offerMonths} Monate, danach ${fmtEuro(offerRegular / 100)} pro Monat (individuelles Angebot: ${offer.text}; Gesamtpreis, ${UST})`
: `${preis} pro Monat (individuelles Angebot: ${offer.text}; Gesamtpreis, ${UST})`
return { preisZeile, erste: fmtEuro((mitZahlen ? offerMonthly : product.amountCents) / 100) }
}
/** Texte der Zeilen „Fällig“ und „Beginn“ je nach Lage: sofort (Betrieb), sofort auf Wunsch (Privat mit Häkchen), nach der Frist (Privat ohne). */
function faelligBeginn({ lage, erste, frist }) {
if (lage === 'frist') {
return {
faellig: `erst am ${frist || 'Ende der Widerrufsfrist'} (${erste})`,
beginn: `nach Ablauf der 14-tägigen Widerrufsfrist${frist ? ` (am ${frist})` : ''}`,
}
}
return {
faellig: `heute ${erste}`,
beginn: lage === 'wunsch' ? 'sofort, auf deinen ausdrücklichen Wunsch' : 'sofort',
}
}
function angebotAbschnitt(offer) {
if (!offer) return null
return h('section', { class: 'kn-angebot-kasten', id: 'abo-angebot' },
h('h2', null, 'Dein persönliches Angebot'),
h('p', null, `${offer.text}. Du sparst ${fmtEuro(offer.savingsCents / 100)}.`),
offer.note ? h('p', { class: 'kn-hinweis' }, offer.note) : null,
h('p', { class: 'kn-hinweis' }, `Gültig bis ${fmtDatum(offer.validUntil)} · wird beim Abschluss automatisch berücksichtigt`))
}
function leistungenAbschnitt(product) {
return h('section', { class: 'kn-panel', id: 'abo-leistungen' },
h('ul', { class: 'kn-leistungen' }, (product.features || []).map(([titel, detail]) => h('li', null, titel, h('small', null, detail)))),
h('p', { class: 'kn-hinweis' }, 'Schon bei einem anderen Anbieter für Domain, Hosting oder E-Mail bezahlt? Nach dem Abschluss kannst du eine Übernahmegutschrift beantragen – wir rechnen die doppelt bezahlte Zeit anteilig an.'))
}
function bestellung(daten, productKey, { viewAs, kundenart }) {
const product = produktVon(daten, productKey)
const T = daten.bestelltexte
const offer = daten.offers?.[productKey] || null
const privat = kundenart !== 'unternehmen'
const frist = daten.lateStartAt ? fmtDatum(daten.lateStartAt) : ''
const { preisZeile, erste } = preise(product, offer)
// Betrieb: sofort, ohne Auswahl. Privat und leer: ohne Häkchen nach der Frist, mit Häkchen sofort.
const start = faelligBeginn({ lage: privat ? 'frist' : 'sofort', erste, frist })
const block = baueBlock([
{ label: 'Leistung', text: labelVon(daten, productKey), link: { text: 'Leistungen ansehen', href: leistungLink(T.abo.linkzeile, productKey) } },
{ label: 'Preis', text: preisZeile },
{ id: 'abo-due-summary', label: 'Fällig', text: start.faellig },
{ id: 'abo-start-summary', label: 'Beginn', text: start.beginn },
{ label: 'Laufzeit', text: `Mindestlaufzeit ${product.minTermMonths} Monate, kündbar bis 1 Monat vor Ende; danach monatlich` },
{ label: 'Zahlung', text: 'Karte oder SEPA über Stripe' },
], { id: 'abo-block' })
// Die ausführlichen Sätze stehen unter dem Knopf
const einzelheiten = baueEinzelheiten([
{ label: 'Laufzeit', text: `Mindestlaufzeit ${product.minTermMonths} Monate ab Leistungsbeginn, kündbar zum Ende der Mindestlaufzeit bis einen Monat vorher; danach jederzeit zum Ende des Abrechnungsmonats kündbar` },
{ label: 'Zahlung', text: 'Kreditkarte oder SEPA-Lastschrift über Stripe; die Zahlungsart hinterlegst du im nächsten Schritt, bis zum Abschluss dort kannst du abbrechen' },
{ label: 'Kosten', text: 'keine weiteren Kosten' },
], { id: 'abo-einzelheiten' })
const haken = privat ? h('input', { type: 'checkbox', id: 'abo-beginn', disabled: viewAs }) : null
const fehler = h('p', { class: K.fehler, id: 'abo-error', role: 'alert', hidden: true })
const kaufen = h('button', { type: 'button', class: K.primaer, id: 'abo-buy', disabled: viewAs }, T.knopf || KNOPF)
const zeigeFehler = (text) => { fehler.textContent = text; fehler.hidden = false }
const abschnitt = h('section', { class: 'kn-panel kn-bestellen', id: 'abo-order' },
h('h2', null, 'Was du bestellst'),
daten.testAccount ? h('div', { class: 'kn-meldung kn-meldung-warn' }, `Testkonto: Bei Stripe wird ein Test-Rabatt verrechnet, abgebucht werden nur 0,50 € pro Monat.${offer ? ' Das Angebot wird nach dem Abschluss zusätzlich ans Abo gehängt.' : ''}`) : null,
block,
baueLinkzeile(T.abo.linkzeile, { id: 'abo-linkzeile' }),
haken ? h('label', { class: K.check }, haken, h('span', { id: 'abo-beginn-text' }, T.abo.haken)) : null,
fehler,
h('div', { class: 'kn-aktionen' }, kaufen, viewAs ? h('span', { class: K.meldung }, TEXT_SPERRE) : null),
baueKundenartZeile(kundenart, T.kundenart, { id: 'abo-kundenart' }),
einzelheiten)
// Zurück von der Zahlungsseite (Bfcache): der Knopf ist wieder bedienbar (E-Fix M6, wie ansicht-wechsel.js)
beiRueckkehr(abschnitt, () => { kaufen.disabled = viewAs; kaufen.textContent = T.knopf || KNOPF })
// Das Häkchen schaltet die Zeilen „Fällig“ und „Beginn“ um
haken?.addEventListener('change', () => {
const neu = faelligBeginn({ lage: haken.checked ? 'wunsch' : 'frist', erste, frist })
abschnitt.querySelector('#abo-due-summary').textContent = neu.faellig
abschnitt.querySelector('#abo-start-summary').textContent = neu.beginn
})
kaufen.addEventListener('click', async () => {
if (viewAs) return
fehler.hidden = true
kaufen.disabled = true
kaufen.textContent = 'Weiter zur Zahlung…'
try {
// Der Klick auf den Knopf ist die Zustimmung (acceptTerms). Nie kundenart oder startChoice: den Beginn bestimmt der Server aus der Kundenart.
const { url } = await api('/api/abo/checkout', {
method: 'POST',
body: {
productKey,
acceptTerms: true,
termsVersion: daten.termsVersion,
offerId: offer?.id || '',
...(privat ? { earlyStart: haken.checked, lateStartAt: daten.lateStartAt || '' } : {}),
},
})
const ziel = sichereAdresse(url)
if (!ziel) throw new Error(TEXT_ZAHLUNGSSEITE)
window.location.href = ziel
} catch (f) {
zeigeFehler(f.message)
kaufen.disabled = false
kaufen.textContent = T.knopf || KNOPF
}
})
return [angebotAbschnitt(offer), leistungenAbschnitt(product), abschnitt]
}
/** Bestellseite. Das Produkt kommt aus route.produkt, hilfsweise aus dem Hash; daten (GET /api/abo) ist optional. */
export async function zeigeBestellen(wurzel, { route, me, nav, daten } = {}) {
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 …'))
const stand = daten ?? await ladeAbo()
if (!aktuell()) return
if (!stand) { leeren(wurzel).append(h('p', { class: K.fehler }, ABO_NICHT_GELADEN)); return }
const key = produktAusRoute(route)
const product = key ? produktVon(stand, key) : null
if (key) meldeTitel(wurzel, labelVon(stand, key))
// Ohne Wortlaut vom Server gibt es keinen Bestellknopf
if (!product || !stand.available || !hatWortlaut(stand)) {
leeren(wurzel).append(h('div', { class: 'kn-panel' }, h('p', { class: 'kn-hinweis' }, TEXT_NICHT_VERFUEGBAR)))
return
}
leeren(wurzel).append(...bestellung(stand, key, { viewAs: me?.viewAs === true, kundenart: kundenartVon(me, stand) }).filter(Boolean))
}