diff --git a/public/neu/dashboard.html b/public/neu/dashboard.html
new file mode 100644
index 0000000..b9b3707
--- /dev/null
+++ b/public/neu/dashboard.html
@@ -0,0 +1,32 @@
+
+
+
+
+
+
+ Kundenbereich – WEBklar
+
+
+
+
+ Kundenansicht neu
+ Platzhalter bis P1
+ Ansicht:
+
+
+
+
diff --git a/server/index.js b/server/index.js
index cd7bd06..7c4d3cc 100644
--- a/server/index.js
+++ b/server/index.js
@@ -60,6 +60,7 @@ import teamRoutes from './routes/team/index.js'
import legacyAdminRedirect from './routes/legacyAdminRedirect.js'
import { corsMiddleware } from './middleware/cors.js'
import { kundenansichtGuard } from './middleware/kundenansichtGuard.js'
+import { dashboardWeiche, neuDirektWeiche, oeffentlicheSeiteWeiche } from './services/kundenansicht/dashboardWeiche.js'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const publicDir = path.join(__dirname, '..', 'public')
@@ -151,6 +152,11 @@ app.use(unterkunftSeiteRouter())
// Abgeschaltetes Admin-Panel und Rechnungsanalyse: Links aus alten Team-Mails ins Ticketsystem
app.use('/admin.html', legacyAdminRedirect.admin)
app.use('/kalkulation.html', legacyAdminRedirect.kalkulation)
+// Kundenansicht neu: je Sitzung die neue oder die alte Seite (Schalter KUNDENANSICHT_NEU), vor den statischen Dateien
+app.get('/dashboard.html', dashboardWeiche({ publicDir }))
+app.get('/neu/dashboard.html', neuDirektWeiche())
+// Kuendigen und Widerrufen: im Pilot die neuen Seiten aus public/neu/oeffentlich/, sobald die Dateien dort liegen
+app.get(['/kuendigen.html', '/widerrufen.html'], oeffentlicheSeiteWeiche({ publicDir }))
app.use(express.static(publicDir))
app.get('/dashboard.html', (req, res, next) => {
diff --git a/server/services/kundenansicht/dashboardWeiche.js b/server/services/kundenansicht/dashboardWeiche.js
new file mode 100644
index 0000000..1c46086
--- /dev/null
+++ b/server/services/kundenansicht/dashboardWeiche.js
@@ -0,0 +1,56 @@
+import { stat } from 'node:fs/promises'
+import path from 'node:path'
+import { neueAnsicht } from './schalter.js'
+
+/**
+ * Weichen der neuen Kundenansicht (Schalter KUNDENANSICHT_NEU, siehe schalter.js). Handler-Fabriken, die server/index.js
+ * vor express.static einhaengt: je Sitzung die neue Seite aus public/neu/ oder next() fuer die alte (bytegleich, von
+ * express.static ausgeliefert). Die neue Seite wird nie gecacht, weil dieselbe Adresse je Sitzung etwas anderes liefert.
+ */
+
+const NEUES_DASHBOARD = 'neu/dashboard.html'
+
+// Nachtrag N39: Seiten, die schon im Pilot aus public/neu/oeffentlich/ kommen, sobald die Datei dort liegt
+const OEFFENTLICHE_SEITEN = new Map([
+ ['/kuendigen.html', 'neu/oeffentlich/kuendigen.html'],
+ ['/widerrufen.html', 'neu/oeffentlich/widerrufen.html'],
+])
+
+/** Neue Seite senden; no-cache steht vor sendFile, damit Express den Header nicht ueberschreibt. Fehler gehen an next(err). */
+function sendeNeu(res, publicDir, datei, next) {
+ res.set('Cache-Control', 'no-cache')
+ res.sendFile(datei, { root: publicDir }, (err) => {
+ if (err) next(err)
+ })
+}
+
+/** GET /dashboard.html: neue Ansicht -> public/neu/dashboard.html, sonst next(). */
+export function dashboardWeiche({ publicDir }) {
+ return (req, res, next) => {
+ if (!neueAnsicht(req)) return next()
+ sendeNeu(res, publicDir, NEUES_DASHBOARD, next)
+ }
+}
+
+/** GET /neu/dashboard.html (Direktaufruf): neue Ansicht -> next() (express.static liefert die Datei), sonst 302 auf /dashboard.html. */
+export function neuDirektWeiche() {
+ return (req, res, next) => {
+ if (neueAnsicht(req)) return next()
+ res.redirect(302, '/dashboard.html')
+ }
+}
+
+/** GET /kuendigen.html und /widerrufen.html (N39): angemeldete Pilot-Sitzung und Datei vorhanden -> neue Seite, sonst next(). */
+export function oeffentlicheSeiteWeiche({ publicDir }) {
+ return async (req, res, next) => {
+ try {
+ const datei = OEFFENTLICHE_SEITEN.get(req.path)
+ if (!datei || !neueAnsicht(req)) return next()
+ const info = await stat(path.join(publicDir, datei)).catch(() => null)
+ if (!info?.isFile()) return next()
+ sendeNeu(res, publicDir, datei, next)
+ } catch (err) {
+ next(err)
+ }
+ }
+}
diff --git a/test/kundenansichtDashboard.test.js b/test/kundenansichtDashboard.test.js
new file mode 100644
index 0000000..814e102
--- /dev/null
+++ b/test/kundenansichtDashboard.test.js
@@ -0,0 +1,127 @@
+import { test, after } from 'node:test'
+import assert from 'node:assert/strict'
+import express from 'express'
+import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
+import { fileURLToPath } from 'node:url'
+import os from 'node:os'
+import path from 'node:path'
+import { startAppwriteMock } from './support/appwriteMock.js'
+import { startFakeStripe } from './support/fakeStripe.js'
+import { useTestEnv } from './support/testEnv.js'
+import { schemaKeys } from './support/schema.js'
+
+// Dashboard-Weiche: je Sitzung die neue Platzhalter-Seite oder bytegleich die alte (Schalter KUNDENANSICHT_NEU)
+const aw = await startAppwriteMock({ schema: { customerViewLog: schemaKeys('customerViewLog') } })
+const st = await startFakeStripe()
+useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
+const { config } = await import('../server/config.js')
+const { startTestApp, rawGet } = await import('./support/testApp.js')
+const { customerCookie, kundenansichtCookie, adminCookie, viewAsCookie } = await import('./support/fixtures.js')
+const { dashboardWeiche, neuDirektWeiche, oeffentlicheSeiteWeiche } = await import('../server/services/kundenansicht/dashboardWeiche.js')
+const publicDir = fileURLToPath(new URL('../public/', import.meta.url))
+const router = express.Router() // gleiche Reihenfolge wie server/index.js
+router.get('/dashboard.html', dashboardWeiche({ publicDir }))
+router.get('/neu/dashboard.html', neuDirektWeiche())
+router.get(['/kuendigen.html', '/widerrufen.html'], oeffentlicheSeiteWeiche({ publicDir }))
+router.use(express.static(publicDir))
+// Nachtrag N39: dieselbe Weiche mit einem Nachbau von public/ (Datei fuer Datei erfunden), weil public/neu/oeffentlich/ erst mit P2 entsteht
+const nachbau = mkdtempSync(path.join(os.tmpdir(), 'kn-weiche-'))
+mkdirSync(path.join(nachbau, 'neu', 'oeffentlich'), { recursive: true })
+writeFileSync(path.join(nachbau, 'neu', 'oeffentlich', 'kuendigen.html'), 'Nachbau')
+const nachbauRouter = express.Router()
+nachbauRouter.get(['/kuendigen.html', '/widerrufen.html'], oeffentlicheSeiteWeiche({ publicDir: nachbau }))
+nachbauRouter.use(express.static(publicDir))
+const app = await startTestApp({ '/nachbau': nachbauRouter, '/': router })
+after(() => {
+ if (nachbau.startsWith(path.join(os.tmpdir(), 'kn-weiche-'))) rmSync(nachbau, { recursive: true, force: true })
+ return Promise.all([app.close(), aw.close(), st.close()])
+})
+const stelle = (mode, pilotEmails = []) => Object.assign(config.kundenansichtNeu, { mode, pilotEmails })
+const ALT = readFileSync(path.join(publicDir, 'dashboard.html'), 'utf8')
+const istNeu = (r) => r.status === 200 && String(r.body).includes('') // N5: gepinnt wird nur die Meta-Zeile
+const istAlt = (r) => r.status === 200 && r.body === ALT
+const MAIL = 'k@example.invalid' // E-Mail von c1; c2 hat k2@example.invalid
+const UNSIGNIERT = `${config.cookieName}=${encodeURIComponent(JSON.stringify({ customerId: 'c1', appwriteUserId: 'u1', email: MAIL }))}`
+
+const FAELLE = [ // [Name, Modus, Liste, Cookie, Ergebnis]
+ ['Pilot, Kunde auf der Liste', 'pilot', [MAIL], customerCookie('c1'), 'neu'],
+ ['Pilot, Kunde daneben', 'pilot', [MAIL], customerCookie('c2'), 'alt'],
+ ['Pilot, Mitarbeiter-Kundenansicht', 'pilot', [], kundenansichtCookie(), 'neu'],
+ ['off mit Stern, Kunde', 'off', ['*'], customerCookie('c1'), 'alt'],
+ ['off mit Stern, Kundenansicht', 'off', ['*'], kundenansichtCookie(), 'alt'],
+ ['on, Kunde', 'on', [], customerCookie('c1'), 'neu'],
+ ['on, alte Mitarbeiter-Sitzung', 'on', [], adminCookie(), 'alt'],
+ ['on, Kundenansicht ohne viewId', 'on', [], viewAsCookie('c1'), 'alt'],
+ ['Pilot mit Stern, ohne Sitzung (200 wie heute, kein Redirect)', 'pilot', ['*'], undefined, 'alt'],
+ ['Pilot mit Stern, unsigniertes Cookie', 'pilot', ['*'], UNSIGNIERT, 'alt'],
+]
+for (const [name, modus, liste, cookie, soll] of FAELLE) test(name, async () => {
+ stelle(modus, liste)
+ const r = await rawGet('/dashboard.html', { cookie })
+ assert.ok(soll === 'neu' ? istNeu(r) : istAlt(r), `${r.status} ${String(r.body).slice(0, 80)}`)
+})
+
+test('neue Seite: HTML und Cache-Control no-cache; Query bleibt erhalten', async () => {
+ stelle('pilot', [MAIL])
+ const r = await rawGet('/dashboard.html?abo=success', { cookie: customerCookie('c1') })
+ assert.ok(istNeu(r)); assert.equal(r.headers.get('cache-control'), 'no-cache'); assert.match(r.headers.get('content-type'), /^text\/html/)
+})
+test('Platzhalter trägt beide Marker (N5) und die Hülle des Briefs', () => {
+ const html = readFileSync(path.join(publicDir, 'neu', 'dashboard.html'), 'utf8')
+ for (const teil of ['', '', 'data-kn-platzhalter', 'id="kn-platzhalter-ansicht"', 'Kundenansicht neu', 'Platzhalter bis P1', '/api/auth/me']) assert.ok(html.includes(teil), teil)
+ assert.ok(!/