/** * Erzeugt die Bildschirmfotos für docs/anleitung.md. * * Warum als Skript und nicht von Hand: Bilder in einer Anleitung veralten * beim ersten Umbau. So lassen sie sich in einer Minute neu erzeugen. * * Voraussetzungen: * - eine **leere** Instanz (die Beispieldaten würden sonst mit fremden * Codes im Bild landen), erreichbar unter BASE * - `BASE_URL` der Instanz muss auf **dieselbe** Adresse zeigen wie BASE. * Sonst scheitert der Domain-Check im Bild 21, weil die Anwendung sich * selbst unter der eingetragenen Adresse nicht erreicht. * - AUTH_ENABLED=true, damit Kopfzeile und Verwaltung so aussehen wie im * Regelbetrieb * - Playwright: npm i -D playwright && npx playwright install chromium * * Aufruf: * node docs/screenshots.mjs http://localhost:3000 */ import { mkdir } from "node:fs/promises"; import { fileURLToPath } from "node:url"; import { dirname, join } from "node:path"; import { chromium } from "playwright"; import sharp from "sharp"; const BASE = (process.argv[2] ?? "http://localhost:3000").replace(/\/+$/, ""); const ZIEL = join(dirname(fileURLToPath(import.meta.url)), "bilder"); await mkdir(ZIEL, { recursive: true }); const ADMIN = { email: "demo@example.com", password: "nur-fuer-die-bilder-1" }; const DESIGN = { fgColor: "#312E81", bgColor: "#FFFFFF", size: 512, ecLevel: "M", margin: 4 }; /* ------------------------------------------------------------- Anmelden */ let cookie = null; async function api(pfad, init = {}) { const headers = new Headers(init.headers ?? {}); if (init.json !== undefined) headers.set("Content-Type", "application/json"); if (cookie) headers.set("Cookie", cookie); const response = await fetch(BASE + pfad, { ...init, headers, body: init.json !== undefined ? JSON.stringify(init.json) : init.body, redirect: "manual", }); const gesetzt = response.headers.get("set-cookie"); if (gesetzt) cookie = gesetzt.split(";")[0]; let body = null; try { body = await response.json(); } catch { /* leerer Rumpf */ } return { status: response.status, body }; } const zustand = await api("/api/auth"); if (zustand.body?.authEnabled) { const aktion = zustand.body.setupOpen ? "setup" : "login"; const angemeldet = await api("/api/auth", { method: "POST", json: { action: aktion, ...ADMIN, name: "Demo Betreiber" }, }); if (angemeldet.status >= 400) { console.error("Anmeldung fehlgeschlagen:", angemeldet.body); process.exit(1); } } /* --------------------------------------------------------- Beispieldaten */ const angelegt = []; async function code(type, data, label) { const erstellt = await api("/api/codes", { method: "POST", json: { type, data, design: DESIGN, label }, }); if (erstellt.status !== 201) { console.error(`Beispielcode „${label}“ ging nicht:`, erstellt.body); process.exit(1); } angelegt.push(erstellt.body.code); return erstellt.body.code; } await code("website", { url: "https://example.com/nordwind" }, "Projekt Nordwind"); await code("website", { url: "https://example.com/messe" }, "Messestand Halle 3"); const links = await code( "links", { title: "Projekt Nordwind", description: "Alle Unterlagen auf einen Blick.", links: [ { label: "Preisliste als PDF", url: "https://example.com/preise.pdf" }, { label: "Anfahrt zum Werk", url: "https://example.com/anfahrt" }, { label: "Ansprechpartner", url: "https://example.com/kontakt" }, ], }, "Nordwind Linkseite", ); const social = await code( "social", { title: "Bäckerei Sonnenschein", description: "Folge uns für Neues aus der Backstube.", profiles: [ { network: "instagram", url: "@sonnenschein" }, { network: "facebook", url: "sonnenschein.backt" }, { network: "youtube", url: "@sonnenschein" }, { network: "website", url: "https://sonnenschein.example.com" }, ], }, "Sonnenschein Profile", ); /* --- Eine Datei zum Vorzeigen: klein, aber gültig ------------------------ */ async function hochladen(kind, name, inhalt, typ) { const form = new FormData(); form.set("kind", kind); form.set("file", new File([inhalt], name, { type: typ })); const antwort = await api("/api/uploads", { method: "POST", body: form }); if (antwort.status !== 201) { console.error(`Upload „${name}“ ging nicht:`, antwort.body); process.exit(1); } return antwort.body.upload; } const pdfDatei = await hochladen( "pdf", "preisliste-2026.pdf", Buffer.from( "%PDF-1.4\n1 0 obj<>endobj\n" + "2 0 obj<>endobj\n" + "3 0 obj<>endobj\n" + "trailer<>\n%%EOF\n", "latin1", ), "application/pdf", ); const pdf = await code( "pdf", { fileId: pdfDatei.id, filename: pdfDatei.filename, size: pdfDatei.size, title: "Preisliste 2026", description: "Gültig ab Januar, alle Preise ohne Mehrwertsteuer.", }, "Preisliste", ); const feedback = await code( "feedback", { title: "Wie war dein Besuch?", question: "Wie zufrieden bist du?", scale: "stars", allowComment: true, commentLabel: "Möchtest du uns noch etwas sagen?", thankYou: "Danke, das hilft uns weiter!", }, "Gastraum", ); // Ein paar Rückmeldungen, damit die Auswertung nicht leer ist. for (const [note, text] of [ [5, "Sehr freundlich, gerne wieder."], [4, ""], [5, "Der Kuchen war hervorragend."], [3, "Etwas lange Wartezeit."], ]) { await api("/api/feedback", { method: "POST", json: { codeId: feedback.id, rating: note, comment: text }, }); } /* ------------------------------------------------------------ Aufnehmen */ const browser = await chromium.launch(); const desktop = await browser.newContext({ viewport: { width: 1200, height: 820 }, deviceScaleFactor: 1, locale: "de-DE", }); if (cookie) { const [name, ...rest] = cookie.split("="); await desktop.addCookies([{ name, value: rest.join("="), url: BASE }]); } const handy = await browser.newContext({ viewport: { width: 390, height: 800 }, deviceScaleFactor: 1, locale: "de-DE", }); /** * Nimmt eine Seite auf. * * `bereich` grenzt auf einen Ausschnitt ein — für Dialoge und einzelne Karten, * bei denen die ganze Seite mehr verdeckt als zeigt. */ async function schuss( context, pfad, datei, { ganzeSeite = true, vorher, bereich } = {}, ) { const page = await context.newPage(); await page.goto(BASE + pfad, { waitUntil: "networkidle" }); if (vorher) await vorher(page); await page.waitForTimeout(300); const ziel = join(ZIEL, datei); if (bereich) { await page.locator(bereich).first().screenshot({ path: ziel }); } else { await page.screenshot({ path: ziel, fullPage: ganzeSeite }); } await page.close(); console.log(" ✓", datei); } /** * Ein Beispiellogo für die Aufnahmen zum Logo im Code. * * Erzeugt statt mitgeliefert: So liegt kein Bild im Repo, das niemand pflegt, * und die Farbe passt zu den übrigen Aufnahmen. */ async function beispiellogo() { const svg = ` `; return sharp(Buffer.from(svg)).png().toBuffer(); } console.log("Bilder werden erzeugt:"); await schuss(desktop, "/", "01-startseite.png"); await schuss(desktop, "/erstellen/website", "02-website-formular.png", { vorher: async (page) => { await page.locator("#website-url").fill("https://example.com/nordwind"); await page.waitForTimeout(900); }, }); await schuss(desktop, "/erstellen/website", "03-dynamisch.png", { vorher: async (page) => { await page.locator("#website-url").fill("https://example.com/nordwind"); await page.locator('button[data-mode="dynamic"]').click(); await page.waitForTimeout(900); }, }); await schuss(desktop, "/meine-codes", "04-meine-codes-liste.png"); await schuss(desktop, "/meine-codes", "05-meine-codes-karten.png", { vorher: async (page) => { await page.locator('button[data-view="cards"]').click(); await page.waitForTimeout(600); }, }); await schuss(desktop, "/meine-codes", "06-ziel-bearbeiten.png", { ganzeSeite: false, vorher: async (page) => { await page.locator('button[data-view="list"]').click(); await page.waitForTimeout(400); // Bewusst die letzte Zeile: ein Website-Code, dessen Dialog ganz ins Bild passt. await page.locator("tbody tr").last().getByRole("button", { name: /bearbeiten/ }).click(); await page.getByRole("heading", { name: "Code bearbeiten" }).waitFor(); }, }); await schuss(handy, `/s/${links.slug}`, "07-landing-links.png"); await schuss(handy, `/s/${social.slug}`, "08-landing-social.png"); await schuss(handy, `/s/${pdf.slug}`, "09-landing-pdf.png"); await schuss(handy, `/s/${feedback.slug}`, "09b-landing-feedback.png"); await schuss(desktop, `/meine-codes/${feedback.id}/feedback`, "10-feedback-auswertung.png"); await schuss(desktop, "/verwaltung", "11-verwaltung.png"); await schuss(desktop, "/verwaltung/typen", "12-verwaltung-typen.png"); await schuss(desktop, "/verwaltung/gruppen", "13-verwaltung-gruppen.png"); await schuss(desktop, "/verwaltung/domains", "14-verwaltung-domains.png"); await schuss(desktop, "/konto", "15-konto.png"); /* ------------------------------------------- Seit 1.7.0: die neuen Funktionen */ const logo = await beispiellogo(); // Logo in der Mitte: ganze Seite, damit Auswahl und Vorschau zusammen im Bild // sind — das eine erklärt das andere. await schuss(desktop, "/erstellen/website", "16-logo-im-code.png", { vorher: async (page) => { await page.locator("#website-url").fill("https://example.com/nordwind"); await page.getByRole("checkbox", { name: /Logo in die Mitte/ }).check(); await page.setInputFiles('input[type="file"]', { name: "logo.png", mimeType: "image/png", buffer: logo, }); await page.waitForTimeout(2500); }, }); // Für die Dublettenwarnung braucht es einen bereits abgelegten statischen Code. const statisch = await api("/api/codes", { method: "POST", json: { type: "website", data: { url: "https://example.com/aushang" }, design: DESIGN, mode: "static", label: "Aushang Eingang", }, }); if (statisch.status === 201) angelegt.push(statisch.body.code); await schuss(desktop, "/erstellen/website", "17-dublettenwarnung.png", { bereich: "section:has-text('Art des Codes')", vorher: async (page) => { await page.locator("#website-url").fill("https://example.com/aushang"); await page.locator('button[data-mode="static"]').click(); await page.waitForTimeout(900); await page.getByRole("button", { name: /ablegen/i }).click(); await page.getByText("Diesen Inhalt hast du schon abgelegt").waitFor({ timeout: 15000, }); }, }); // Filter nach Art — sichtbar, weil jetzt beide Arten in der Liste stehen. await schuss(desktop, "/meine-codes", "18-meine-codes-filter.png", { vorher: async (page) => { await page.getByLabel("Nach Art filtern").selectOption("static"); await page.waitForTimeout(600); }, }); // Freigabe je Gruppe: eine zweite Gruppe, damit die Auswahl etwas zu wählen hat. const gruppe = await api("/api/admin/groups", { method: "POST", json: { name: "Marketing" }, }); const gruppeId = gruppe.body?.group?.id ?? null; if (gruppeId) { // Ein Typ in dieser Gruppe abgeschaltet, damit das Bild den Fall zeigt, // um den es geht. await api("/api/admin/types", { method: "PATCH", json: { type: "payment", enabled: false, groupId: gruppeId }, }); } await schuss(desktop, "/verwaltung/typen", "19-verwaltung-typen-gruppe.png", { vorher: async (page) => { if (gruppeId) { await page.getByLabel("Freigabe für").selectOption(gruppeId); await page.waitForTimeout(900); } }, }); // Kurzhilfe: nur der Dialog, die Seite dahinter trägt nichts bei. await schuss(desktop, "/verwaltung/domains", "20-domain-kurzhilfe.png", { bereich: "dialog[open]", vorher: async (page) => { await page.getByRole("button", { name: "Kurzhilfe" }).click(); await page.locator("dialog[open]").waitFor(); await page.waitForTimeout(400); }, }); // Domain-Check: der Bericht zur eigenen Adresse. Weil BASE_URL und der Port // des Containers hier zusammenpassen, geht die Prüfung durch — genau der // Fall, den die Anleitung zeigen soll. await schuss(desktop, "/verwaltung/domains", "21-domain-check.png", { vorher: async (page) => { await page.getByRole("button", { name: "Prüfen" }).first().click(); await page .locator("text=/Die Domain führt hierher|So kommt niemand hier an/") .first() .waitFor({ timeout: 30000 }); await page.waitForTimeout(400); }, }); await browser.close(); /* --- Was über die Oberfläche entstand, kennt `angelegt` noch nicht --------- */ const bestand = await api("/api/codes"); for (const eintrag of bestand.body?.codes ?? []) { if (!angelegt.some((vorhanden) => vorhanden.id === eintrag.id)) { angelegt.push(eintrag); } } // Die Abweichung und die Gruppe wieder zurücknehmen — das Skript soll eine // Instanz so hinterlassen, wie es sie vorgefunden hat. if (gruppeId) { await api("/api/admin/types", { method: "PATCH", json: { type: "payment", enabled: true, groupId: gruppeId }, }); await api(`/api/admin/groups/${gruppeId}`, { method: "DELETE" }); } /* ------------------------------------------------------------ Aufräumen */ for (const eintrag of angelegt) { await api(`/api/codes/${eintrag.id}`, { method: "DELETE" }); } console.log(`\nFertig — ${angelegt.length} Beispielcodes wieder entfernt.`); console.log(`Die Bilder liegen in ${ZIEL}.`);