Kundenansicht neu E-Fix F1: Kerninfo und Knopf der Bestellseiten passen in den nutzbaren Bereich (390x844, 375x667)

Die Messung im Werkzeug nahm innerHeight; nutzbar ist am Handy nur der Bereich zwischen Kopf und fester Leiste (735 px bei 844, 558 px bei 667).
Gemessen vor der Korrektur (Blockanfang bis Knopfunterkante, 375x667): Abo bestellen privat 799 px, Wechsel privat 888 px, Zusatzspeicher 567 px.

- Block knapp: Leistung als eine Zeile mit Link "Leistungen ansehen" auf die Leistungsbeschreibung, Preis, Fällig, Beginn, Laufzeit und Zahlung in je ein bis zwei Zeilen.
- Die ausführlichen Sätze stehen unter dem Knopf und der Kundenart-Zeile (baueEinzelheiten), ohne Klappe.
- Handy: Beschriftung läuft in der Zeile mit, kleinere Überschrift, knappere Abstände.
- Die Wortlaute des Blocks sind gekürzt und gehören in die Rechtsrunde.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-16 21:25:48 +00:00
1 parent 779320c080
commit d4050f870a
8 files changed
+148 -41

No files matched your search

+20 -11
View File
@@ -1,6 +1,8 @@
/* 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. */
@@ -8,7 +10,7 @@
import { api } from './api.js'
import { h, leeren } from './dom.js'
import { K, ladeAbo, sichereAdresse, meldeTitel, fmtEuro, fmtDatum, ABO_NICHT_GELADEN } from './ansicht-hilfen.js'
import { baueBlock, baueKundenartZeile, baueLinkzeile, hatWortlaut, kundenartVon } from './bestell-bausteine.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.'
@@ -35,10 +37,10 @@ function preise(product, offer) {
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}), keine weiteren Kosten`
? `${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}), keine weiteren Kosten`
: `${preis} pro Monat (individuelles Angebot: ${offer.text}; Gesamtpreis, ${UST}), keine weiteren Kosten`
? `${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) }
}
@@ -46,13 +48,13 @@ function preise(product, offer) {
function faelligBeginn({ lage, erste, frist }) {
if (lage === 'frist') {
return {
faellig: `Erste Abbuchung am ${frist || 'Ende der 14-tägigen Widerrufsfrist'}: ${erste} (${UST}); heute wird nichts abgebucht`,
beginn: `nach Ablauf der 14-tägigen Widerrufsfrist${frist ? ` (am ${frist})` : ''}; bis dahin keine Leistung und keine Abbuchung`,
faellig: `erst am ${frist || 'Ende der Widerrufsfrist'}: ${erste}`,
beginn: `nach Ablauf der 14-tägigen Widerrufsfrist${frist ? ` (am ${frist})` : ''}`,
}
}
return {
faellig: `Heute fällig: ${erste} (${UST})`,
beginn: lage === 'wunsch' ? 'sofort, auf deinen ausdrücklichen Wunsch vor Ablauf der Widerrufsfrist' : 'sofort',
faellig: `heute ${erste}`,
beginn: lage === 'wunsch' ? 'sofort, auf deinen ausdrücklichen Wunsch' : 'sofort',
}
}
@@ -82,13 +84,19 @@ function bestellung(daten, productKey, { viewAs, kundenart }) {
const start = faelligBeginn({ lage: privat ? 'frist' : 'sofort', erste, frist })
const block = baueBlock([
{ label: 'Leistung', text: `${product.name}: ${(product.features || []).map(([t]) => t).join(', ')}` },
{ 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' },
], { id: 'abo-block' })
{ 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', hidden: true })
@@ -103,7 +111,8 @@ function bestellung(daten, productKey, { viewAs, kundenart }) {
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' }))
baueKundenartZeile(kundenart, T.kundenart, { id: 'abo-kundenart' }),
einzelheiten)
// Das Häkchen schaltet die Zeilen „Fällig“ und „Beginn“ um
haken?.addEventListener('change', () => {
+24 -7
View File
@@ -4,12 +4,14 @@
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, baueKundenartZeile, baueLinkzeile, hatWortlaut, kundenartVon } from './bestell-bausteine.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.'
@@ -75,18 +77,32 @@ export async function zeigeWechsel(wurzel, { route, me, nav, daten } = {}) {
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). */
/** Zeilen des Blocks aus der Vorschau des Servers, knapp: Leistung, Preis, Zusatzspeicher, Gutschrift, Fällig (mit 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})` },
{ label: 'Leistung', text: 'Wartungs-Abo statt Hosting-Abo', link: { text: 'Leistungen ansehen', href: leistungLink(T.wechsel.linkzeile, 'wartung') } },
{ label: 'Preis', text: `${fmtEuro(monthly / 100)} pro Monat (Gesamtpreis, ${UST})` },
...(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` }]
? [{ label: 'Zusatzspeicher', text: `${units} Paket${units === 1 ? '' : 'e'} (+${units * (Number(mail.gbPerUnit) || 0)} GB) bleiben gebucht, 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: 'Gutschrift', text: `−${fmtEuro(p.creditCents / 100)} für nicht genutzte Tage des Hosting-Abos` }]
: []),
{ label: 'Fällig', text: `heute ${fmtEuro(p.amountDueCents / 100)}; 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: `${units} Paket${units === 1 ? '' : 'e'} (+${units * (Number(mail.gbPerUnit) || 0)} GB) bleiben gebucht: ${fmtEuro(units * unitCents / 100)} pro Monat zusätzlich (${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)}` },
@@ -119,7 +135,8 @@ export async function zeigeWechsel(wurzel, { route, me, nav, daten } = {}) {
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' })))
baueKundenartZeile(kundenart, T.kundenart, { id: 'switch-kundenart' }),
p ? baueEinzelheiten(einzelheitenVon(p), { id: 'switch-einzelheiten' }) : null))
}
lauf.zeichne = zeichne
+14 -2
View File
@@ -336,6 +336,9 @@
.kn-speicher-warnung { margin: 0 0 12px; font-size: 13px; line-height: 20px; color: var(--kn-orange-fg); }
.kn-speicher-warnung a { color: inherit; text-decoration: underline; text-underline-offset: 3px; }
.kn-block dd.kn-fehler { color: var(--kn-danger); }
.kn-einzelheiten { margin: 20px 0 0; }
.kn-einzelheiten > h3 { margin: 0 0 8px; font-size: 14px; line-height: 20px; font-weight: 600; color: var(--kn-text-2); }
.kn-einzelheiten .kn-block { color: var(--kn-text-2); }
.kn-gutschrift-bedingungen { max-width: 640px; }
/* ------------------------------------------------------------------ Handy --- */
@@ -353,8 +356,17 @@
.kn-pw { max-width: none; }
.kn-tk-feld input:not([type="checkbox"]):not([type="file"]) { height: 44px; font-size: 16px; }
.kn-stepper .kn-knopf { min-width: 44px; }
.kn-block-zeile { grid-template-columns: minmax(0, 1fr); gap: 0; padding: 6px 0; }
.kn-block dt { font-size: 12px; line-height: 16px; }
/* Bestellseiten (F1): Kerninfo und Knopf müssen im nutzbaren Bereich zwischen Kopf und Leiste zusammen stehen (375 x 667: gut 550 px).
Darum läuft die Beschriftung in der Zeile mit (Label: Text) statt eine eigene Zeile zu brauchen, und der Abstand ist knapp. */
.kn-bestellen > h2 { margin: 0 0 6px; font-size: 17px; line-height: 24px; font-weight: 600; }
.kn-block { margin-bottom: 10px; }
.kn-block-zeile { display: block; padding: 5px 0; }
.kn-block dt, .kn-block dd { display: inline; }
.kn-block dt { font-weight: 600; color: var(--kn-text); }
.kn-block dt::after { content: ": "; }
.kn-linkzeile { margin-bottom: 8px; }
.kn-bestellen .kn-check { margin-bottom: 8px; }
.kn-sperrzeile { margin-bottom: 8px; }
.kn-bestellen .kn-knopf { width: 100%; min-height: 44px; }
.kn .kn-chat .chat-bubble { max-width: 88%; }
.kn .kn-chat .chat-messages { min-height: 240px; max-height: 55vh; }
+23 -2
View File
@@ -70,9 +70,30 @@ export function baueKundenartZeile(kundenart, texte, { id } = {}) {
return h('p', { id, class: 'kn-kundenart-zeile' }, lage, ' · ', korrektur)
}
/** Block „Was du bestellst“: zeilen [{ id?, label, text, klasse? }] als Beschriftungsliste. Der Text ist immer Text. */
/**
* Block „Was du bestellst“: zeilen [{ id?, label, text, klasse?, link? }] als Beschriftungsliste. Der Text ist immer Text.
* link { text, href }: hinter dem Text ' · ' und ein Link (nur mit sicherer Adresse; Adressen außerhalb der Seite öffnen in neuem Tab).
*/
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, class: z.klasse }, z.text))))
return h('dl', { id, class: 'kn-block' }, (zeilen || []).map((z) => {
const href = z.link ? sicherLink(z.link.href) : ''
const link = href ? [' · ', h('a', { href, ...(ausserhalb(href) ? { target: '_blank', rel: 'noopener' } : {}) }, z.link.text)] : null
return h('div', { class: 'kn-block-zeile' }, h('dt', null, z.label), h('dd', { id: z.id, class: z.klasse }, z.text, link))
}))
}
/**
* Die ausführlichen Sätze UNTER dem Knopf (und der Kundenart-Zeile): Überschrift „Im Einzelnen“ und dieselbe Beschriftungsliste wie der Block.
* Der Block über dem Knopf trägt die Kerninfo knapp, damit sie mit dem Knopf auf einen Handy-Bildschirm passt; hier steht sie ausführlich.
*/
export function baueEinzelheiten(zeilen, { id } = {}) {
return h('section', { id, class: 'kn-einzelheiten' }, h('h3', null, 'Im Einzelnen'), baueBlock(zeilen))
}
/** Adresse der Leistungsbeschreibung: die Bedingungen (erste Adresse der Linkzeile) mit Anker ('wartung', 'hosting'); unsicher oder fehlend ''. */
export function leistungLink(linkzeile, anker) {
const basis = sicherLink(Array.isArray(linkzeile?.urls) ? linkzeile.urls[0] : '')
return basis && !basis.includes('#') ? `${basis}#${anker}` : ''
}
const nichtLeer = (x) => typeof x === 'string' && x !== ''