diff --git a/public/neu/routen.js b/public/neu/routen.js new file mode 100644 index 0000000..889ed09 --- /dev/null +++ b/public/neu/routen.js @@ -0,0 +1,108 @@ +/* Kundenansicht neu: Routentabelle. Reine Logik ohne DOM, in Node importierbar. + Alle alten Hashes bleiben gültig (VIEW_HASHES, Aliase #wartung, #abo, #fortschritt); die Query nach ? bleibt Zeichen für Zeichen erhalten. + Neue Hash-Namen nur aus [a-z] wegen der Login-Whitelist /^\/dashboard\.html(#[a-z]+)?$/. */ + +export const BEREICHE = ['website', 'funktionen', 'chat', 'vertraege'] +export const FUNKTIONS_ETAGEN = ['news', 'termine', 'webshop', 'buchungen', 'zeiten', 'analyse'] + +const FELDER = ['bereich', 'reiter', 'etage', 'produkt', 'scroll'] + +// Name (Hash ohne #) → Ziel ohne Query. Je Ziel steht die kanonische Schreibweise zuerst (abo vor wartung). +// vertraege hat bewusst keinen Reiter: nur dann wählt die Ansicht den Standardreiter; abo und wartung sind die ausdrückliche Wahl. +export const ROUTEN = Object.freeze(Object.fromEntries(Object.entries({ + '': { bereich: 'website' }, + projekt: { bereich: 'website', etage: 'projekt' }, + fortschritt: { bereich: 'website', scroll: 'aktivitaet' }, + passwort: { bereich: 'website', etage: 'passwort' }, + funktionen: { bereich: 'funktionen', reiter: 'meine' }, + entdecken: { bereich: 'funktionen', reiter: 'entdecken' }, + news: { bereich: 'funktionen', etage: 'news' }, + termine: { bereich: 'funktionen', etage: 'termine' }, + webshop: { bereich: 'funktionen', etage: 'webshop' }, + buchungen: { bereich: 'funktionen', etage: 'buchungen' }, + zeiten: { bereich: 'funktionen', etage: 'zeiten' }, + analyse: { bereich: 'funktionen', etage: 'analyse' }, + chat: { bereich: 'chat' }, + vertraege: { bereich: 'vertraege' }, + abo: { bereich: 'vertraege', reiter: 'abo' }, + wartung: { bereich: 'vertraege', reiter: 'abo' }, + rechnungen: { bereich: 'vertraege', reiter: 'rechnungen' }, + stunden: { bereich: 'vertraege', reiter: 'stunden' }, + speicher: { bereich: 'vertraege', reiter: 'email' }, + gutschrift: { bereich: 'vertraege', etage: 'gutschrift' }, + wechsel: { bereich: 'vertraege', etage: 'wechsel' }, + 'bestellen-hosting': { bereich: 'vertraege', etage: 'bestellen', produkt: 'hosting' }, + 'bestellen-wartung': { bereich: 'vertraege', etage: 'bestellen', produkt: 'wartung' }, +}).map(([name, ziel]) => [name, Object.freeze(ziel)]))) + +// Hash eines Bereichs, wenn kein Eintrag der Tabelle genau passt +const BEREICHS_HASH = { website: '', funktionen: 'funktionen', chat: 'chat', vertraege: 'vertraege' } + +const TITEL_BEREICH = { website: 'Website', funktionen: 'Funktionen', chat: 'Chat', vertraege: 'Verträge' } +const TITEL_ETAGE = { + projekt: 'Projekt', news: 'News', termine: 'Terminplaner', webshop: 'Webshop', buchungen: 'Buchungskalender', zeiten: 'Öffnungszeiten', + analyse: 'Analyse', gutschrift: 'Übernahmegutschrift', wechsel: 'Abo wechseln', bestellen: 'Abo bestellen', passwort: 'Passwort ändern', +} + +// Alter data-view-Name (VIEW_HASHES, app.js:1933) → Hash-Name +const ANSICHT_ZU_HASH = { + projects: '', funktionen: 'funktionen', news: 'news', analyse: 'analyse', termine: 'termine', invoices: 'rechnungen', chat: 'chat', + vertraege: 'vertraege', stunden: 'stunden', 'bestellen-hosting': 'bestellen-hosting', 'bestellen-wartung': 'bestellen-wartung', + wechsel: 'wechsel', takeover: 'gutschrift', projekt: 'projekt', passwort: 'passwort', webshop: 'webshop', buchungen: 'buchungen', zeiten: 'zeiten', +} + +const gleich = (a, b) => FELDER.every((feld) => (a[feld] ?? '') === (b[feld] ?? '')) +const mitQuery = (name, query) => `#${name}${query ? `?${query}` : ''}` + +/** '#termine?bestellt=1' → { bereich: 'funktionen', etage: 'termine', query: 'bestellt=1' }. Unbekannte Namen ergeben die Website. */ +export function routeAusHash(hash) { + const roh = typeof hash === 'string' ? hash.replace(/^#/, '') : '' + const frage = roh.indexOf('?') + const name = (frage === -1 ? roh : roh.slice(0, frage)).toLowerCase() + const query = frage === -1 ? '' : roh.slice(frage + 1) + if (!Object.hasOwn(ROUTEN, name)) return { ...ROUTEN[''], query: '' } + return { ...ROUTEN[name], query } +} + +/** Umkehrung von routeAusHash in der kanonischen Schreibweise (#wartung wird #abo, die Website ohne Query ist '#'). */ +export function hashFuerRoute(route) { + const query = typeof route?.query === 'string' ? route.query : '' + const treffer = Object.entries(ROUTEN).find(([, ziel]) => gleich(ziel, route ?? {})) + const name = treffer ? treffer[0] : (BEREICHS_HASH[route?.bereich] ?? '') + return mitQuery(name, query) +} + +/** Brücke für die gemeinsamen Module: alter data-view-Name → Hash, unbekannter Name → null. */ +export function hashFuerAnsicht(name) { + return Object.hasOwn(ANSICHT_ZU_HASH, name) ? `#${ANSICHT_ZU_HASH[name]}` : null +} + +/** Hash beim Start der Seite. ?abo=success|cancel (Rückkehr von Stripe) öffnet den Reiter Abo; die Query bleibt in location.search. */ +export function startHash({ search = '', hash = '' } = {}) { + const abo = new URLSearchParams(search).get('abo') + if (abo === 'success' || abo === 'cancel') return '#abo' + return hashFuerRoute(routeAusHash(hash)) +} + +/** Hashes von der Website bis zum Ziel (für den Eltern-Eintrag bei Einstieg per Link). */ +export function ankerKette(route) { + const ziel = hashFuerRoute(route) + if (route.bereich === 'website') return route.etage ? ['#', ziel] : [ziel] + if (!route.etage) return ['#', ziel] + return ['#', hashFuerRoute({ ...ROUTEN[BEREICHS_HASH[route.bereich]], query: '' }), ziel] +} + +/** Elternroute einer Etage: derselbe Bereich, bei passwort die Herkunft (sonst die Website). Ohne Etage null. */ +export function elternVon(route, herkunft = '') { + if (!route.etage) return null + if (route.etage === 'passwort') { + const vorher = routeAusHash(herkunft) + return vorher.etage === 'passwort' ? { ...ROUTEN[''], query: '' } : vorher + } + return { ...ROUTEN[BEREICHS_HASH[route.bereich]], query: '' } +} + +/** Titel für Kopfzeile und Browser: die Etage, sonst der Bereich. */ +export function titelFuer(route) { + return TITEL_ETAGE[route.etage] ?? TITEL_BEREICH[route.bereich] ?? '' +} diff --git a/test/knHuelleRouten.test.js b/test/knHuelleRouten.test.js new file mode 100644 index 0000000..f162ccc --- /dev/null +++ b/test/knHuelleRouten.test.js @@ -0,0 +1,61 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { BEREICHE, ROUTEN, routeAusHash, hashFuerRoute, hashFuerAnsicht, startHash, ankerKette, elternVon, titelFuer } from '../public/neu/routen.js' + +const FAELLE = [ // Alte Links (Review Focus 1) + ['', { bereich: 'website', query: '' }], + ['#termine?bestellt=1&projekt=x', { bereich: 'funktionen', etage: 'termine', query: 'bestellt=1&projekt=x' }], + ['#webshop?stripe=zurueck&projekt=x', { bereich: 'funktionen', etage: 'webshop', query: 'stripe=zurueck&projekt=x' }], + ['#buchungen?stripe=neu&projekt=x', { bereich: 'funktionen', etage: 'buchungen', query: 'stripe=neu&projekt=x' }], + ['#news?bestellt=1', { bereich: 'funktionen', etage: 'news', query: 'bestellt=1' }], + ['#funktionen', { bereich: 'funktionen', reiter: 'meine', query: '' }], ['#entdecken', { bereich: 'funktionen', reiter: 'entdecken', query: '' }], + ['#rechnungen', { bereich: 'vertraege', reiter: 'rechnungen', query: '' }], ['#stunden', { bereich: 'vertraege', reiter: 'stunden', query: '' }], + ['#speicher', { bereich: 'vertraege', reiter: 'email', query: '' }], ['#gutschrift', { bereich: 'vertraege', etage: 'gutschrift', query: '' }], + ['#wartung', { bereich: 'vertraege', reiter: 'abo', query: '' }], ['#abo', { bereich: 'vertraege', reiter: 'abo', query: '' }], ['#vertraege', { bereich: 'vertraege', query: '' }], // #vertraege ohne Reiter: die Ansicht wählt den Standardreiter + ['#fortschritt', { bereich: 'website', scroll: 'aktivitaet', query: '' }], ['#projekt?id=p1', { bereich: 'website', etage: 'projekt', query: 'id=p1' }], + ['#Chat', { bereich: 'chat', query: '' }], ['#passwort', { bereich: 'website', etage: 'passwort', query: '' }], + ['#bestellen-wartung', { bereich: 'vertraege', etage: 'bestellen', produkt: 'wartung', query: '' }], + ['#bestellen-hosting', { bereich: 'vertraege', etage: 'bestellen', produkt: 'hosting', query: '' }], + ['#gibtesnicht', { bereich: 'website', query: '' }], +] + +test('Alte Links: Ziel und Query stimmen (Review Focus 1), V2-Beispiel', () => { + for (const [hash, soll] of FAELLE) assert.deepEqual(routeAusHash(hash), soll, hash) + assert.deepEqual(routeAusHash('#termine?bestellt=1'), { bereich: 'funktionen', etage: 'termine', query: 'bestellt=1' }) +}) + +test('hashFuerRoute ist die Umkehrung: Query bleibt, #wartung wird #abo, #vertraege und #abo fallen nicht zusammen; Hash-Namen nur [a-z]', () => { + const kanon = { '#wartung': '#abo', '#Chat': '#chat', '#gibtesnicht': '#', '': '#' } + for (const [hash] of FAELLE) assert.equal(hashFuerRoute(routeAusHash(hash)), kanon[hash] ?? hash, hash) + assert.deepEqual(['#vertraege', '#abo', '#wartung'].map((hash) => hashFuerRoute(routeAusHash(hash))), ['#vertraege', '#abo', '#abo']) // Review Focus 1: Reiterklick auf Abo darf nie #vertraege schreiben + assert.equal('reiter' in routeAusHash('#vertraege'), false); assert.equal(routeAusHash('#abo').reiter, 'abo') + assert.deepEqual(BEREICHE, ['website', 'funktionen', 'chat', 'vertraege']) + for (const k of Object.keys(ROUTEN).filter((k) => !k.startsWith('bestellen-'))) assert.match(k, /^[a-z]*$/, k) // Login-Whitelist app.js:466 +}) + +test('data-view-Brücke: jeder Name aus VIEW_HASHES (app.js:1933 wörtlich) führt zum gleichen Hash', () => { + const ALT = { projects: '', funktionen: 'funktionen', news: 'news', analyse: 'analyse', termine: 'termine', invoices: 'rechnungen', chat: 'chat', vertraege: 'vertraege', stunden: 'stunden', 'bestellen-hosting': 'bestellen-hosting', 'bestellen-wartung': 'bestellen-wartung', wechsel: 'wechsel', takeover: 'gutschrift', projekt: 'projekt', passwort: 'passwort', webshop: 'webshop', buchungen: 'buchungen', zeiten: 'zeiten' } + for (const [name, hash] of Object.entries(ALT)) assert.equal(hashFuerAnsicht(name), `#${hash}`, name) + assert.equal(hashFuerAnsicht('unbekannt'), null) +}) + +test('startHash: ?abo= führt zum Reiter Abo (die Query bleibt in search), #wartung wird #abo, #vertraege bleibt, sonstige Query bleibt', () => { + assert.equal(startHash({ search: '?abo=success', hash: '' }), '#abo') + assert.equal(startHash({ search: '?abo=cancel', hash: '#chat' }), '#abo') + assert.equal(startHash({ search: '', hash: '#wartung' }), '#abo') + assert.equal(startHash({ search: '', hash: '#vertraege' }), '#vertraege') + assert.equal(startHash({ search: '?x=1', hash: '#termine?bestellt=1&projekt=p1' }), '#termine?bestellt=1&projekt=p1') + assert.equal(startHash({ search: '', hash: '' }), '#') +}) + +test('ankerKette, elternVon, titelFuer', () => { + const r = routeAusHash + assert.deepEqual(ankerKette(r('#termine?bestellt=1')), ['#', '#funktionen', '#termine?bestellt=1']) + assert.deepEqual(ankerKette(r('#chat')), ['#', '#chat']); assert.deepEqual(ankerKette(r('#gutschrift')), ['#', '#vertraege', '#gutschrift']) + assert.deepEqual(ankerKette(r('#abo')), ['#', '#abo']); assert.deepEqual(ankerKette(r('#vertraege')), ['#', '#vertraege']) + assert.deepEqual(ankerKette(r('#passwort')), ['#', '#passwort']); assert.deepEqual(ankerKette(r('#fortschritt')), ['#fortschritt']) + assert.deepEqual(elternVon(r('#termine')), { bereich: 'funktionen', reiter: 'meine', query: '' }) + assert.deepEqual(elternVon(r('#gutschrift')), { bereich: 'vertraege', query: '' }) // Bereichs-Anker #vertraege, kein Reiter + assert.deepEqual(elternVon(r('#passwort'), '#chat'), { bereich: 'chat', query: '' }); assert.equal(elternVon(r('#passwort')).bereich, 'website'); assert.equal(elternVon(r('#chat')), null) + assert.deepEqual(['#termine', '#zeiten', '#bestellen-wartung', '#passwort', '#chat'].map((h) => titelFuer(r(h))), ['Terminplaner', 'Öffnungszeiten', 'Abo bestellen', 'Passwort ändern', 'Chat']) +})