Öffnungszeiten: Kachel und Bestell-/Einschaltweg im Portal

Kachel „Öffnungszeiten live“ inline in funktionen.js (keine neuen Importe, keine Zusatzabfrage),
Ansicht #zeiten (zeiten.js, zeiten-bestellen.js, zeiten.css) mit Einschalten im Wartungs-Abo,
Bestellen für 30 € (Widerrufsbelehrung vor dem Knopf, Ruling R32), Behalten nach dem Abo-Ende,
Statuskarte je Zustand, Rückkehr von Stripe und Platz für den Editor (Aufgabe 29).
GET /api/funktionen liefert dafür zeiten.belehrung (Haken belehrung der Funktion).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-05 05:30:23 +00:00
1 parent f20f056c60
commit 7bb918390a
10 files changed
+1878 -8

No files matched your search

+113 -1
View File
@@ -92,6 +92,7 @@ const ICONS = {
news: '<path d="M12 17v5"/><path d="M9 10.8a2 2 0 0 1-1.1 1.8l-1.8.9A2 2 0 0 0 5 15.2V16a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-.8a2 2 0 0 0-1.1-1.8l-1.8-.9A2 2 0 0 1 15 10.8V7a1 1 0 0 1 1-1 2 2 0 0 0 0-4H8a2 2 0 0 0 0 4 1 1 0 0 1 1 1z"/>',
calendar: '<rect x="3" y="5" width="18" height="16" rx="3"/><path d="M8 3v4M16 3v4M3 10h18"/>',
chart: '<path d="M4 20V10M10 20V4M16 20v-7M22 20H2"/>',
clock: '<circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/>',
}
const svg = (name) => `<svg class="fn-ico" viewBox="0 0 24 24" aria-hidden="true">${ICONS[name]}</svg>`
@@ -238,6 +239,107 @@ async function showTermineCount(root, projects) {
el.hidden = false
}
/* Oeffnungszeiten live: Kachel nur, wenn der Schalter es erlaubt (zeiten.verfuegbar); sonst gibt es sie gar nicht (auch kein "Kommt bald").
Wie bei den anderen Funktionen steht sie inline, die Ansicht #zeiten (zeiten.js) laedt app.js. Alles, was sie zeigt, kommt mit der Liste
(GET /api/funktionen): zeiten (Angebot) mit inklusive (true: im laufenden, aktiven Wartungs-Abo enthalten, einschaltbar | false: sonst, dann
sagt grund warum: kein_abo | gekuendigt | nicht_aktiv | null: Abo nicht lesbar) und preis; je Projekt features.zeiten (state, einbau, ticketWoid,
aboEndeAt, disabledReason, loeschungAm), zeitenStatus ({ offen, kurz, text } des Hauptplans) und zeitenLeer (keine Plaene gespeichert).
Ruling R20: nicht_aktiv (Probezeit, Zahlung offen) ist einschaltbar, sobald das Abo aktiv ist, und bietet kein Bezahlen an. */
const ZEITEN_TEXT = 'Deine Website zeigt live, ob du gerade geöffnet hast – mit Feiertagen, Urlaub und Hinweisen.'
const ZEITEN_ABGESCHALTET = 'Öffnungszeiten live wurde für diese Website abgeschaltet. Schreib uns im Chat, wenn du die Funktion wieder möchtest.'
const ZEITEN_NICHT_PRUEFBAR = 'Gerade nicht prüfbar – bitte später erneut.'
const zeitenOf = (project) => project.features?.zeiten || { state: 'off' }
// Zielprojekt: aktiv, dann in Einrichtung/Zahlung, dann abgeschaltet, sonst das erste
function pickZeitenProject(projects) {
return projects.find((p) => zeitenOf(p).state === 'active')
|| projects.find((p) => isSetup(zeitenOf(p).state))
|| projects.find((p) => zeitenOf(p).state === 'disabled')
|| projects[0]
}
// Frist zum Behalten: Berliner Kalendertag des erkannten Abo-Endes plus 30 Tage (wie fristTag in server/services/zeiten/aboAbgleich.js und die Mail K26);
// ein unlesbares Datum gibt '' (dann steht kein Hinweis da, nie ein erfundenes Datum). Doppelt zu zeiten.js (funktionen.js importiert dort nichts).
function zeitenFrist(aboEndeAt) {
const ms = typeof aboEndeAt === 'string' ? Date.parse(aboEndeAt) : NaN
if (Number.isNaN(ms)) return ''
const [y, m, d] = new Date(ms).toLocaleDateString('sv-SE', { timeZone: 'Europe/Berlin' }).split('-').map(Number)
return new Date(Date.UTC(y, m - 1, d + 30)).toLocaleDateString('de-DE', { timeZone: 'UTC', day: '2-digit', month: '2-digit', year: 'numeric' })
}
// 'JJJJ-MM-TT' als TT.MM.JJJJ, sonst ''
const zeitenTag = (iso) => {
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(typeof iso === 'string' ? iso : '')
return m ? `${m[3]}.${m[2]}.${m[1]}` : ''
}
function zeitenTile(projects, angebot = {}) {
const target = pickZeitenProject(projects)
const f = target ? zeitenOf(target) : { state: 'off' }
const preis = textOr(angebot.preis, PRICE)
const knopf = (text, { weg = '', klasse = 'kb-btn-primary fn-tile-cta' } = {}) => `<button type="button" class="${klasse}" data-view="zeiten" data-project="${esc(target.projectId)}"${weg ? ` data-zeiten-weg="${weg}"` : ''}>${text}</button>`
const chat = '<button type="button" class="kb-btn-primary fn-tile-cta" data-view="chat">Chat öffnen</button>'
let pill = ['Aus', 'off']
let extra = ''
let action = ''
if (!target) {
extra = '<p class="fn-count">Sobald deine Website angelegt ist, kannst du Öffnungszeiten live einrichten.</p>'
} else if (f.state === 'active' || f.state === 'setup') {
const aktiv = f.state === 'active'
pill = aktiv ? ['Aktiv', 'on'] : ['In Einrichtung', 'setup']
const status = typeof target.zeitenStatus?.text === 'string' ? target.zeitenStatus.text : ''
if (aktiv && status) extra += `<p class="fn-count" data-zeiten-status>${esc(status)}</p>`
if (!aktiv) {
extra += f.einbau === 'angestossen'
? '<p>Hinweis- und Kontakt-Leiste erscheinen nach dem nächsten Veröffentlichen (meist in wenigen Minuten).</p>'
: `<p>Unser Team baut die Funktion ein${f.ticketWoid ? ` (Ticket #${esc(f.ticketWoid)})` : ''}.</p>`
}
if (target.zeitenLeer === true) extra += `<p>${aktiv ? 'Trag jetzt deine Zeiten ein.' : 'Trag jetzt deine Zeiten ein – erst dann kann das Team die Tabelle einbauen.'}</p>`
action = knopf('Zeiten bearbeiten')
// Wartungs-Abo beendet (aboEndeAt): fuer einmalig 30 € behalten; nur wenn sicher ist, dass das Abo nicht wieder laeuft (inklusive false)
const frist = f.aboEndeAt && angebot.inklusive === false ? zeitenFrist(f.aboEndeAt) : ''
if (frist) {
extra += `<p class="fn-count">Dein Wartungs-Abo ist beendet – behalte die Funktion bis ${frist} für einmalig ${esc(preis)}.</p>`
action += knopf(`Für ${esc(preis)} behalten`, { weg: 'behalten', klasse: 'kb-btn-outline' })
}
} else if (f.state === 'ordered') {
pill = ['Zahlung offen', 'setup']
extra = '<p>Zahlung wird bestätigt …</p>'
action = knopf('Status ansehen')
} else if (f.state === 'disabled') {
pill = ['Abgeschaltet', 'off']
const aboEnde = f.disabledReason === 'abo_ende'
// Daten da: Plaene gespeichert (zeitenLeer false) und die Loeschfrist (loeschungAm, Berliner Tag) ist nicht vorbei
const daten = aboEnde && target.zeitenLeer === false && !(typeof f.loeschungAm === 'string' && f.loeschungAm < berlinIsoDay())
extra = `<p>${aboEnde ? 'Öffnungszeiten live ist abgeschaltet, weil dein Wartungs-Abo beendet ist.' : ZEITEN_ABGESCHALTET}</p>`
if (daten && zeitenTag(f.loeschungAm)) extra += `<p class="fn-count">Deine Eingaben bleiben bis ${zeitenTag(f.loeschungAm)} gespeichert.</p>`
if (daten && angebot.inklusive === true) extra += '<p class="fn-count">Dein Wartungs-Abo läuft wieder – wir schalten Öffnungszeiten live automatisch wieder ein (meist innerhalb eines Tages).</p>'
else if (daten && angebot.inklusive !== false) extra += `<p class="fn-count">${ZEITEN_NICHT_PRUEFBAR}</p>`
else action = daten ? knopf(`Für ${esc(preis)} wieder einschalten`, { weg: 'behalten' }) : chat
} else if (angebot.inklusive === true) {
extra = '<p>Im Wartungs-Abo enthalten.</p>'
action = knopf('Einschalten')
} else if (angebot.inklusive === false && angebot.grund === 'nicht_aktiv') {
extra = '<p class="fn-count">Im Wartungs-Abo enthalten – einschaltbar, sobald dein Abo aktiv ist.</p>'
} else if (angebot.inklusive === false) {
extra = `<p>${esc(preis)} einmalig</p>`
action = knopf(`Öffnungszeiten einrichten · ${esc(preis)}`)
} else {
extra = `<p class="fn-count">${ZEITEN_NICHT_PRUEFBAR}</p>`
}
const aktiveAnzahl = projects.filter((p) => zeitenOf(p).state === 'active').length
const count = projects.length > 1 && aktiveAnzahl ? `<p class="fn-count">aktiv für ${aktiveAnzahl} von ${projects.length} Websites</p>` : ''
return `
<article class="fn-tile" data-fn-tile="zeiten">
<div class="fn-tile-head">${svg('clock')}<span class="fn-pill fn-pill-${pill[1]}">${pill[0]}</span></div>
<h3>Öffnungszeiten live</h3>
<p>${ZEITEN_TEXT}</p>
${extra}
${count}
${action}
</article>`
}
const soonTile = (icon, title, text) => `
<article class="fn-tile fn-tile-soon">
<div class="fn-tile-head">${svg(icon)}<span class="fn-pill fn-pill-soon">Kommt bald</span></div>
@@ -247,11 +349,12 @@ const soonTile = (icon, title, text) => `
export async function initFunktionen(root) {
if (!root) return
const { projects = [], termine, features } = await request('/api/funktionen')
const { projects = [], termine, zeiten, features } = await request('/api/funktionen')
root.innerHTML = `
<div class="fn-tiles">
${newsTile(projects, newsOffer(features).price)}
${termine?.verfuegbar ? termineTile(projects, termine) : soonTile('calendar', 'Terminplaner', TERMINE_TEXT)}
${zeiten?.verfuegbar === true ? zeitenTile(projects, zeiten) : ''}
${analyseTile(projects)}
</div>`
root.querySelector('[data-fn-tile="news"] .fn-tile-cta')?.addEventListener('click', (ev) => { selection.projectId = ev.currentTarget.dataset.project })
@@ -261,6 +364,15 @@ export async function initFunktionen(root) {
const body = document.getElementById('termine-body')
if (body) body.dataset.wunschProjekt = ev.currentTarget.dataset.project
})
// Die Zeiten-Ansicht liest Projekt und Weg (Behalten) aus ihrem Behaelter (zeiten.js, initZeiten); der Klick steigt danach zum Navigationshandler auf
root.querySelector('[data-fn-tile="zeiten"]')?.addEventListener('click', (ev) => {
const knopf = ev.target?.closest?.('button[data-view="zeiten"]')
const body = knopf && document.getElementById('zeiten-body')
if (!body) return
body.dataset.wunschProjekt = knopf.dataset.project
if (knopf.dataset.zeitenWeg === 'behalten') body.dataset.wunschWeg = 'behalten'
else delete body.dataset.wunschWeg
})
if (termine?.verfuegbar) await showTermineCount(root, projects)
}