public/embed/termine.js: Buchungs-Widget fuer Kundenwebsites (inline und Knopf mit dialog, Monatsraster nach APG, Haken-Texte und Knopf aus der Konfiguration, Honigtopf kontakt_url, Widerrufsfunktion, Konfiguration nach 100 min neu, Ausweichtext bei Fehlern). Rechnet nie mit Datum oder Zeitzone des Browsers. Dazu Demo-Seite, Einbauanleitung fuer Mitarbeiter, statischer Test und Playwright-Skript mit wiederverwendbarem UI-Testserver (test/ui/termineTestServer.mjs). Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
270 lines
13 KiB
JavaScript
270 lines
13 KiB
JavaScript
// UI-Testserver des Terminplaners (gemeinsam fuer Playwright-Skripte der Aufgaben 19 bis 21): die echten Express-Router
|
|
// (/api/public/termine/v1, /api/termine, /api/funktionen) und public/ als statische Dateien, dahinter der Nachbau von Appwrite
|
|
// und Stripe aus test/support/ und eine Mail-Senke. Nichts verlaesst den Rechner (useTestEnv erlaubt nur 127.0.0.1).
|
|
//
|
|
// Aufruf in einem Skript (KEINE Server-Module vorher importieren, config.js liest die Umgebung beim ersten Import):
|
|
// import { startTermineUiServer } from './termineTestServer.mjs'
|
|
// const srv = await startTermineUiServer()
|
|
// ... srv.url, srv.pageOrigin, srv.SITE_KEY, srv.sink.sent, srv.buchungen(), srv.speichere(...) ...
|
|
// await srv.stop() // erst den Browser schliessen, dann den Server
|
|
//
|
|
// Uhr: Die Server-Uhr beginnt bei jetzt (Vorgabe Montag 2026-10-05 10:00 Berliner Zeit) und laeuft dann in Echtzeit weiter,
|
|
// damit Tage und Uhrzeiten in Screenshots gleich bleiben und Zwischenspeicher, Haltezeiten und Formular-Token trotzdem
|
|
// ablaufen. srv.schiebeUhr(ms) verschiebt sie (z. B. +3 h: Formular-Token abgelaufen), srv.setzeUhr(date) setzt sie neu.
|
|
//
|
|
// Seitenherkunft: srv.url ist http://127.0.0.1:PORT, srv.pageOrigin ist http://localhost:PORT (derselbe Server, anderer Host).
|
|
// Mit { crossOrigin: true } gilt pageOrigin als Herkunft des Portals (config.publicBaseUrl): Seiten von pageOrigin mit
|
|
// data-api=srv.url sind dann echte Cross-Origin-Anfragen (Preflight fuer POST), die der Server erlaubt.
|
|
import path from 'node:path'
|
|
import { fileURLToPath } from 'node:url'
|
|
import express from 'express'
|
|
import { startAppwriteMock } from '../support/appwriteMock.js'
|
|
import { startFakeStripe } from '../support/fakeStripe.js'
|
|
import { mailSink } from '../support/mailSink.js'
|
|
import { schemaKeys } from '../support/schema.js'
|
|
import { useTestEnv } from '../support/testEnv.js'
|
|
|
|
const PUBLIC_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), '../../public')
|
|
|
|
export const SITE_KEY = 'abcdefghijklmnop'
|
|
export const KUNDE = 'c1'
|
|
export const PROJEKT = 'p1'
|
|
export const FEATURE = 'f_p1_termine'
|
|
export const JETZT = '2026-10-05T08:00:00.000Z' // Montag, 10:00 Berlin (MESZ)
|
|
|
|
// Feste IDs (aus den Mustern r_ + 8 und s_ + 8), damit Skripte sie in Seiten schreiben koennen (data-leistung)
|
|
export const R_ANNA = 'r_anna0001'
|
|
export const R_BEN = 'r_ben00002'
|
|
export const S_HERREN = 's_herren01'
|
|
export const S_DAMEN = 's_damen001'
|
|
export const S_KINDER = 's_kinder01'
|
|
export const S_FAERBEN = 's_faerben1'
|
|
export const S_KENNEN = 's_kennen01' // ohne Preis, sofort: Knopf "Termin buchen"
|
|
export const S_ANFRAGE = 's_anfrage1' // ohne Preis, Anfrage: Knopf "Termin anfragen"
|
|
|
|
/**
|
|
* Eingabe fuer normalisiereEinstellung: Salon (Leistungen der Vorlage "salon", zwei Personen), Mo bis Fr 09:00 bis 12:30 und
|
|
* 13:30 bis 18:00, Sa 09:00 bis 13:00, So zu; keine Feiertage, kein Vorlauf, 10 offene Termine je E-Mail erlaubt.
|
|
*/
|
|
export function salonEingabe(ueberschreibe = {}) {
|
|
const woche = { 1: [['09:00', '12:30'], ['13:30', '18:00']], 6: [['09:00', '13:00']] }
|
|
for (const tag of [2, 3, 4, 5]) woche[tag] = woche[1]
|
|
const ressourcen = [R_ANNA, R_BEN]
|
|
const leistung = (id, name, dauer, pufferNach, preisArt, preisCent, buchungsart, beschreibung = '') => ({
|
|
id, name, beschreibung, dauer, pufferNach, preisArt, preisCent, buchungsart, ressourcen,
|
|
})
|
|
return {
|
|
vorlage: 'salon',
|
|
betrieb: {
|
|
name: 'Salon Müller',
|
|
email: 'info@salon-mueller.example',
|
|
telefon: '06301 1234',
|
|
adresse: 'Hauptstr. 1, 67722 Winnweiler',
|
|
kleinunternehmer: false,
|
|
websiteUrl: 'https://salon-mueller.example',
|
|
impressumUrl: 'https://salon-mueller.example/impressum',
|
|
datenschutzUrl: 'https://salon-mueller.example/datenschutz',
|
|
},
|
|
anrede: 'sie',
|
|
farbe: '#0f766e',
|
|
bundesland: 'RP',
|
|
feiertageGeschlossen: false,
|
|
ressourcen: [
|
|
{ id: R_ANNA, name: 'Anna', typ: 'person', plan: woche },
|
|
{ id: R_BEN, name: 'Ben', typ: 'person', plan: woche },
|
|
],
|
|
leistungen: [
|
|
leistung(S_HERREN, 'Herrenschnitt', 30, 5, 'fest', 2800, 'sofort', 'Waschen, Schneiden und Föhnen.'),
|
|
leistung(S_DAMEN, 'Damenschnitt', 45, 5, 'ab', 4500, 'sofort', 'Mit Beratung zur Frisur.'),
|
|
leistung(S_KINDER, 'Kinderschnitt', 30, 5, 'fest', 1800, 'sofort'),
|
|
leistung(S_FAERBEN, 'Färben', 120, 10, 'ab', 7500, 'sofort'),
|
|
leistung(S_KENNEN, 'Kennenlern-Beratung', 15, 0, 'keiner', 0, 'sofort', 'Kostenlos und unverbindlich.'),
|
|
leistung(S_ANFRAGE, 'Brautfrisur', 90, 15, 'keiner', 0, 'anfrage', 'Wir melden uns und bestätigen den Termin.'),
|
|
],
|
|
regeln: { raster: 15, vorlaufMin: 0, horizontTage: 60, absageBisStd: 24, maxOffeneJeEmail: 10, personWaehlbar: true },
|
|
gastFelder: { telefon: 'optional', nachricht: 'optional', zusatz: [] },
|
|
erinnerung: { anbieten: true, stundenVorher: 24 },
|
|
storno: { text: 'Sie können Ihren Termin bis 24 Stunden vorher kostenlos absagen oder verschieben. Danach berechnen wir 50 % des Preises.' },
|
|
recht: { gaesteSind: 'verbraucher', freizeit: false },
|
|
zahlung: { modus: 'vorort', vorOrtArten: ['bar', 'ec', 'kreditkarte'] },
|
|
...ueberschreibe,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Startet Nachbauten, Router und Seiten.
|
|
* @param {object} [opt]
|
|
* @param {string|Date} [opt.jetzt] Startzeit der Server-Uhr
|
|
* @param {boolean} [opt.crossOrigin] pageOrigin (localhost) gilt als Herkunft des Portals
|
|
* @param {(eingabe: object) => object} [opt.anpassen] aendert die Eingabe der Einstellung vor dem Speichern
|
|
*/
|
|
export async function startTermineUiServer({ jetzt = JETZT, crossOrigin = false, anpassen = (e) => e } = {}) {
|
|
const { TERMINE_DATETIMES } = await import('../../server/services/termine/schema.js')
|
|
const aw = await startAppwriteMock({
|
|
schema: {
|
|
portalFeatures: schemaKeys('portalFeatures'),
|
|
termineConfig: schemaKeys('termineConfig'),
|
|
termineBookings: schemaKeys('termineBookings'),
|
|
termineClaims: schemaKeys('termineClaims'),
|
|
termineOutbox: schemaKeys('termineOutbox'),
|
|
customerViewLog: schemaKeys('customerViewLog'),
|
|
},
|
|
datetimes: TERMINE_DATETIMES,
|
|
defaultLimit: 25,
|
|
})
|
|
const st = await startFakeStripe()
|
|
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
|
|
|
|
const { config } = await import('../../server/config.js')
|
|
const { startTestApp } = await import('../support/testApp.js')
|
|
const { setMailTransportForTests } = await import('../../server/services/mailer.js')
|
|
const { deleteDocument } = await import('../../server/services/appwriteAdmin.js')
|
|
const { default: publicTermineRoutes, setzeUhrFuerTests: setzeUhrPublic, resetPublicTermineForTests } = await import('../../server/routes/publicTermine.js')
|
|
const { default: termineRoutes, setzeUhrFuerTests: setzeUhrKunde, resetTermineRoutesForTests } = await import('../../server/routes/termine.js')
|
|
const { default: funktionenRoutes } = await import('../../server/routes/funktionen.js')
|
|
const { normalisiereEinstellung } = await import('../../server/services/termine/einstellungen.js')
|
|
const { speichereEinstellung, resetKontextForTests } = await import('../../server/services/termine/kontext.js')
|
|
const { resetOutboxForTests, warteAufVersand } = await import('../../server/services/termine/outbox.js')
|
|
const { resetTermineLimitsForTests } = await import('../../server/services/termine/limits.js')
|
|
|
|
// Testseiten (HTML aus dem Skript) kommen vom Server selbst: Chromium lehnt Anfragen von Seiten, die Playwright per
|
|
// route.fulfill liefert, an 127.0.0.1 ab (Local Network Access); Seiten desselben Servers duerfen.
|
|
const seiten = new Map()
|
|
const seitenRouter = express.Router()
|
|
seitenRouter.get('/:name', (req, res) => {
|
|
const html = seiten.get(req.params.name)
|
|
if (html === undefined) return res.status(404).type('text').send('nicht gefunden')
|
|
return res.set('Cache-Control', 'no-store').type('text/html; charset=utf-8').send(html)
|
|
})
|
|
|
|
const app = await startTestApp({
|
|
'/api/public/termine/v1': publicTermineRoutes,
|
|
'/api/termine': termineRoutes,
|
|
'/api/funktionen': funktionenRoutes,
|
|
'/ui-seiten': seitenRouter,
|
|
'/': express.static(PUBLIC_DIR),
|
|
})
|
|
const pageOrigin = app.url.replace('127.0.0.1', 'localhost')
|
|
|
|
const startMs = new Date(jetzt).getTime()
|
|
let versatz = startMs - Date.now()
|
|
const uhr = () => new Date(Date.now() + versatz)
|
|
|
|
const vorher = {
|
|
mode: config.termine.mode,
|
|
pilotEmails: config.termine.pilotEmails,
|
|
formMinSekunden: config.termine.formMinSekunden,
|
|
minAntwortMs: config.termine.minAntwortMs,
|
|
publicBaseUrl: config.publicBaseUrl,
|
|
smtpFrom: config.smtp.from,
|
|
}
|
|
|
|
const srv = {
|
|
url: app.url,
|
|
pageOrigin,
|
|
SITE_KEY, KUNDE, PROJEKT, FEATURE,
|
|
aw, st, config,
|
|
sink: null,
|
|
uhr,
|
|
|
|
/** Testseite unter pageOrigin/ui-seiten/<name> bereitstellen; liefert die Adresse. */
|
|
seite(name, html) {
|
|
seiten.set(name, html)
|
|
return `${pageOrigin}/ui-seiten/${name}`
|
|
},
|
|
|
|
/** Zeitpunkt der Server-Uhr verschieben (ms) oder neu setzen. */
|
|
schiebeUhr(ms) { versatz += ms },
|
|
setzeUhr(datum) { versatz = new Date(datum).getTime() - Date.now() },
|
|
|
|
/** Portal-Funktion des Termin-Features (portalFeatures-Dokument). */
|
|
featureDoc(state = 'active') {
|
|
return { $id: FEATURE, customerId: KUNDE, projectId: PROJEKT, featureKey: 'termine', state, enabled: state === 'active', siteKey: SITE_KEY, activatedAt: '2026-10-01T09:00:00.000Z' }
|
|
},
|
|
|
|
/** Zustand der Funktion aendern ('active' | 'disabled' | 'off'); der Kontext-Speicher des Servers wird geleert. */
|
|
async setzeFunktion(state) {
|
|
aw.seed('portalFeatures', srv.featureDoc(state))
|
|
resetKontextForTests()
|
|
},
|
|
|
|
/** Einstellung speichern: Eingabe fuer normalisiereEinstellung (z. B. salonEingabe({ anrede: 'du' })). Liefert die normalisierte Einstellung. */
|
|
async speichere(eingabe = salonEingabe()) {
|
|
const erg = normalisiereEinstellung(eingabe, { now: uhr() })
|
|
if (erg.fehler) throw new Error(`Einstellung ungueltig: ${JSON.stringify(erg.fehler)}`)
|
|
resetKontextForTests()
|
|
const doc = aw.docs('termineConfig').get(FEATURE)
|
|
await speichereEinstellung(srv.featureDoc(), erg.einstellung, { version: doc ? doc.version : 0, now: uhr() })
|
|
return erg.einstellung
|
|
},
|
|
|
|
/** Gespeicherte Einstellung (Objekt) oder null. */
|
|
einstellung() {
|
|
const doc = aw.docs('termineConfig').get(FEATURE)
|
|
return doc ? JSON.parse(doc.einstellung) : null
|
|
},
|
|
|
|
/** Alle Buchungen (Appwrite-Dokumente), alle Mails der Senke (nach warteAufVersand(): die Warteschlange sendet im Hintergrund). */
|
|
buchungen() { return [...aw.docs('termineBookings').values()] },
|
|
mails() { return srv.sink.sent },
|
|
warteAufVersand,
|
|
|
|
/** Nur Grenzen und Zwischenspeicher leeren (zwischen Pruefungen, damit die Grenzen je IP nicht greifen). */
|
|
limitsZuruecksetzen() {
|
|
resetTermineLimitsForTests()
|
|
resetKontextForTests()
|
|
},
|
|
|
|
/** Alles auf Anfang: Dokumente loeschen, Zaehler und Zwischenspeicher leeren, neue Senke, Einstellung und Funktion neu anlegen. */
|
|
async zuruecksetzen({ eingabe, uhrNeu = true } = {}) {
|
|
await warteAufVersand()
|
|
for (const collection of ['portalFeatures', 'termineConfig', 'termineBookings', 'termineClaims', 'termineOutbox', 'customers', 'websiteProjects']) {
|
|
for (const id of [...aw.docs(collection).keys()]) await deleteDocument(collection, id)
|
|
}
|
|
resetKontextForTests()
|
|
resetOutboxForTests()
|
|
resetTermineLimitsForTests()
|
|
resetPublicTermineForTests()
|
|
resetTermineRoutesForTests()
|
|
if (uhrNeu) versatz = startMs - Date.now()
|
|
setzeUhrPublic(uhr)
|
|
setzeUhrKunde(uhr)
|
|
config.termine.mode = 'pilot'
|
|
config.termine.pilotEmails = ['*']
|
|
config.termine.formMinSekunden = 0
|
|
config.termine.minAntwortMs = 0
|
|
config.smtp.from = 'noreply@webklar.com'
|
|
config.publicBaseUrl = crossOrigin ? pageOrigin : app.url
|
|
srv.sink = mailSink()
|
|
setMailTransportForTests(srv.sink)
|
|
aw.seed('websiteProjects', { $id: PROJEKT, customerId: KUNDE, projectName: 'Salon Müller', subdomain: 'salon', liveDomain: 'salon-mueller.example' })
|
|
aw.seed('customers', { $id: KUNDE, name: 'Silke Müller', email: 'silke@salon-mueller.example' })
|
|
aw.seed('portalFeatures', srv.featureDoc())
|
|
await srv.speichere(anpassen(eingabe ?? salonEingabe()))
|
|
},
|
|
|
|
/** Beendet Server und Nachbauten (zuerst den Browser schliessen) und stellt config wieder her. */
|
|
async stop() {
|
|
await warteAufVersand().catch(() => {})
|
|
setMailTransportForTests(null)
|
|
resetPublicTermineForTests()
|
|
resetTermineRoutesForTests()
|
|
Object.assign(config.termine, { mode: vorher.mode, pilotEmails: vorher.pilotEmails, formMinSekunden: vorher.formMinSekunden, minAntwortMs: vorher.minAntwortMs })
|
|
config.publicBaseUrl = vorher.publicBaseUrl
|
|
config.smtp.from = vorher.smtpFrom
|
|
await Promise.all([srv.beendeApp(), aw.close(), st.close()])
|
|
},
|
|
|
|
/** Nur die App (die Seiten und Schnittstellen) beenden; Appwrite-Nachbau bleibt (Test "Server gestoppt"). */
|
|
async beendeApp() {
|
|
if (srv.appBeendet) return
|
|
srv.appBeendet = true
|
|
await Promise.race([app.close(), new Promise((resolve) => setTimeout(resolve, 3000))])
|
|
},
|
|
appBeendet: false,
|
|
}
|
|
|
|
await srv.zuruecksetzen()
|
|
return srv
|
|
}
|