Portal: Fortschritt unter Projekte, neuer Tab Funktionen mit News-Editor
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
6dac6aa073
commit
5a09d060a1
7 files changed
+937
-24
No files matched your search
+51
-15
@@ -210,7 +210,7 @@ function renderProjectCard(project, features) {
|
||||
/* --------------------------------------------------------- Projektdetails --- */
|
||||
// Die Daten kommen fertig fuer Kunden aufbereitet vom Server (customerProjectView.js): keine
|
||||
// Commit-Kuerzel, Kontonamen oder KI-Hinweise, Status und Arbeitsarten auf Deutsch.
|
||||
// Dieselben Bausteine nutzt der Tab "Fortschritt" (loadCustomerActivity).
|
||||
// Dieselben Bausteine nutzt der Abschnitt "Was wir zuletzt erledigt haben" unter Projekte (loadCustomerActivity).
|
||||
|
||||
const PD_ICONS = {
|
||||
update: '<path d="M4 20h4L19 9l-4-4L4 16z"/><path d="M13 7l4 4"/>',
|
||||
@@ -690,23 +690,34 @@ async function loadCustomerInvoices() {
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------- Aktivität --- */
|
||||
// Tab "Fortschritt": je Auftrag eine Karte mit Status, Fortschrittsleiste und Arbeiten
|
||||
// (Bausteine der Projektdetails; Texte kommen aufbereitet vom Server)
|
||||
// Abschnitt "Was wir zuletzt erledigt haben" unter Projekte: je Auftrag eine Karte mit Status, Fortschrittsleiste
|
||||
// und Arbeiten (Bausteine der Projektdetails; Texte kommen aufbereitet vom Server). Zuerst die drei neuesten,
|
||||
// "Alle anzeigen" zeigt den Rest.
|
||||
const ACTIVITY_SHOWN = 3
|
||||
async function loadCustomerActivity() {
|
||||
const body = document.getElementById('activity-body')
|
||||
if (!body) return
|
||||
try {
|
||||
const { tickets } = await api('/api/activity')
|
||||
if (!tickets || !tickets.length) {
|
||||
const { tickets: all } = await api('/api/activity')
|
||||
if (!all || !all.length) {
|
||||
body.innerHTML = '<p class="muted">Es wurden noch keine Arbeiten dokumentiert.</p>'
|
||||
return
|
||||
}
|
||||
body.innerHTML = tickets.map((t) => `
|
||||
const tickets = [...all].sort((a, b) => String(b.createdAt || '').localeCompare(String(a.createdAt || '')))
|
||||
const card = (t) => `
|
||||
<article class="pd-order pd-order-card pd-tone-${escapeAttr(t.status.tone)}">
|
||||
${renderOrder(t)}
|
||||
${renderWorkEntries(t.entries || [], 'Noch keine Arbeiten dokumentiert.')}
|
||||
</article>`).join('')
|
||||
initPdToggles(body)
|
||||
</article>`
|
||||
const draw = (showAll) => {
|
||||
body.innerHTML = tickets.slice(0, showAll ? tickets.length : ACTIVITY_SHOWN).map(card).join('') +
|
||||
(tickets.length > ACTIVITY_SHOWN
|
||||
? `<button type="button" class="kb-btn-outline kb-activity-all" data-activity-all>${showAll ? 'Weniger anzeigen' : `Alle anzeigen (${tickets.length})`}</button>`
|
||||
: '')
|
||||
initPdToggles(body)
|
||||
body.querySelector('[data-activity-all]')?.addEventListener('click', () => draw(!showAll))
|
||||
}
|
||||
draw(false)
|
||||
} catch (err) {
|
||||
body.innerHTML = `<p class="muted">Aktivität konnte nicht geladen werden: ${escapeHtml(err.message)}</p>`
|
||||
}
|
||||
@@ -1948,14 +1959,32 @@ function initPasswordView() {
|
||||
// Ansichten im Inhaltsbereich; Hash fuer Direktlinks (z. B. aus Mails: #vertraege, #gutschrift).
|
||||
// Die alten Hashes #wartung und #abo fuehren zu #vertraege (HASH_ALIASES).
|
||||
// "projekt" (Projektdetails als eigene Seite) gibt es ebenfalls nur in der App-Ansicht.
|
||||
// "passwort" oeffnet das Konto-Menue (Profilbild).
|
||||
const VIEW_HASHES = { projects: '', activity: 'fortschritt', invoices: 'rechnungen', chat: 'chat', vertraege: 'vertraege', stunden: 'stunden', 'bestellen-hosting': 'bestellen-hosting', 'bestellen-wartung': 'bestellen-wartung', wechsel: 'wechsel', takeover: 'gutschrift', projekt: 'projekt', passwort: 'passwort' }
|
||||
// "passwort" oeffnet das Konto-Menue (Profilbild). #fortschritt fuehrt zu Projekte und scrollt zum Abschnitt
|
||||
// "Was wir zuletzt erledigt haben" (#pj-activity); hinter dem Hash darf ein Query stehen (#news?bestellt=1, Rueckkehr von Stripe).
|
||||
const VIEW_HASHES = { projects: '', funktionen: 'funktionen', news: 'news', invoices: 'rechnungen', chat: 'chat', vertraege: 'vertraege', stunden: 'stunden', 'bestellen-hosting': 'bestellen-hosting', 'bestellen-wartung': 'bestellen-wartung', wechsel: 'wechsel', takeover: 'gutschrift', projekt: 'projekt', passwort: 'passwort' }
|
||||
const HASH_ALIASES = { wartung: 'vertraege', abo: 'vertraege' }
|
||||
const viewLoaders = { activity: loadCustomerActivity, chat: initChat }
|
||||
const viewLoaders = { chat: initChat }
|
||||
// Funktionen und News laden bei jedem Oeffnen neu (Zustand aendert sich durch Bestellung und Freischaltung); Modul erst dann laden
|
||||
const viewRefreshers = {
|
||||
funktionen: () => import('/funktionen.js').then((m) => m.initFunktionen(document.getElementById('funktionen-body'))),
|
||||
news: () => import('/funktionen.js').then((m) => m.initNews(document.getElementById('news-body'))),
|
||||
}
|
||||
const loadedViews = new Set()
|
||||
|
||||
function currentHash() {
|
||||
return window.location.hash.replace(/^#/, '').split('?')[0]
|
||||
}
|
||||
|
||||
// Direktlink #fortschritt: nach dem Laden der Projekte zum Abschnitt scrollen
|
||||
let activityScrollPending = false
|
||||
function scrollToActivity() {
|
||||
if (!activityScrollPending) return
|
||||
document.getElementById('pj-activity')?.scrollIntoView({ block: 'start' })
|
||||
}
|
||||
|
||||
function viewFromHash() {
|
||||
const hash = window.location.hash.replace(/^#/, '')
|
||||
const hash = currentHash()
|
||||
if (hash === 'fortschritt') return 'projects'
|
||||
if (HASH_ALIASES[hash]) return HASH_ALIASES[hash]
|
||||
return Object.keys(VIEW_HASHES).find((v) => VIEW_HASHES[v] && VIEW_HASHES[v] === hash) || ''
|
||||
}
|
||||
@@ -1969,6 +1998,10 @@ function showView(name, { updateHash = true } = {}) {
|
||||
loadedViews.add(name)
|
||||
viewLoaders[name]()
|
||||
}
|
||||
viewRefreshers[name]?.().catch((err) => {
|
||||
const box = document.getElementById(`${name}-body`)
|
||||
if (box) box.innerHTML = `<p class="muted">${escapeHtml(err.message || 'Konnte nicht geladen werden.')}</p>`
|
||||
})
|
||||
if (name === 'chat') {
|
||||
api('/api/chat/read', { method: 'POST' }).catch(() => {})
|
||||
updateChatBadge(0)
|
||||
@@ -2073,10 +2106,12 @@ async function initDashboardPage() {
|
||||
if (aboParam) window.history.replaceState(null, '', `/dashboard.html${window.location.hash}`)
|
||||
loadContracts({ afterCheckout: aboParam === 'success' })
|
||||
loadCustomerInvoices()
|
||||
activityScrollPending = currentHash() === 'fortschritt'
|
||||
loadCustomerActivity().then(scrollToActivity)
|
||||
|
||||
const initialView = viewFromHash()
|
||||
// Alte Hashes (#wartung, #abo) gleich auf den neuen (#vertraege) umschreiben
|
||||
if (initialView) showView(initialView, { updateHash: Boolean(HASH_ALIASES[window.location.hash.replace(/^#/, '')]) })
|
||||
if (initialView) showView(initialView, { updateHash: Boolean(HASH_ALIASES[currentHash()]) })
|
||||
|
||||
const [{ projects }, { features }] = await Promise.all([
|
||||
api('/api/projects'),
|
||||
@@ -2098,6 +2133,7 @@ async function initDashboardPage() {
|
||||
} else {
|
||||
empty.classList.remove('hidden')
|
||||
}
|
||||
scrollToActivity()
|
||||
|
||||
// Chat-Badge global pollen (auch wenn der Chat nicht offen ist)
|
||||
pollChatBadge()
|
||||
@@ -2121,8 +2157,8 @@ const appLayout = window.matchMedia('(max-width: 880px), (pointer: coarse) and (
|
||||
const coarsePointer = window.matchMedia('(pointer: coarse)')
|
||||
// Unterseite -> Tab, zu dem sie gehoert (und Text neben dem Zurueck-Pfeil).
|
||||
// "passwort" setzt onAppViewShown: die Ansicht, aus der das Konto-Menue geoeffnet wurde.
|
||||
const APP_PARENT = { stunden: 'vertraege', 'bestellen-hosting': 'vertraege', 'bestellen-wartung': 'vertraege', wechsel: 'vertraege', takeover: 'vertraege', projekt: 'projects' }
|
||||
const APP_PARENT_LABEL = { vertraege: 'Verträge', projects: 'Projekte', activity: 'Fortschritt', invoices: 'Rechnungen', chat: 'Chat' }
|
||||
const APP_PARENT = { news: 'funktionen', stunden: 'vertraege', 'bestellen-hosting': 'vertraege', 'bestellen-wartung': 'vertraege', wechsel: 'vertraege', takeover: 'vertraege', projekt: 'projects' }
|
||||
const APP_PARENT_LABEL = { vertraege: 'Verträge', projects: 'Projekte', funktionen: 'Funktionen', invoices: 'Rechnungen', chat: 'Chat' }
|
||||
const appShell = { view: 'projects', scroll: {}, fromHistory: false, sideCards: [], installPrompt: null }
|
||||
const TEXT_INPUT = 'textarea, input:not([type=checkbox]):not([type=radio]):not([type=file]):not([type=date]):not([type=button]):not([type=submit]):not([type=range]):not([type=color])'
|
||||
|
||||
|
||||
+24
-8
@@ -21,6 +21,7 @@
|
||||
<link rel="stylesheet" href="/login.css">
|
||||
<link rel="stylesheet" href="/kundenbereich.css">
|
||||
<link rel="stylesheet" href="/chat-composer.css">
|
||||
<link rel="stylesheet" href="/funktionen.css">
|
||||
</head>
|
||||
<body class="portal-page dashboard-page kb-page">
|
||||
<div class="dot-background" aria-hidden="true">
|
||||
@@ -99,9 +100,9 @@
|
||||
<button type="button" class="kb-nav-item active" data-view="projects">
|
||||
<svg class="kb-tab-ico" viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="4" width="18" height="16" rx="3"/><path d="M3 9h18M6.5 6.5h.01M9.5 6.5h.01"/></svg>
|
||||
<span class="kb-tab-label">Projekte</span> <span class="kb-count" id="projects-count"></span></button>
|
||||
<button type="button" class="kb-nav-item" data-view="activity">
|
||||
<svg class="kb-tab-ico" viewBox="0 0 24 24" aria-hidden="true"><path d="M3 12h4l3-7 4 14 3-7h4"/></svg>
|
||||
<span class="kb-tab-label">Fortschritt</span></button>
|
||||
<button type="button" class="kb-nav-item" data-view="funktionen">
|
||||
<svg class="kb-tab-ico" viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="3" width="7.5" height="7.5" rx="2"/><rect x="13.5" y="3" width="7.5" height="7.5" rx="2"/><rect x="3" y="13.5" width="7.5" height="7.5" rx="2"/><path d="M17.25 13.5v7.5M13.5 17.25H21"/></svg>
|
||||
<span class="kb-tab-label">Funktionen</span></button>
|
||||
<button type="button" class="kb-nav-item" data-view="invoices">
|
||||
<svg class="kb-tab-ico" viewBox="0 0 24 24" aria-hidden="true"><path d="M6 3h12v18l-3-2-3 2-3-2-3 2z"/><path d="M9 8h6M9 12h6M9 16h3"/></svg>
|
||||
<span class="kb-tab-label">Rechnungen</span> <span class="kb-badge kb-badge-red hidden" id="invoices-badge" title="offene Rechnungen"></span></button>
|
||||
@@ -139,6 +140,12 @@
|
||||
<p id="loading" class="kb-empty">Projekte werden geladen…</p>
|
||||
<ul id="projects" class="kb-projects hidden"></ul>
|
||||
<div id="empty" class="kb-panel kb-empty hidden">Noch keine Website-Projekte zugewiesen.</div>
|
||||
|
||||
<!-- Fortschritt: die neuesten Aufträge (app.js, loadCustomerActivity); Direktlink #fortschritt -->
|
||||
<section class="kb-activity" id="pj-activity" aria-labelledby="pj-activity-title">
|
||||
<h2 id="pj-activity-title">Was wir zuletzt erledigt haben</h2>
|
||||
<div id="activity-body"><p class="kb-empty">Lade…</p></div>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<!-- App-Ansicht: Projektdetails als eigene Seite mit Zurueck zu "Projekte" (app.js, openProjectPage);
|
||||
@@ -151,13 +158,22 @@
|
||||
<div class="kb-details pd-page"></div>
|
||||
</section>
|
||||
|
||||
<!-- Fortschritt -->
|
||||
<section class="kb-view" id="view-activity">
|
||||
<!-- Funktionen: Kacheln (funktionen.js, initFunktionen); News ist auf dem Handy eine Unterseite davon -->
|
||||
<section class="kb-view" id="view-funktionen">
|
||||
<header class="kb-head">
|
||||
<h1>Fortschritt</h1>
|
||||
<p>Was wir an deinen Projekten erledigt haben.</p>
|
||||
<h1>Funktionen</h1>
|
||||
<p>Zusätzliche Bausteine für deine Website – ein Klick, wir richten sie ein.</p>
|
||||
</header>
|
||||
<div class="kb-panel" id="activity-body"><p class="kb-empty">Lade…</p></div>
|
||||
<div id="funktionen-body"><p class="kb-empty">Lade…</p></div>
|
||||
</section>
|
||||
|
||||
<!-- News-Pinnwand: Buchung, Einrichtungsstatus oder Editor (funktionen.js, initNews) -->
|
||||
<section class="kb-view" id="view-news" aria-live="polite">
|
||||
<header class="kb-head">
|
||||
<h1>News-Pinnwand</h1>
|
||||
<p>Kurze Neuigkeiten, die auf deiner Website hängen.</p>
|
||||
</header>
|
||||
<div id="news-body"><p class="kb-empty">Lade…</p></div>
|
||||
</section>
|
||||
|
||||
<!-- Rechnungen -->
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
/* Tab "Funktionen" und News-Pinnwand (funktionen.js). Farben aus den Tokens von kundenbereich.css;
|
||||
Aufbau des Editors nach dem freigegebenen Entwurf (Art, Felder, Zettelfarben, Live-Vorschau, "Hängt gerade"). */
|
||||
|
||||
/* Fortschritt unter Projekte (app.js, loadCustomerActivity) */
|
||||
.kb-activity { margin-top: 32px; scroll-margin-top: calc(var(--kb-appbar-h, 0px) + 16px); }
|
||||
.kb-activity h2 { margin: 0 0 14px; font-size: 20px; }
|
||||
#activity-body { display: grid; gap: 16px; }
|
||||
#activity-body > .muted { margin: 0; }
|
||||
.kb-activity-all { justify-self: start; }
|
||||
|
||||
/* ------------------------------------------------------------- Kacheln --- */
|
||||
.fn-tiles { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 16px; }
|
||||
.fn-tile { display: flex; flex-direction: column; align-items: flex-start; gap: 10px; padding: 20px; border-radius: 16px; background: var(--kb-surface); border: 1px solid var(--kb-border); }
|
||||
.fn-tile h3 { margin: 4px 0 0; font-size: 18px; }
|
||||
.fn-tile p { margin: 0; font-size: 14px; line-height: 1.5; color: var(--kb-muted); }
|
||||
.fn-tile .fn-count { font-size: 13px; color: var(--kb-lilac); }
|
||||
.fn-tile-cta { margin-top: auto; }
|
||||
.fn-tile-soon { opacity: 0.75; }
|
||||
.fn-tile-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; width: 100%; }
|
||||
.fn-ico { width: 34px; height: 34px; padding: 7px; box-sizing: border-box; border-radius: 10px; background: var(--kb-purple-soft); fill: none; stroke: var(--kb-lilac); stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
|
||||
.fn-pill { display: inline-block; padding: 3px 11px; border-radius: 999px; font-size: 12px; font-weight: 700; white-space: nowrap; }
|
||||
.fn-pill-off, .fn-pill-soon { color: var(--kb-muted); background: rgba(255, 255, 255, 0.05); border: 1px solid var(--kb-border-strong); }
|
||||
.fn-pill-setup { color: #fcd34d; background: rgba(251, 191, 36, 0.1); border: 1px solid rgba(251, 191, 36, 0.4); }
|
||||
.fn-pill-on { color: #86efac; background: rgba(74, 222, 128, 0.1); border: 1px solid rgba(74, 222, 128, 0.4); }
|
||||
|
||||
/* ------------------------------------------------- Buchung und Status --- */
|
||||
#view-news .kb-panel { max-width: 640px; }
|
||||
#view-news .nw .kb-panel { max-width: none; }
|
||||
#view-news .fn-project { margin-bottom: 16px; display: flex; align-items: center; gap: 12px; flex-wrap: wrap; padding: 14px 18px; }
|
||||
.fn-project label { font-size: 13px; font-weight: 600; }
|
||||
.fn-project select { flex: 1 1 220px; min-width: 0; padding: 10px 12px; border-radius: 12px; border: 1px solid var(--kb-border-strong); background: rgba(255, 255, 255, 0.05); color: inherit; font: inherit; }
|
||||
.fn-project select option { color: #111; }
|
||||
.fn-booking p, .fn-status p { margin: 0 0 12px; line-height: 1.55; }
|
||||
.fn-price { display: flex; align-items: baseline; flex-wrap: wrap; gap: 8px; }
|
||||
.fn-price strong { font-size: 28px; font-variant-numeric: tabular-nums; }
|
||||
.fn-price span, .fn-estimate { color: var(--kb-muted); font-size: 14px; }
|
||||
.fn-consent { display: flex; gap: 10px; align-items: flex-start; margin: 16px 0; font-size: 13.5px; line-height: 1.5; cursor: pointer; }
|
||||
.fn-consent input { flex: none; width: 18px; height: 18px; margin-top: 2px; accent-color: var(--kb-purple); }
|
||||
.fn-thanks { margin: 0 0 16px; padding: 12px 16px; max-width: 640px; border-radius: 12px; font-size: 14px; background: rgba(74, 222, 128, 0.1); border: 1px solid rgba(74, 222, 128, 0.4); }
|
||||
.fn-status h3 { margin: 10px 0 8px; }
|
||||
.fn-facts { display: grid; gap: 10px; margin: 16px 0; }
|
||||
.fn-facts > div { display: flex; flex-wrap: wrap; gap: 4px 16px; }
|
||||
.fn-facts dt { min-width: 150px; color: var(--kb-muted); font-size: 14px; }
|
||||
.fn-facts dd { margin: 0; font-weight: 600; }
|
||||
|
||||
/* -------------------------------------------------------------- Editor --- */
|
||||
.nw { display: grid; grid-template-columns: minmax(0, 1.1fr) minmax(0, 1fr); gap: 20px; align-items: start; }
|
||||
.nw-form { display: grid; gap: 16px; min-width: 0; }
|
||||
.nw-side { display: grid; gap: 16px; min-width: 0; position: sticky; top: 84px; }
|
||||
.nw-card h3 { margin: 0 0 16px; }
|
||||
.nw-step { margin: 0 0 6px; font-size: 11.5px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; color: var(--kb-lilac); }
|
||||
.nw-types { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; }
|
||||
.nw-type {
|
||||
display: grid; justify-items: center; gap: 8px; padding: 16px 6px 12px; border-radius: 14px; cursor: pointer;
|
||||
border: 2px solid var(--kb-border-strong); background: transparent; color: inherit; font: inherit; font-weight: 700; font-size: 14px; text-align: center;
|
||||
}
|
||||
.nw-type small { font-size: 12px; font-weight: 400; color: var(--kb-muted); }
|
||||
.nw-type-ico { display: grid; place-items: center; width: 44px; height: 44px; border-radius: 14px; background: var(--kb-purple-soft); transform: rotate(-3deg); }
|
||||
.nw-type-ico svg { width: 24px; height: 24px; fill: none; stroke: var(--kb-lilac); stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
|
||||
.nw-type[aria-pressed="true"] { border-color: var(--kb-purple); background: var(--kb-purple-soft); }
|
||||
.nw-field { display: grid; gap: 6px; margin-bottom: 18px; }
|
||||
.nw-field > label { font-size: 13px; font-weight: 600; }
|
||||
.nw-field input:not([type=file]), .nw-field select, .nw-field textarea {
|
||||
width: 100%; box-sizing: border-box; padding: 11px 12px; border-radius: 12px; font: inherit; font-size: 16px;
|
||||
border: 1px solid var(--kb-border-strong); background: rgba(255, 255, 255, 0.05); color: inherit; resize: vertical;
|
||||
}
|
||||
.nw-field select option { color: #111; }
|
||||
.nw-field input:focus, .nw-field select:focus, .nw-field textarea:focus { outline: none; border-color: var(--kb-purple-line); box-shadow: 0 0 0 3px var(--kb-purple-soft); }
|
||||
.nw-hint { margin: 0; font-size: 12.5px; color: var(--kb-muted); }
|
||||
.nw-hint-row { display: flex; justify-content: space-between; gap: 12px; }
|
||||
.nw-err { margin: 0; font-size: 13px; color: #fca5a5; }
|
||||
.nw-err:empty { display: none; }
|
||||
.nw-drop { display: grid; justify-items: center; gap: 12px; padding: 18px 14px; text-align: center; border: 2px dashed var(--kb-purple-line); border-radius: 14px; background: var(--kb-purple-soft); }
|
||||
.nw-drop-img { width: 100%; }
|
||||
.nw-drop-img img { display: block; width: 100%; max-height: 220px; object-fit: cover; border-radius: 10px; }
|
||||
.nw-drop-btns { display: flex; flex-wrap: wrap; gap: 8px; justify-content: center; }
|
||||
.nw-drop .nw-hint { margin: 0; }
|
||||
.nw-only-phone { display: none; }
|
||||
@media (pointer: coarse) { .nw-only-phone { display: inline-flex; } }
|
||||
.nw-swatches { display: flex; gap: 10px; }
|
||||
.nw-sw { width: 42px; height: 42px; padding: 0; border-radius: 12px; border: 2px solid rgba(255, 255, 255, 0.2); cursor: pointer; transform: rotate(-4deg); }
|
||||
.nw-sw[aria-pressed="true"] { outline: 3px solid #fff; outline-offset: 2px; }
|
||||
.nw-actions { margin-top: 6px; }
|
||||
.nw-paper-sun { --paper: #fff6d6; }
|
||||
.nw-paper-sky { --paper: #ddeffb; }
|
||||
.nw-paper-coral { --paper: #ffe1d9; }
|
||||
.nw-paper-white { --paper: #fff; }
|
||||
.nw-sw, .nw-thumb.nw-paper-sun, .nw-thumb.nw-paper-sky, .nw-thumb.nw-paper-coral, .nw-thumb.nw-paper-white { background: var(--paper); }
|
||||
|
||||
/* Vorschau: Papierzettel und Polaroid, dunkler Text auf hellem Papier */
|
||||
.nw-prev-label { display: flex; align-items: center; gap: 8px; margin: 0 0 14px; font-size: 11.5px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; color: var(--kb-muted); }
|
||||
.nw-prev-label i { width: 8px; height: 8px; border-radius: 50%; background: #4ade80; box-shadow: 0 0 0 3px rgba(74, 222, 128, 0.2); }
|
||||
#nw-preview { max-width: 380px; margin: 0 auto; color: #232a33; }
|
||||
.nw-paper { padding: 24px 22px 20px; border-radius: 6px 6px 18px 6px; background: var(--paper, #fff); box-shadow: 0 8px 18px rgba(0, 0, 0, 0.35); transform: rotate(-1.2deg); }
|
||||
.nw-paper h4 { margin: 8px 0; font-size: 19px; line-height: 1.25; overflow-wrap: anywhere; }
|
||||
.nw-paper p { margin: 0; font-size: 15px; line-height: 1.5; overflow-wrap: anywhere; white-space: pre-wrap; }
|
||||
.nw-date { font-size: 11px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; color: #6b7684; }
|
||||
.nw-polaroid { margin: 0; padding: 10px 10px 14px; border-radius: 4px; background: #fff; box-shadow: 0 10px 22px rgba(0, 0, 0, 0.4); transform: rotate(-2deg); }
|
||||
.nw-polaroid img, .nw-ph { display: block; width: 100%; aspect-ratio: 4 / 3; object-fit: cover; border-radius: 2px; background: #e6e9ee; }
|
||||
.nw-ph { display: grid; place-items: center; color: #8a94a3; font-size: 14px; }
|
||||
.nw-polaroid figcaption { display: flex; justify-content: space-between; gap: 12px; align-items: baseline; margin-top: 10px; }
|
||||
.nw-polaroid figcaption b { font-size: 16px; overflow-wrap: anywhere; }
|
||||
.nw-polaroid figcaption span { font-size: 11px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; color: #6b7684; }
|
||||
.nw-combo { display: grid; gap: 0; }
|
||||
.nw-combo .nw-paper { margin: -22px 10px 0 24px; transform: rotate(2deg); position: relative; }
|
||||
.nw-prev-toggle { display: none; width: 100%; justify-content: center; }
|
||||
|
||||
/* Listen */
|
||||
.nw-lists h3 { margin: 0; font-size: 16px; }
|
||||
.nw-lists-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; flex-wrap: wrap; margin-bottom: 8px; }
|
||||
.nw-expired-title { margin-top: 22px !important; }
|
||||
.nw-empty { margin: 8px 0 0; color: var(--kb-muted); font-size: 14px; }
|
||||
.nw-rows { list-style: none; margin: 0; padding: 0; }
|
||||
.nw-row { display: flex; align-items: center; flex-wrap: wrap; gap: 10px 12px; padding: 12px 0; border-top: 1px solid var(--kb-border); }
|
||||
.nw-thumb { width: 46px; height: 46px; flex: none; border-radius: 8px; object-fit: cover; display: grid; place-items: center; color: #232a33; font-weight: 700; }
|
||||
.nw-row-text { flex: 1 1 140px; min-width: 0; }
|
||||
.nw-row-text b { display: block; font-size: 14.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.nw-row-text small { color: var(--kb-muted); font-size: 12.5px; }
|
||||
.nw-row-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
|
||||
.nw-row-actions button, .nw-row-actions select { padding: 8px 12px; border-radius: 999px; border: 1px solid var(--kb-border-strong); background: transparent; color: inherit; font: inherit; font-size: 13px; font-weight: 700; cursor: pointer; }
|
||||
.nw-row-actions select option { color: #111; }
|
||||
.nw-row-actions button:hover { background: rgba(255, 255, 255, 0.07); }
|
||||
.nw-row-actions .nw-del { color: #fca5a5; }
|
||||
.nw-confirm { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; font-size: 13px; }
|
||||
.nw-row-err { flex: 1 0 100%; }
|
||||
|
||||
@media (max-width: 880px) {
|
||||
.nw { grid-template-columns: minmax(0, 1fr); }
|
||||
.nw-side { position: static; }
|
||||
.nw-prev-toggle { display: flex; }
|
||||
.nw-prev { display: none; }
|
||||
.nw-prev.is-open { display: block; }
|
||||
.nw-side { order: 2; }
|
||||
.nw-types { gap: 8px; }
|
||||
.nw-type small { display: none; }
|
||||
.nw-actions .abo-btn { flex: 1 1 auto; }
|
||||
.fn-tiles { grid-template-columns: minmax(0, 1fr); }
|
||||
.fn-facts dt { min-width: 0; flex: 1 0 100%; }
|
||||
}
|
||||
@@ -0,0 +1,500 @@
|
||||
/* Tab "Funktionen" und News-Pinnwand im Kundenbereich (ES-Modul, wird von app.js erst beim Oeffnen geladen).
|
||||
initFunktionen(rootEl): Kacheln der Funktionen. initNews(rootEl, { projectId }): je nach Zustand der Funktion
|
||||
Buchungsseite (off), Einrichtungsstatus (ordered/setup) oder Editor (active). Styles: funktionen.css.
|
||||
Kundentexte kommen nur ueber esc() bzw. textContent in die Seite. */
|
||||
|
||||
const PRICE = '30 €'
|
||||
const LEISTUNGSBESCHREIBUNG = '/vertragsunterlagen/funktionen-leistungsbeschreibung.html'
|
||||
const PHOTO_ACCEPT = 'image/jpeg,image/png,image/webp' // iOS wandelt HEIC dann selbst in JPEG um
|
||||
const PHOTO_MAX_BYTES = 10 * 1024 * 1024
|
||||
const TITLE_MAX = 60
|
||||
const TEXT_MAX = 400
|
||||
|
||||
const DURATIONS = [
|
||||
['1w', '1 Woche'], ['2w', '2 Wochen'], ['1m', '1 Monat'], ['3m', '3 Monate'],
|
||||
['open', 'Bis ich sie abnehme'], ['date', 'Bis zu einem Datum'],
|
||||
]
|
||||
const REHANG_DURATIONS = DURATIONS.filter(([key]) => key !== 'date')
|
||||
const PAPERS = [['sun', 'Sonnengelb'], ['sky', 'Himmelblau'], ['coral', 'Koralle'], ['white', 'Weiß']]
|
||||
const TYPES = [
|
||||
['note', 'Text', 'ein Zettel', '<path d="M12 20h9"/><path d="M16.4 3.6a1 1 0 0 1 3 3L7.4 18.6a2 2 0 0 1-.9.5l-2.9.8a.5.5 0 0 1-.6-.6l.8-2.9a2 2 0 0 1 .5-.9z"/>'],
|
||||
['photo', 'Foto', 'ein Polaroid', '<path d="M14.5 4h-5L7 7H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-3z"/><circle cx="12" cy="13" r="3"/>'],
|
||||
['combo', 'Foto + Text', 'beides', '<rect x="3" y="3" width="12" height="12" rx="2"/><path d="M9 21h10a2 2 0 0 0 2-2V9"/><path d="m3 11 3-3 4 4"/>'],
|
||||
]
|
||||
|
||||
const esc = (value) => String(value ?? '')
|
||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''')
|
||||
const safeUrl = (url) => (/^https?:\/\//i.test(url || '') ? url : '')
|
||||
|
||||
function fmtDay(value) {
|
||||
const d = new Date(value)
|
||||
if (!value || Number.isNaN(d.getTime())) return ''
|
||||
return d.toLocaleDateString('de-DE', { timeZone: 'Europe/Berlin', day: '2-digit', month: '2-digit', year: 'numeric' })
|
||||
}
|
||||
|
||||
// "2026-10-01" in der Zeitzone Berlin, plus Tage (fuer min/max des Datumsfelds)
|
||||
function berlinIsoDay(offsetDays = 0) {
|
||||
const d = new Date(Date.now() + offsetDays * 86400000)
|
||||
return d.toLocaleDateString('sv-SE', { timeZone: 'Europe/Berlin' })
|
||||
}
|
||||
|
||||
async function request(path, { method = 'GET', body, raw } = {}) {
|
||||
const response = await fetch(path, {
|
||||
method,
|
||||
credentials: 'same-origin',
|
||||
headers: raw ? { 'Content-Type': 'application/octet-stream' } : body ? { 'Content-Type': 'application/json' } : {},
|
||||
body: raw || (body ? JSON.stringify(body) : undefined),
|
||||
})
|
||||
const data = await response.json().catch(() => ({}))
|
||||
if (!response.ok) {
|
||||
const err = new Error(data.error || (response.status === 502 ? 'Server nicht erreichbar. Bitte kurz warten und noch einmal versuchen.' : `Fehler ${response.status}`))
|
||||
err.status = response.status
|
||||
if (data.field) err.field = data.field
|
||||
throw err
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ Zustand --- */
|
||||
// Das im News-Bereich gewaehlte Projekt bleibt beim Wechseln zwischen Kacheln und News erhalten
|
||||
const selection = { projectId: '' }
|
||||
|
||||
const newsOf = (project) => project.features?.news || { state: 'off' }
|
||||
const isSetup = (state) => state === 'ordered' || state === 'setup'
|
||||
|
||||
// Vorauswahl: zuerst ein aktives Projekt, dann eins in Einrichtung, sonst das erste
|
||||
function pickProject(projects) {
|
||||
return projects.find((p) => newsOf(p).state === 'active') || projects.find((p) => isSetup(newsOf(p).state)) || projects[0]
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ Kacheln --- */
|
||||
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"/>',
|
||||
}
|
||||
const svg = (name) => `<svg class="fn-ico" viewBox="0 0 24 24" aria-hidden="true">${ICONS[name]}</svg>`
|
||||
|
||||
function newsTile(projects) {
|
||||
const states = projects.map((p) => newsOf(p).state)
|
||||
const active = states.filter((s) => s === 'active').length
|
||||
let pill = ['Aus', 'off']
|
||||
let cta = `Einrichten lassen · ${PRICE}`
|
||||
if (active) { pill = ['Aktiv', 'on']; cta = 'Beiträge schreiben' } else if (states.some(isSetup)) { pill = ['In Einrichtung', 'setup']; cta = 'Status ansehen' }
|
||||
const target = pickProject(projects)
|
||||
const count = projects.length > 1 && active ? `<p class="fn-count">aktiv für ${active} von ${projects.length} Websites</p>` : ''
|
||||
return `
|
||||
<article class="fn-tile" data-fn-tile="news">
|
||||
<div class="fn-tile-head">${svg('news')}<span class="fn-pill fn-pill-${pill[1]}">${pill[0]}</span></div>
|
||||
<h3>News-Pinnwand</h3>
|
||||
<p>Schreib kurze Neuigkeiten mit Text und Foto – sie hängen wie Zettel an einer Pinnwand auf deiner Website.</p>
|
||||
${count}
|
||||
${target
|
||||
? `<button type="button" class="kb-btn-primary fn-tile-cta" data-view="news" data-project="${esc(target.projectId)}">${cta}</button>`
|
||||
: '<p class="fn-count">Sobald deine Website angelegt ist, kannst du die Pinnwand buchen.</p>'}
|
||||
</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>
|
||||
<h3>${title}</h3>
|
||||
<p>${text}</p>
|
||||
</article>`
|
||||
|
||||
export async function initFunktionen(root) {
|
||||
if (!root) return
|
||||
const { projects = [] } = await request('/api/funktionen')
|
||||
root.innerHTML = `
|
||||
<div class="fn-tiles">
|
||||
${newsTile(projects)}
|
||||
${soonTile('calendar', 'Terminplaner', 'Deine Kundschaft bucht Termine direkt über deine Website.')}
|
||||
${soonTile('chart', 'Analyse', 'Sieh, wie viele Besucher deine Website hat und was sie sich ansehen.')}
|
||||
</div>`
|
||||
root.querySelector('.fn-tile-cta')?.addEventListener('click', (ev) => { selection.projectId = ev.currentTarget.dataset.project })
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- News --- */
|
||||
export async function initNews(root, { projectId } = {}) {
|
||||
if (!root) return
|
||||
if (projectId) selection.projectId = projectId
|
||||
const { projects = [], setupEstimate = '', consentText = '' } = await request('/api/funktionen')
|
||||
if (!projects.length) {
|
||||
root.innerHTML = '<div class="kb-panel kb-empty">Noch keine Website-Projekte zugewiesen.</div>'
|
||||
return
|
||||
}
|
||||
const project = projects.find((p) => p.projectId === selection.projectId) || pickProject(projects)
|
||||
selection.projectId = project.projectId
|
||||
const feature = newsOf(project)
|
||||
const ordered = /[?&]bestellt=1\b/.test(window.location.hash)
|
||||
|
||||
root.innerHTML = `
|
||||
${projects.length > 1 ? `
|
||||
<div class="fn-project kb-panel">
|
||||
<label for="news-project">Website</label>
|
||||
<select id="news-project">${projects.map((p) => `<option value="${esc(p.projectId)}"${p.projectId === project.projectId ? ' selected' : ''}>${esc(p.projectName)}</option>`).join('')}</select>
|
||||
</div>` : ''}
|
||||
<div id="news-stage"></div>`
|
||||
root.querySelector('#news-project')?.addEventListener('change', (ev) => {
|
||||
selection.projectId = ev.target.value
|
||||
initNews(root).catch((err) => { root.innerHTML = `<p class="muted">${esc(err.message)}</p>` })
|
||||
})
|
||||
const stage = root.querySelector('#news-stage')
|
||||
const thanks = ordered && feature.state !== 'active'
|
||||
? '<p class="fn-thanks" role="status">Danke! Sobald die Zahlung da ist, legen wir los.</p>'
|
||||
: ''
|
||||
if (feature.state === 'active') renderEditor(stage, project)
|
||||
else if (isSetup(feature.state)) stage.innerHTML = thanks + statusCard(feature, setupEstimate)
|
||||
else if (feature.state === 'disabled') stage.innerHTML = '<div class="kb-panel"><h3>News-Pinnwand ist abgeschaltet</h3><p>Für diese Website ist die Pinnwand im Moment nicht aktiv. <button type="button" class="kb-link" data-view="chat">Schreib uns im Chat</button>, dann klären wir das.</p></div>'
|
||||
else renderBooking(stage, project, { setupEstimate, consentText, thanks })
|
||||
}
|
||||
|
||||
function renderBooking(stage, project, { setupEstimate, consentText, thanks }) {
|
||||
stage.innerHTML = `
|
||||
${thanks}
|
||||
<div class="kb-panel fn-booking">
|
||||
<h3>News-Pinnwand einrichten lassen</h3>
|
||||
<p>Wir bauen die Pinnwand in deine Website <strong>${esc(project.projectName)}</strong> ein. Danach schreibst du deine Neuigkeiten selbst hier im Kundenbereich – mit Text, Foto und Anzeigedauer.</p>
|
||||
<ul class="kb-checks">
|
||||
<li>Einrichtung und Einbau durch uns</li>
|
||||
<li>Beiträge jederzeit selbst schreiben, ändern und abnehmen</li>
|
||||
<li>Nutzung danach kostenlos, kein Abo</li>
|
||||
</ul>
|
||||
<p class="fn-price"><strong>${PRICE}</strong> <span>einmalig, inklusive Mehrwertsteuer</span></p>
|
||||
${setupEstimate ? `<p class="fn-estimate">Einrichtung: ${esc(setupEstimate)}</p>` : ''}
|
||||
<p class="fn-desc-link"><a href="${LEISTUNGSBESCHREIBUNG}" target="_blank" rel="noopener">Leistungsbeschreibung</a></p>
|
||||
<label class="fn-consent"><input type="checkbox" id="news-consent"><span id="news-consent-text"></span></label>
|
||||
<p class="error hidden" id="news-order-error" role="alert"></p>
|
||||
<div class="abo-actions"><button type="button" class="abo-btn" id="news-order" disabled>Zahlungspflichtig bestellen · ${PRICE}</button></div>
|
||||
</div>`
|
||||
stage.querySelector('#news-consent-text').textContent = consentText
|
||||
const box = stage.querySelector('#news-consent')
|
||||
const button = stage.querySelector('#news-order')
|
||||
const error = stage.querySelector('#news-order-error')
|
||||
box.addEventListener('change', () => { button.disabled = !box.checked })
|
||||
button.addEventListener('click', async () => {
|
||||
if (!box.checked) return
|
||||
button.disabled = true
|
||||
error.classList.add('hidden')
|
||||
try {
|
||||
const { url } = await request('/api/funktionen/news/checkout', { method: 'POST', body: { projectId: project.projectId, consent: true } })
|
||||
window.location.href = url
|
||||
} catch (err) {
|
||||
error.textContent = err.message
|
||||
error.classList.remove('hidden')
|
||||
button.disabled = !box.checked
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function statusCard(feature, setupEstimate) {
|
||||
const rows = [
|
||||
feature.ticketWoid ? `<div><dt>Ticketnummer</dt><dd>#${esc(feature.ticketWoid)}</dd></div>` : '',
|
||||
feature.orderedAt ? `<div><dt>Bestellt am</dt><dd>${esc(fmtDay(feature.orderedAt))}</dd></div>` : '',
|
||||
setupEstimate ? `<div><dt>Zeiteinschätzung</dt><dd>${esc(setupEstimate)}</dd></div>` : '',
|
||||
].join('')
|
||||
const invoice = safeUrl(feature.invoiceUrl)
|
||||
return `
|
||||
<div class="kb-panel fn-status">
|
||||
<span class="fn-pill fn-pill-setup">In Einrichtung</span>
|
||||
<h3>Wir richten deine News-Pinnwand ein</h3>
|
||||
<p>${feature.state === 'ordered' ? 'Sobald deine Zahlung bei uns ankommt, legen wir los.' : 'Sobald sie fertig ist, kannst du hier deine ersten Beiträge schreiben.'}</p>
|
||||
${rows ? `<dl class="fn-facts">${rows}</dl>` : ''}
|
||||
<div class="abo-actions">
|
||||
${invoice ? `<a class="kb-btn-outline" href="${esc(invoice)}" target="_blank" rel="noopener">Rechnung ansehen</a>` : ''}
|
||||
<button type="button" class="kb-btn-outline" data-view="chat">Frage dazu? Chat öffnen</button>
|
||||
</div>
|
||||
</div>`
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------- Editor --- */
|
||||
function renderEditor(stage, project) {
|
||||
const pid = project.projectId
|
||||
const q = `projectId=${encodeURIComponent(pid)}`
|
||||
const imageUrl = (imageId, size = 's') => `/api/news/image/${encodeURIComponent(pid)}/${encodeURIComponent(imageId)}-${size}.webp`
|
||||
const fresh = () => ({ editId: '', type: 'combo', title: '', text: '', paper: 'sun', duration: '1m', date: '', imageId: '', imageSrc: '', uploading: false })
|
||||
let ed = fresh()
|
||||
let liveUrl = ''
|
||||
let list = { hanging: [], expired: [] }
|
||||
|
||||
stage.innerHTML = `
|
||||
<div class="nw">
|
||||
<div class="nw-form">
|
||||
<div class="kb-panel nw-card">
|
||||
<p class="nw-step" id="nw-step-title">Neuer Zettel</p>
|
||||
<h3>Was möchtest du aufhängen?</h3>
|
||||
<div class="nw-types">
|
||||
${TYPES.map(([key, label, sub, icon]) => `
|
||||
<button type="button" class="nw-type" data-type="${key}" aria-pressed="false">
|
||||
<span class="nw-type-ico"><svg viewBox="0 0 24 24" aria-hidden="true">${icon}</svg></span>
|
||||
${label}<small>${sub}</small>
|
||||
</button>`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
<div class="kb-panel nw-card">
|
||||
<div class="nw-field" id="nw-photo-field">
|
||||
<label>Foto</label>
|
||||
<div class="nw-drop" id="nw-drop">
|
||||
<div class="nw-drop-img hidden" id="nw-drop-img"><img alt=""></div>
|
||||
<div class="nw-drop-btns">
|
||||
<label class="kb-btn-outline nw-only-phone"><input type="file" accept="${PHOTO_ACCEPT}" capture="environment" hidden>Foto aufnehmen</label>
|
||||
<label class="kb-btn-outline" id="nw-pick"><input type="file" accept="${PHOTO_ACCEPT}" hidden><span>Foto auswählen</span></label>
|
||||
</div>
|
||||
<p class="nw-hint" id="nw-photo-hint">JPEG, PNG, WebP oder iPhone-Foto, bis 10 MB.</p>
|
||||
</div>
|
||||
<p class="nw-err" data-err="photo" role="alert"></p>
|
||||
</div>
|
||||
<div class="nw-field">
|
||||
<label for="nw-title" id="nw-title-label">Überschrift (freiwillig)</label>
|
||||
<input type="text" id="nw-title" maxlength="${TITLE_MAX}" autocomplete="off">
|
||||
<p class="nw-err" data-err="title" role="alert"></p>
|
||||
</div>
|
||||
<div class="nw-field" id="nw-text-field">
|
||||
<label for="nw-text">Text</label>
|
||||
<textarea id="nw-text" rows="4" maxlength="${TEXT_MAX}"></textarea>
|
||||
<p class="nw-hint nw-hint-row"><span>Kurz und herzlich wirkt am besten.</span><span id="nw-count">0 / ${TEXT_MAX}</span></p>
|
||||
<p class="nw-err" data-err="text" role="alert"></p>
|
||||
</div>
|
||||
<div class="nw-field" id="nw-paper-field">
|
||||
<label>Zettelfarbe</label>
|
||||
<div class="nw-swatches">
|
||||
${PAPERS.map(([key, label]) => `<button type="button" class="nw-sw nw-paper-${key}" data-paper="${key}" aria-pressed="false" aria-label="${label}" title="${label}"></button>`).join('')}
|
||||
</div>
|
||||
<p class="nw-err" data-err="paper" role="alert"></p>
|
||||
</div>
|
||||
<div class="nw-field">
|
||||
<label for="nw-duration">Wie lange soll der Zettel hängen?</label>
|
||||
<select id="nw-duration">${DURATIONS.map(([key, label]) => `<option value="${key}"${key === '1m' ? ' selected' : ''}>${label}</option>`).join('')}</select>
|
||||
<input type="date" id="nw-date" class="hidden" min="${berlinIsoDay(1)}" max="${berlinIsoDay(365)}" aria-label="Letzter Tag">
|
||||
<p class="nw-err" data-err="duration" role="alert"></p>
|
||||
</div>
|
||||
<p class="nw-err" data-err="_" role="alert"></p>
|
||||
<p class="fn-thanks hidden" id="nw-done" role="status"></p>
|
||||
<div class="abo-actions nw-actions">
|
||||
<button type="button" class="abo-btn" id="nw-publish">Aufhängen</button>
|
||||
<button type="button" class="kb-btn-outline hidden" id="nw-cancel">Abbrechen</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="nw-side">
|
||||
<button type="button" class="kb-btn-outline nw-prev-toggle" id="nw-prev-toggle" aria-expanded="false">So sieht dein Zettel aus</button>
|
||||
<div class="kb-panel nw-prev" id="nw-prev-box">
|
||||
<p class="nw-prev-label"><i></i>Vorschau: so sehen es alle</p>
|
||||
<div id="nw-preview"></div>
|
||||
</div>
|
||||
<div class="kb-panel nw-lists" id="nw-lists"><p class="kb-empty">Lade…</p></div>
|
||||
</div>
|
||||
</div>`
|
||||
|
||||
const $ = (sel) => stage.querySelector(sel)
|
||||
const $$ = (sel) => [...stage.querySelectorAll(sel)]
|
||||
const setErr = (field, message) => {
|
||||
const target = $(`[data-err="${field}"]`) || $('[data-err="_"]')
|
||||
if (target) target.textContent = message || ''
|
||||
}
|
||||
const clearErrs = () => $$('[data-err]').forEach((el) => { el.textContent = '' })
|
||||
const errField = (field) => ({ imageId: 'photo', date: 'duration' }[field] || field || '_')
|
||||
|
||||
function previewHtml() {
|
||||
const hasPhoto = ed.type !== 'note'
|
||||
const photo = hasPhoto
|
||||
? (ed.imageSrc ? `<img src="${esc(ed.imageSrc)}" alt="">` : '<span class="nw-ph">Foto</span>')
|
||||
: ''
|
||||
const title = ed.title.trim()
|
||||
if (ed.type === 'photo') {
|
||||
return `<figure class="nw-polaroid">${photo}<figcaption><b>${esc(title)}</b><span>Heute</span></figcaption></figure>`
|
||||
}
|
||||
const note = `<div class="nw-paper nw-paper-${ed.paper}"><span class="nw-date">Heute</span>${title ? `<h4>${esc(title)}</h4>` : ''}<p>${esc(ed.text)}</p></div>`
|
||||
return ed.type === 'combo' ? `<div class="nw-combo"><figure class="nw-polaroid">${photo}</figure>${note}</div>` : note
|
||||
}
|
||||
|
||||
function sync() {
|
||||
$$('.nw-type').forEach((b) => b.setAttribute('aria-pressed', String(b.dataset.type === ed.type)))
|
||||
$$('.nw-sw').forEach((b) => b.setAttribute('aria-pressed', String(b.dataset.paper === ed.paper)))
|
||||
$('#nw-photo-field').classList.toggle('hidden', ed.type === 'note')
|
||||
$('#nw-text-field').classList.toggle('hidden', ed.type === 'photo')
|
||||
$('#nw-paper-field').classList.toggle('hidden', ed.type === 'photo')
|
||||
$('#nw-title-label').textContent = ed.type === 'photo' ? 'Bildunterschrift (freiwillig)' : 'Überschrift (freiwillig)'
|
||||
$('#nw-count').textContent = `${ed.text.length} / ${TEXT_MAX}`
|
||||
$('#nw-date').classList.toggle('hidden', ed.duration !== 'date')
|
||||
$('#nw-step-title').textContent = ed.editId ? 'Zettel ändern' : 'Neuer Zettel'
|
||||
$('#nw-publish').textContent = ed.editId ? 'Änderungen speichern' : 'Aufhängen'
|
||||
$('#nw-cancel').classList.toggle('hidden', !ed.editId)
|
||||
const img = $('#nw-drop-img')
|
||||
img.classList.toggle('hidden', !ed.imageSrc)
|
||||
if (ed.imageSrc) img.querySelector('img').src = ed.imageSrc
|
||||
$('#nw-pick span').textContent = ed.imageSrc ? 'Anderes Foto wählen' : 'Foto auswählen'
|
||||
$('#nw-publish').disabled = ed.uploading
|
||||
$('#nw-preview').innerHTML = previewHtml()
|
||||
}
|
||||
|
||||
function reset() {
|
||||
ed = fresh()
|
||||
$('#nw-title').value = ''
|
||||
$('#nw-text').value = ''
|
||||
$('#nw-duration').value = '1m'
|
||||
$('#nw-date').value = ''
|
||||
clearErrs()
|
||||
sync()
|
||||
}
|
||||
|
||||
// Formular
|
||||
$$('.nw-type').forEach((b) => b.addEventListener('click', () => { ed.type = b.dataset.type; sync() }))
|
||||
$$('.nw-sw').forEach((b) => b.addEventListener('click', () => { ed.paper = b.dataset.paper; sync() }))
|
||||
$('#nw-title').addEventListener('input', (ev) => { ed.title = ev.target.value; sync() })
|
||||
$('#nw-text').addEventListener('input', (ev) => { ed.text = ev.target.value; sync() })
|
||||
$('#nw-duration').addEventListener('change', (ev) => { ed.duration = ev.target.value; sync() })
|
||||
$('#nw-date').addEventListener('change', (ev) => { ed.date = ev.target.value })
|
||||
$('#nw-prev-toggle').addEventListener('click', (ev) => {
|
||||
const open = $('#nw-prev-box').classList.toggle('is-open')
|
||||
ev.currentTarget.setAttribute('aria-expanded', String(open))
|
||||
})
|
||||
$('#nw-cancel').addEventListener('click', reset)
|
||||
|
||||
// Foto: gleich hochladen, der Server liefert die imageId
|
||||
$$('#nw-photo-field input[type=file]').forEach((input) => input.addEventListener('change', async () => {
|
||||
const file = input.files[0]
|
||||
input.value = ''
|
||||
if (!file) return
|
||||
setErr('photo', '')
|
||||
if (file.size > PHOTO_MAX_BYTES) { setErr('photo', 'Das Foto ist größer als 10 MB.'); return }
|
||||
const previous = { imageId: ed.imageId, imageSrc: ed.imageSrc }
|
||||
ed.uploading = true
|
||||
ed.imageSrc = URL.createObjectURL(file)
|
||||
sync()
|
||||
$('#nw-photo-hint').textContent = 'Foto wird hochgeladen…'
|
||||
try {
|
||||
const { imageId } = await request(`/api/news/image?${q}`, { method: 'POST', raw: file })
|
||||
ed.imageId = imageId
|
||||
} catch (err) {
|
||||
setErr('photo', err.message)
|
||||
Object.assign(ed, previous)
|
||||
} finally {
|
||||
ed.uploading = false
|
||||
$('#nw-photo-hint').textContent = 'JPEG, PNG, WebP oder iPhone-Foto, bis 10 MB.'
|
||||
sync()
|
||||
}
|
||||
}))
|
||||
|
||||
$('#nw-publish').addEventListener('click', async () => {
|
||||
clearErrs()
|
||||
$('#nw-done').classList.add('hidden')
|
||||
const body = { type: ed.type, title: ed.title.trim(), text: ed.type === 'photo' ? '' : ed.text, paper: ed.paper, duration: ed.duration }
|
||||
if (ed.duration === 'date') body.date = ed.date
|
||||
if (ed.type !== 'note') body.imageId = ed.imageId
|
||||
const wasEdit = Boolean(ed.editId)
|
||||
const button = $('#nw-publish')
|
||||
button.disabled = true
|
||||
try {
|
||||
if (wasEdit) await request(`/api/news/${encodeURIComponent(ed.editId)}?${q}`, { method: 'PATCH', body })
|
||||
else await request('/api/news', { method: 'POST', body: { ...body, projectId: pid } })
|
||||
reset()
|
||||
const done = $('#nw-done')
|
||||
done.textContent = wasEdit ? 'Gespeichert.' : 'Hängt jetzt an deiner Pinnwand.'
|
||||
done.classList.remove('hidden')
|
||||
await loadList()
|
||||
} catch (err) {
|
||||
setErr(errField(err.field), err.message)
|
||||
} finally {
|
||||
button.disabled = ed.uploading
|
||||
}
|
||||
})
|
||||
|
||||
// Listen "Hängt gerade" / "Abgelaufen"
|
||||
const untilText = (p) => (p.visibleUntil ? `bis ${fmtDay(p.visibleUntil)}` : 'bis du sie abnimmst')
|
||||
function rowHtml(p, expired) {
|
||||
const label = p.title || (p.text ? p.text.slice(0, 60) : 'Foto')
|
||||
const thumb = p.imageId
|
||||
? `<img class="nw-thumb" src="${esc(imageUrl(p.imageId))}" alt="">`
|
||||
: `<span class="nw-thumb nw-paper-${esc(p.paper)}">T</span>`
|
||||
const meta = expired
|
||||
? (p.takenDownAt ? `Abgenommen am ${fmtDay(p.takenDownAt)}` : `Abgelaufen am ${fmtDay(p.visibleUntil)}`)
|
||||
: `Seit ${fmtDay(p.visibleFrom)} · ${untilText(p)}`
|
||||
const actions = expired
|
||||
? `<select class="nw-rehang-dur" aria-label="Anzeigedauer">${REHANG_DURATIONS.map(([k, l]) => `<option value="${k}"${k === '1m' ? ' selected' : ''}>${l}</option>`).join('')}</select>
|
||||
<button type="button" data-rehang>Wieder aufhängen</button>`
|
||||
: '<button type="button" data-edit>Ändern</button><button type="button" data-takedown>Abnehmen</button>'
|
||||
return `
|
||||
<li class="nw-row" data-id="${esc(p.$id)}">
|
||||
${thumb}
|
||||
<div class="nw-row-text"><b>${esc(label)}</b><small>${esc(meta)}</small></div>
|
||||
<div class="nw-row-actions">${actions}<button type="button" class="nw-del" data-del>Löschen</button></div>
|
||||
<p class="nw-err nw-row-err" role="alert"></p>
|
||||
</li>`
|
||||
}
|
||||
|
||||
function renderLists() {
|
||||
const box = $('#nw-lists')
|
||||
const site = safeUrl(liveUrl)
|
||||
box.innerHTML = `
|
||||
<div class="nw-lists-head">
|
||||
<h3>Hängt gerade</h3>
|
||||
${site ? `<a class="kb-btn-outline" href="${esc(site)}" target="_blank" rel="noopener">Auf meiner Website ansehen</a>` : ''}
|
||||
</div>
|
||||
${list.hanging.length ? `<ul class="nw-rows">${list.hanging.map((p) => rowHtml(p, false)).join('')}</ul>` : '<p class="nw-empty">Im Moment hängt nichts. Schreib deinen ersten Zettel links.</p>'}
|
||||
${list.expired.length ? `<h3 class="nw-expired-title">Abgelaufen</h3><p class="nw-hint">Nicht mehr auf deiner Website zu sehen. Du kannst sie wieder aufhängen.</p><ul class="nw-rows">${list.expired.map((p) => rowHtml(p, true)).join('')}</ul>` : ''}`
|
||||
box.querySelectorAll('.nw-row').forEach(wireRow)
|
||||
}
|
||||
|
||||
function wireRow(row) {
|
||||
const id = row.dataset.id
|
||||
const all = [...list.hanging, ...list.expired]
|
||||
const post = all.find((p) => p.$id === id)
|
||||
const rowErr = row.querySelector('.nw-row-err')
|
||||
const run = async (fn) => {
|
||||
rowErr.textContent = ''
|
||||
try {
|
||||
await fn()
|
||||
await loadList()
|
||||
} catch (err) {
|
||||
rowErr.textContent = err.message
|
||||
}
|
||||
}
|
||||
row.querySelector('[data-edit]')?.addEventListener('click', () => startEdit(post))
|
||||
row.querySelector('[data-takedown]')?.addEventListener('click', () => run(() => request(`/api/news/${encodeURIComponent(id)}?${q}`, { method: 'PATCH', body: { action: 'takeDown' } })))
|
||||
row.querySelector('[data-rehang]')?.addEventListener('click', () => {
|
||||
const duration = row.querySelector('.nw-rehang-dur').value
|
||||
run(() => request(`/api/news/${encodeURIComponent(id)}?${q}`, { method: 'PATCH', body: { action: 'rehang', duration } }))
|
||||
})
|
||||
// Loeschen mit Rueckfrage in der Zeile (kein confirm())
|
||||
row.querySelector('[data-del]').addEventListener('click', () => {
|
||||
const actions = row.querySelector('.nw-row-actions')
|
||||
actions.innerHTML = '<span class="nw-confirm"><span>Wirklich löschen?</span><button type="button" class="nw-del" data-yes>Ja, löschen</button><button type="button" data-cancel>Abbrechen</button></span>'
|
||||
actions.querySelector('[data-cancel]').addEventListener('click', renderLists)
|
||||
actions.querySelector('[data-yes]').addEventListener('click', () => run(async () => {
|
||||
await request(`/api/news/${encodeURIComponent(id)}?${q}`, { method: 'DELETE' })
|
||||
if (ed.editId === id) reset()
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
function startEdit(post) {
|
||||
clearErrs()
|
||||
$('#nw-done').classList.add('hidden')
|
||||
ed = {
|
||||
editId: post.$id, type: post.type, title: post.title || '', text: post.text || '', paper: post.paper,
|
||||
duration: post.duration, date: post.duration === 'date' && post.visibleUntil ? new Date(post.visibleUntil).toLocaleDateString('sv-SE', { timeZone: 'Europe/Berlin' }) : '',
|
||||
imageId: post.imageId || '', imageSrc: post.imageId ? imageUrl(post.imageId, 'l') : '', uploading: false,
|
||||
}
|
||||
$('#nw-title').value = ed.title
|
||||
$('#nw-text').value = ed.text
|
||||
$('#nw-duration').value = ed.duration
|
||||
$('#nw-date').value = ed.date
|
||||
sync()
|
||||
$('#nw-step-title').scrollIntoView({ block: 'start', behavior: 'smooth' })
|
||||
}
|
||||
|
||||
async function loadList() {
|
||||
try {
|
||||
const data = await request(`/api/news?${q}`)
|
||||
list = { hanging: data.hanging || [], expired: data.expired || [] }
|
||||
liveUrl = data.liveUrl || ''
|
||||
renderLists()
|
||||
} catch (err) {
|
||||
$('#nw-lists').innerHTML = `<p class="muted">Beiträge konnten nicht geladen werden: ${esc(err.message)}</p>`
|
||||
}
|
||||
}
|
||||
|
||||
sync()
|
||||
return loadList()
|
||||
}
|
||||
@@ -20,7 +20,11 @@ router.get('/', requireSession, async (req, res) => {
|
||||
const customerId = getSessionCustomerId(req)
|
||||
// Ein Fehler beim Freischalten (z. B. Ticketsystem) darf die Liste nicht verhindern
|
||||
await runActivation({ customerId }).catch((err) => console.error('[funktionen] Freischalten fehlgeschlagen:', err.message))
|
||||
res.json({ projects: await listCustomerFeatures(customerId), setupEstimate: config.funktionen.setupEstimate })
|
||||
res.json({
|
||||
projects: await listCustomerFeatures(customerId),
|
||||
setupEstimate: config.funktionen.setupEstimate,
|
||||
consentText: config.funktionen.consentText,
|
||||
})
|
||||
} catch (err) {
|
||||
fail(res, err)
|
||||
}
|
||||
|
||||
@@ -60,6 +60,7 @@ const paidSession = (id, projectId, extra = {}) => ({
|
||||
test('liste_zustaende', async () => {
|
||||
const body = await get('/api/funktionen', { cookie })
|
||||
assert.equal(body.setupEstimate, 'meist innerhalb von 1–2 Wochen')
|
||||
assert.equal(body.consentText, config.funktionen.consentText)
|
||||
assert.equal(body.projects.length, 4)
|
||||
for (const p of body.projects) assert.equal(p.features.news.state, 'off')
|
||||
})
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
// Playwright-Checks Funktionen-Tab und News-Editor: statischer Server fuer public/ + per page.route gemockte API.
|
||||
// Nicht Teil von npm test. Aufruf: node test/ui/funktionen.ui.mjs
|
||||
// Screenshots der Hauptansichten landen in /root/reports/funktionen-news-ui/.
|
||||
import http from 'node:http'
|
||||
import { readFile, mkdir } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { chromium } from '/root/.npm/_npx/db89d7302a373f10/node_modules/playwright-core/index.mjs'
|
||||
|
||||
const pub = path.join(path.dirname(fileURLToPath(import.meta.url)), '../../public')
|
||||
const SHOTS = '/root/reports/funktionen-news-ui'
|
||||
await mkdir(SHOTS, { recursive: true })
|
||||
const TYPES = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.svg': 'image/svg+xml', '.png': 'image/png', '.ico': 'image/x-icon', '.webmanifest': 'application/json' }
|
||||
|
||||
const server = http.createServer(async (req, res) => {
|
||||
const url = new URL(req.url, 'http://x')
|
||||
try {
|
||||
const file = path.join(pub, url.pathname === '/' ? 'dashboard.html' : url.pathname)
|
||||
const data = await readFile(file)
|
||||
res.writeHead(200, { 'content-type': TYPES[path.extname(file)] || 'application/octet-stream' })
|
||||
res.end(data)
|
||||
} catch {
|
||||
res.writeHead(404)
|
||||
res.end('nicht gefunden')
|
||||
}
|
||||
})
|
||||
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve))
|
||||
const base = `http://127.0.0.1:${server.address().port}`
|
||||
|
||||
// ---- Fixtures ----
|
||||
const feat = (state, extra = {}) => ({ state, siteKey: 'abcdefghijklmnop', ticketWoid: state === 'off' ? null : '1234', orderedAt: state === 'off' ? null : '2026-09-28T09:00:00.000Z', activatedAt: state === 'active' ? '2026-09-29T09:00:00.000Z' : null, invoiceUrl: state === 'off' ? null : 'https://example.test/rechnung', ...extra })
|
||||
const proj = (id, name, state) => ({ projectId: id, projectName: name, liveUrl: 'https://baeckerei.example', features: { news: feat(state) } })
|
||||
const SCENARIOS = {
|
||||
off: [proj('p1', 'Bäckerei Berg', 'off')],
|
||||
setup: [proj('p1', 'Bäckerei Berg', 'setup')],
|
||||
active: [proj('p1', 'Bäckerei Berg', 'active')],
|
||||
multi: [proj('p1', 'Bäckerei Berg', 'active'), proj('p2', 'Café Nord', 'off')],
|
||||
}
|
||||
const CONSENT = 'Ich verlange ausdrücklich, dass WEBklar vor Ablauf der Widerrufsfrist mit dem Einbau beginnt.'
|
||||
const post = (id, type, title, text, paper, extra = {}) => ({ $id: id, type, title, text, paper, duration: '1m', imageId: type === 'note' ? null : `img-${id}`, visibleFrom: '2026-09-25T10:00:00.000Z', visibleUntil: '2026-10-25T21:59:59.999Z', takenDownAt: null, ...extra })
|
||||
const newsList = {
|
||||
hanging: [post('n1', 'combo', 'Danke für eure Spenden!', 'Diese Woche sind drei volle Kisten zusammengekommen.', 'sun'), post('n2', 'note', 'Freitag geschlossen', 'Am 3. Oktober bleibt der Laden zu.', 'sky')],
|
||||
expired: [post('n3', 'photo', 'Holzspielzeug', '', 'white', { visibleUntil: '2026-09-20T21:59:59.999Z' })],
|
||||
liveUrl: 'https://baeckerei.example',
|
||||
}
|
||||
const tickets = [1, 2, 3, 4].map((n) => ({
|
||||
number: String(1200 + n), topic: `Auftrag ${n}`, createdAt: `2026-09-${10 + n}T10:00:00.000Z`,
|
||||
status: { step: 4, tone: 'done', label: 'Erledigt' },
|
||||
entries: [{ kind: 'update', label: 'Änderung', date: '2026-09-20', person: 'Kenso', text: 'Text angepasst' }],
|
||||
}))
|
||||
|
||||
let scenario = 'off'
|
||||
const checkoutCalls = []
|
||||
async function mockApi(page) {
|
||||
await page.route('**/api/**', async (route) => {
|
||||
const req = route.request()
|
||||
const url = new URL(req.url())
|
||||
const p = url.pathname
|
||||
const json = (body, status = 200) => route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(body) })
|
||||
if (p === '/api/auth/me') return json({ authenticated: true, role: 'customer', customer: { id: 'c1', code: '1001', name: 'Berta Berg', email: 'b@example.test' } })
|
||||
if (p === '/api/projects') return json({ projects: [{ id: 'p1', name: 'Bäckerei Berg', status: 'live' }] })
|
||||
if (p === '/api/features') return json({ features: [] })
|
||||
if (p === '/api/activity') return json({ tickets })
|
||||
if (p === '/api/funktionen') return json({ projects: SCENARIOS[scenario], setupEstimate: 'meist innerhalb von 1–2 Wochen', consentText: CONSENT })
|
||||
if (p === '/api/funktionen/news/checkout') {
|
||||
checkoutCalls.push(req.postDataJSON())
|
||||
return json({ url: `${base}/checkout-fertig.html` })
|
||||
}
|
||||
if (p === '/api/news' && req.method() === 'GET') return json(newsList)
|
||||
if (p.startsWith('/api/news/image/')) return route.fulfill({ status: 200, contentType: 'image/svg+xml', body: '<svg xmlns="http://www.w3.org/2000/svg" width="240" height="180"><rect width="240" height="180" fill="#e8b96a"/></svg>' })
|
||||
if (p === '/api/contact') return json({ name: 'WEBklar-Team', responseTime: 'Antwort meist < 1 Werktag' })
|
||||
if (p === '/api/chat/unread-count') return json({ unread: 0 })
|
||||
return json({})
|
||||
})
|
||||
}
|
||||
|
||||
let failed = 0
|
||||
const check = (name, ok, detail = '') => {
|
||||
console.log(`${ok ? 'OK ' : 'FAIL'} ${name}${detail ? ` (${detail})` : ''}`)
|
||||
if (!ok) failed += 1
|
||||
}
|
||||
const hScroll = (page) => page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)
|
||||
|
||||
const browser = await chromium.launch()
|
||||
try {
|
||||
for (const [label, viewport] of [['desktop', { width: 1280, height: 900 }], ['phone', { width: 390, height: 844 }]]) {
|
||||
const phone = label === 'phone'
|
||||
checkoutCalls.length = 0
|
||||
const ctx = await browser.newContext({ viewport, deviceScaleFactor: 1 })
|
||||
const page = await ctx.newPage()
|
||||
const errors = []
|
||||
page.on('pageerror', (e) => errors.push(e.message))
|
||||
await mockApi(page)
|
||||
const open = async (hash, sc = scenario) => {
|
||||
scenario = sc
|
||||
await page.goto('about:blank') // sonst laedt ein reiner Hash-Wechsel nicht neu
|
||||
await page.goto(`${base}/dashboard.html${hash}`)
|
||||
await page.waitForFunction(() => document.getElementById('customer-name')?.textContent)
|
||||
}
|
||||
// Einblend-Animation abwarten; Fenster auf Seitenhoehe ziehen, damit feste Leisten (Tab-Leiste) am Rand bleiben
|
||||
const shot = async (name) => {
|
||||
await page.waitForTimeout(900)
|
||||
const height = await page.evaluate(() => Math.min(document.documentElement.scrollHeight, 2600))
|
||||
await page.setViewportSize({ width: viewport.width, height: Math.max(viewport.height, height) })
|
||||
await page.waitForTimeout(150)
|
||||
await page.screenshot({ path: `${SHOTS}/${label}-${name}.png` })
|
||||
await page.setViewportSize(viewport)
|
||||
}
|
||||
const noScroll = async (name) => check(`${label} kein_horizontaler_scroll ${name}`, await hScroll(page))
|
||||
|
||||
// Projekte + Fortschritt-Sektion
|
||||
await open('')
|
||||
await page.waitForSelector('#pj-activity .pd-order-card')
|
||||
const labels = await page.$$eval('.kb-nav-item .kb-tab-label', (els) => els.map((e) => e.textContent.trim()))
|
||||
check(`${label} nav_ohne_fortschritt`, labels.join(', ') === 'Projekte, Funktionen, Rechnungen, Chat, Verträge', labels.join(', '))
|
||||
const shown = await page.$$eval('#pj-activity .pd-order-card', (els) => els.length)
|
||||
const more = await page.$('#pj-activity [data-activity-all]')
|
||||
check(`${label} projekte_drei_neueste`, shown === 3 && Boolean(more), `Karten ${shown}`)
|
||||
await noScroll('projekte')
|
||||
await shot('1-projekte')
|
||||
|
||||
await open('#fortschritt')
|
||||
await page.waitForSelector('#pj-activity .pd-order-card')
|
||||
await page.waitForTimeout(900)
|
||||
const fort = await page.evaluate(() => {
|
||||
const r = document.getElementById('pj-activity').getBoundingClientRect()
|
||||
return {
|
||||
inView: r.top < window.innerHeight && r.bottom > 0,
|
||||
projectsActive: document.getElementById('view-projects').classList.contains('active'),
|
||||
navActive: document.querySelector('.kb-nav-item.active')?.dataset.view,
|
||||
}
|
||||
})
|
||||
check(`${label} fortschritt_link`, fort.inView && fort.projectsActive && fort.navActive === 'projects', JSON.stringify(fort))
|
||||
|
||||
// Funktionen-Kacheln
|
||||
const tiles = {}
|
||||
for (const [sc, pill, btn, shotName] of [['off', 'Aus', 'Einrichten lassen · 30 €', '2-funktionen-aus'], ['setup', 'In Einrichtung', 'Status ansehen', '3-funktionen-einrichtung'], ['active', 'Aktiv', 'Beiträge schreiben', '4-funktionen-aktiv']]) {
|
||||
await open('#funktionen', sc)
|
||||
await page.waitForSelector('[data-fn-tile="news"] .fn-pill')
|
||||
const got = await page.evaluate(() => ({
|
||||
pill: document.querySelector('[data-fn-tile="news"] .fn-pill').textContent.trim(),
|
||||
btn: document.querySelector('[data-fn-tile="news"] .fn-tile-cta').textContent.trim(),
|
||||
soon: [...document.querySelectorAll('.fn-tile:not([data-fn-tile="news"]) .fn-pill')].map((e) => e.textContent.trim()),
|
||||
}))
|
||||
tiles[sc] = got
|
||||
check(`${label} kacheln ${sc}`, got.pill === pill && got.btn === btn && got.soon.length === 2 && got.soon.every((t) => t === 'Kommt bald'), JSON.stringify(got))
|
||||
await noScroll(`funktionen-${sc}`)
|
||||
await shot(shotName)
|
||||
}
|
||||
await open('#funktionen', 'multi')
|
||||
await page.waitForSelector('[data-fn-tile="news"] .fn-pill')
|
||||
const multi = await page.textContent('[data-fn-tile="news"]')
|
||||
check(`${label} kachel_mehrere_projekte`, multi.includes('aktiv für 1 von 2 Websites'))
|
||||
|
||||
// Buchung
|
||||
await open('#funktionen', 'off')
|
||||
await page.click('[data-fn-tile="news"] .fn-tile-cta')
|
||||
await page.waitForSelector('#news-order')
|
||||
const bookText = await page.textContent('#view-news')
|
||||
check(`${label} buchung_texte`, bookText.includes('meist innerhalb von 1–2 Wochen') && bookText.includes(CONSENT) && bookText.includes('Leistungsbeschreibung'))
|
||||
check(`${label} bestellen_braucht_haken`, await page.isDisabled('#news-order') && (await page.textContent('#news-order')).trim() === 'Zahlungspflichtig bestellen · 30 €')
|
||||
await noScroll('news-buchung')
|
||||
await shot('5-news-buchung')
|
||||
await page.check('#news-consent')
|
||||
check(`${label} bestellen_haken_aktiv`, !(await page.isDisabled('#news-order')))
|
||||
await page.click('#news-order')
|
||||
await page.waitForURL('**/checkout-fertig.html')
|
||||
check(`${label} bestellen_checkout`, checkoutCalls.length === 1 && checkoutCalls[0].projectId === 'p1' && checkoutCalls[0].consent === true, JSON.stringify(checkoutCalls))
|
||||
|
||||
// Rueckkehr von Stripe
|
||||
await open('#news?bestellt=1', 'setup')
|
||||
await page.waitForSelector('#view-news .fn-status')
|
||||
const back = await page.textContent('#view-news')
|
||||
check(`${label} bestellt_hinweis`, back.includes('Danke! Sobald die Zahlung da ist, legen wir los.') && back.includes('1234') && back.includes('Rechnung ansehen') && back.includes('Frage dazu? Chat öffnen'))
|
||||
check(`${label} bestellt_router`, await page.evaluate(() => document.getElementById('view-news').classList.contains('active')))
|
||||
|
||||
// Editor
|
||||
await open('#news', 'active')
|
||||
await page.waitForSelector('#nw-title')
|
||||
check(`${label} editor_dauer_vorauswahl`, (await page.inputValue('#nw-duration')) === '1m')
|
||||
await page.fill('#nw-title', 'Neue Winterjacken')
|
||||
await page.waitForTimeout(50)
|
||||
const prev = await page.textContent('#nw-preview')
|
||||
check(`${label} editor_vorschau_live`, prev.includes('Neue Winterjacken'))
|
||||
await page.click('[data-type="note"]')
|
||||
check(`${label} editor_text_ohne_foto`, !(await page.isVisible('#nw-photo-field')))
|
||||
await page.click('[data-type="combo"]')
|
||||
check(`${label} editor_foto_sichtbar`, await page.isVisible('#nw-photo-field'))
|
||||
const accepts = await page.$$eval('#nw-photo-field input[type=file]', (els) => els.map((e) => `${e.accept}|${e.getAttribute('capture') || ''}`))
|
||||
check(`${label} editor_foto_accept`, accepts.length >= 1 && accepts.every((a) => a.startsWith('image/jpeg,image/png,image/webp|')) && accepts.filter((a) => a.endsWith('|environment')).length === 1, accepts.join(' ; '))
|
||||
const lists = await page.textContent('#view-news')
|
||||
check(`${label} editor_listen`, lists.includes('Hängt gerade') && lists.includes('Abgelaufen') && lists.includes('Freitag geschlossen') && lists.includes('Auf meiner Website ansehen'))
|
||||
await page.click('[data-del]')
|
||||
check(`${label} editor_loeschen_rueckfrage`, await page.isVisible('.nw-confirm'))
|
||||
await page.click('.nw-confirm [data-cancel]')
|
||||
await noScroll('editor')
|
||||
await shot('6-news-editor')
|
||||
|
||||
if (phone) {
|
||||
await open('#funktionen', 'active')
|
||||
await page.click('[data-fn-tile="news"] .fn-tile-cta')
|
||||
await page.waitForSelector('#nw-title')
|
||||
const back = await page.evaluate(() => {
|
||||
const b = document.getElementById('kb-back')
|
||||
return { hidden: b.classList.contains('hidden'), label: b.getAttribute('aria-label'), tab: document.querySelector('.kb-nav-item.active')?.dataset.view }
|
||||
})
|
||||
check('phone editor_handy_unterseite', !back.hidden && back.label === 'Zurück zu Funktionen' && back.tab === 'funktionen', JSON.stringify(back))
|
||||
}
|
||||
check(`${label} keine_seitenfehler`, errors.length === 0, errors.join(' | '))
|
||||
await ctx.close()
|
||||
}
|
||||
} finally {
|
||||
await browser.close()
|
||||
server.close()
|
||||
}
|
||||
console.log(failed ? `${failed} Check(s) fehlgeschlagen` : 'Alle Checks bestanden')
|
||||
process.exit(failed ? 1 : 0)
|
||||
Reference in new issue
Block a user