Bedienungsanleitung mit Bildern
docs/anleitung.md fuehrt vom ersten Code bis zur Auswertung von Rueckmeldungen: statisch gegen dynamisch, Gestaltung, Meine Codes, die fuenf Typen mit eigener Seite, Verwaltung, Konto und haeufige Fragen. Die Bilder erzeugt docs/screenshots.mjs. Von Hand aufgenommene Bilder veralten beim ersten Umbau — so sind sie in einer Minute neu erzeugt, samt Beispieldaten, die das Skript hinterher wieder entfernt.
@@ -8,6 +8,15 @@ entscheidet die Verwaltung — nicht gebrauchte lassen sich ausblenden.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Anleitungen
|
||||||
|
|
||||||
|
- **[Bedienungsanleitung mit Bildern](docs/anleitung.md)** — vom ersten Code
|
||||||
|
bis zur Auswertung von Rückmeldungen
|
||||||
|
- **[Anmeldung über Authentik (OIDC)](docs/authentik.md)** — Einrichtung,
|
||||||
|
Rollen und der Weg zurück
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Inhalt
|
## Inhalt
|
||||||
|
|
||||||
- [Schritt für Schritt zum laufenden Stack](#schritt-für-schritt-zum-laufenden-stack)
|
- [Schritt für Schritt zum laufenden Stack](#schritt-für-schritt-zum-laufenden-stack)
|
||||||
|
|||||||
@@ -0,0 +1,322 @@
|
|||||||
|
# Bedienungsanleitung
|
||||||
|
|
||||||
|
Diese Anleitung führt durch die tägliche Arbeit mit dem QR-Code-Generator —
|
||||||
|
vom ersten Code bis zur Auswertung von Rückmeldungen. Wie der Stack
|
||||||
|
aufgesetzt wird, steht im [README](../README.md); die Anmeldung über
|
||||||
|
Authentik in [authentik.md](authentik.md).
|
||||||
|
|
||||||
|
> Die Bilder zeigen die Oberfläche mit eingeschalteter Anmeldung. Ohne
|
||||||
|
> Anmeldung fehlen Kopfzeile rechts und der Bereich „Verwaltung“, alles
|
||||||
|
> Übrige ist gleich.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Inhalt
|
||||||
|
|
||||||
|
- [Der schnellste Weg zum Code](#der-schnellste-weg-zum-code)
|
||||||
|
- [Statisch oder dynamisch — die wichtigste Entscheidung](#statisch-oder-dynamisch--die-wichtigste-entscheidung)
|
||||||
|
- [Gestaltung](#gestaltung)
|
||||||
|
- [Meine Codes](#meine-codes)
|
||||||
|
- [Die Typen mit eigener Seite](#die-typen-mit-eigener-seite)
|
||||||
|
- [Feedback einsammeln und auswerten](#feedback-einsammeln-und-auswerten)
|
||||||
|
- [Verwaltung](#verwaltung)
|
||||||
|
- [Konto](#konto)
|
||||||
|
- [Häufige Fragen](#häufige-fragen)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Der schnellste Weg zum Code
|
||||||
|
|
||||||
|
Die Startseite fragt „Was möchtest du erstellen?“ und zeigt alle
|
||||||
|
freigegebenen Typen als Kacheln.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Ein Klick auf **Website**, die Adresse eintippen — die Vorschau erscheint
|
||||||
|
sofort — und mit **PNG** oder **SVG** herunterladen. Fertig.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Links stehen Inhalt, Art des Codes und Gestaltung, rechts die Vorschau mit
|
||||||
|
den beiden Download-Knöpfen. Die Vorschau aktualisiert sich bei jeder
|
||||||
|
Änderung.
|
||||||
|
|
||||||
|
**PNG oder SVG?**
|
||||||
|
|
||||||
|
| | PNG | SVG |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Wofür | Bildschirm, Office, Web | Druck, große Formate, Schilder |
|
||||||
|
| Größe | fest (256–2048 px) | frei skalierbar ohne Qualitätsverlust |
|
||||||
|
| Faustregel | wenn du es einfügst | wenn es jemand druckt |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Statisch oder dynamisch — die wichtigste Entscheidung
|
||||||
|
|
||||||
|
| | Statisch | Dynamisch |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Im Code steht | die Zieladresse selbst | eine Kurz-URL dieser Anwendung |
|
||||||
|
| Ziel später änderbar | **nein** | **ja**, jederzeit |
|
||||||
|
| Scans werden gezählt | nein | ja |
|
||||||
|
| Braucht diesen Server | nein, nie | ja, dauerhaft |
|
||||||
|
| Code ist | dichter (mehr Daten) | schlanker, leichter scannbar |
|
||||||
|
|
||||||
|
**Nimm statisch,** wenn die Adresse feststeht und nie wieder geändert wird —
|
||||||
|
und wenn der Code auch dann noch funktionieren soll, wenn es diese Anwendung
|
||||||
|
nicht mehr gibt.
|
||||||
|
|
||||||
|
**Nimm dynamisch,** wenn etwas gedruckt wird. Ein Aufsteller, ein Flyer, ein
|
||||||
|
Schild am Werkstor: Solange der Code dynamisch ist, kannst du das Ziel
|
||||||
|
umbiegen, ohne neu zu drucken.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Beim Umschalten auf **Dynamisch** erscheint ein Feld für den Namen und der
|
||||||
|
Knopf **Dynamischen Code speichern**. Erst beim Speichern entsteht die
|
||||||
|
Kurz-URL — deshalb ist der Download bis dahin gesperrt.
|
||||||
|
|
||||||
|
> **Der Name ist nur für dich.** Er steht nirgends im Code und sieht ihn
|
||||||
|
> niemand, der scannt. Er hilft dir, den Code später in der Liste
|
||||||
|
> wiederzufinden — bei vielen Projekten ist er Gold wert.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Gestaltung
|
||||||
|
|
||||||
|
Farben, Größe, Fehlerkorrektur und Rand gelten für Vorschau und Download
|
||||||
|
gleichermaßen.
|
||||||
|
|
||||||
|
**Farben.** Fünf Vorschläge stehen bereit, eigene Werte gehen über den
|
||||||
|
Farbwähler oder als Hex-Wert. Wird der Kontrast zu schwach, warnt die
|
||||||
|
Anwendung — ein hübscher, aber unlesbarer Code nützt niemandem. Faustregel:
|
||||||
|
dunkler Vordergrund auf hellem Hintergrund. Umgekehrt kommen viele
|
||||||
|
Scanner-Apps nicht zurecht.
|
||||||
|
|
||||||
|
**Größe.** 256 bis 2048 Pixel, betrifft nur den PNG-Download. Für Druck lieber
|
||||||
|
SVG nehmen und die Größe im Layoutprogramm bestimmen.
|
||||||
|
|
||||||
|
**Fehlerkorrektur.** Wie viel vom Code beschädigt sein darf, ohne dass er
|
||||||
|
unlesbar wird:
|
||||||
|
|
||||||
|
| Stufe | Verträgt | Wofür |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| L | ~7 % | saubere Bildschirmanzeige |
|
||||||
|
| **M** | ~15 % | **Voreinstellung, für fast alles richtig** |
|
||||||
|
| Q | ~25 % | Aufkleber, die abgerieben werden |
|
||||||
|
| H | ~30 % | Außenbereich, Logo mitten im Code |
|
||||||
|
|
||||||
|
Höhere Stufen machen den Code dichter — er braucht mehr Fläche, um noch
|
||||||
|
gescannt zu werden.
|
||||||
|
|
||||||
|
**Rand.** Die weiße Zone rundherum. Vier Module sind Standard; weniger kann
|
||||||
|
das Scannen erschweren, weil der Scanner den Code nicht mehr vom Hintergrund
|
||||||
|
trennen kann.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Meine Codes
|
||||||
|
|
||||||
|
Hier stehen alle **dynamischen** Codes. Statische erscheinen nicht — sie
|
||||||
|
werden nicht gespeichert.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Die **Liste** ist die Voreinstellung: eine Zeile je Code, gedacht für viele
|
||||||
|
Projekte nebeneinander.
|
||||||
|
|
||||||
|
- Das **Suchfeld** durchsucht Name, Ziel, Kurz-URL und Typ.
|
||||||
|
- Die **Sortierung** kennt neueste, älteste, Name und Scan-Anzahl.
|
||||||
|
- Ein **Gruppenfilter** erscheint, sobald Codes in mehr als einer Gruppe
|
||||||
|
liegen.
|
||||||
|
- Das Symbol neben der Kurz-URL kopiert sie in die Zwischenablage.
|
||||||
|
|
||||||
|
Über die beiden Schalter rechts geht es zur **Kartenansicht** mit großer
|
||||||
|
Vorschau. Die Wahl merkt sich der Browser.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### Ziel ändern
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Das ist der eigentliche Sinn dynamischer Codes: **Die Kurz-URL bleibt, der
|
||||||
|
gedruckte Code bleibt gültig, nur das Ziel wechselt.** Die Änderung wirkt
|
||||||
|
sofort — beim nächsten Scan.
|
||||||
|
|
||||||
|
### Löschen
|
||||||
|
|
||||||
|
Löschen fragt nach und nennt den betroffenen Code. Danach führt der gedruckte
|
||||||
|
Code ins Leere und zeigt eine freundliche Seite „Diese Seite gibt es nicht“.
|
||||||
|
Ein gelöschter Code lässt sich nicht wiederherstellen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Die Typen mit eigener Seite
|
||||||
|
|
||||||
|
Fünf Typen leiten nicht auf eine fremde Adresse weiter, sondern auf eine
|
||||||
|
Seite, die diese Anwendung selbst ausliefert: **Mehrere Links**, **Social
|
||||||
|
Media**, **PDF**, **Bild** und **Feedback**.
|
||||||
|
|
||||||
|
Diese Seiten sind öffentlich und zeigen bewusst nichts von der Anwendung —
|
||||||
|
keine Navigation, kein „Verwaltung“, kein Hinweis auf den Generator. Wer
|
||||||
|
einen Aushang scannt, soll nur den Inhalt sehen.
|
||||||
|
|
||||||
|
### Mehrere Links
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Eine Überschrift, ein kurzer Text und bis zu 20 beschriftete Links. Mit den
|
||||||
|
Pfeilen im Formular lässt sich die Reihenfolge ändern — das oberste wird am
|
||||||
|
häufigsten geklickt, also gehört das Wichtigste nach oben.
|
||||||
|
|
||||||
|
### Social Media
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Dasselbe Prinzip, aber mit einem Katalog von Netzwerken. Als Eingabe genügt
|
||||||
|
das Kürzel: Aus `@firma` wird beim Speichern `https://instagram.com/firma`.
|
||||||
|
Eine vollständige Adresse bleibt unverändert, damit auch abweichende
|
||||||
|
Profil-Adressen möglich sind. Jedes Netzwerk kann nur einmal vorkommen.
|
||||||
|
|
||||||
|
### PDF und Bild
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Beim PDF-Typ lädst du ein Dokument hoch; die Seite zeigt Titel, Dateiname,
|
||||||
|
Größe und zwei Knöpfe: **Dokument öffnen** und **Herunterladen**.
|
||||||
|
|
||||||
|
Beim Bild-Typ sind es bis zu zehn Bilder mit Bildunterschriften, untereinander
|
||||||
|
als Galerie.
|
||||||
|
|
||||||
|
Beides lässt sich später austauschen, **ohne den Code neu zu drucken** — genau
|
||||||
|
wie ein geändertes Ziel. Erlaubt sind PDF sowie PNG, JPEG, WebP, GIF und AVIF
|
||||||
|
bis zur eingestellten Grenze (Voreinstellung 10 MB, siehe `MAX_UPLOAD_MB`).
|
||||||
|
|
||||||
|
> SVG wird bewusst nicht angenommen: Eine SVG-Datei darf Skripte enthalten,
|
||||||
|
> und sie würde von derselben Adresse ausgeliefert wie die Anwendung selbst.
|
||||||
|
|
||||||
|
### Video
|
||||||
|
|
||||||
|
Der Video-Typ erkennt YouTube, Vimeo und direkte Videodateien. Auf der Seite
|
||||||
|
erscheint ein Abspieler — der Rahmen zum Anbieter lädt aber **erst nach einem
|
||||||
|
Klick**, und davor steht, dass dabei Daten an den Anbieter gehen.
|
||||||
|
|
||||||
|
Video ist der einzige dieser Typen, der auch **statisch** funktioniert: Dann
|
||||||
|
steht die Videoadresse direkt im Code und führt ohne Umweg dorthin.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Feedback einsammeln und auswerten
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Der Feedback-Typ erzeugt ein Formular mit fünf Sternen oder fünf Gesichtern
|
||||||
|
und wahlweise einem Textfeld. Alle Texte — Überschrift, Frage, Beschriftung
|
||||||
|
des Textfelds, Dank nach dem Absenden — lassen sich frei formulieren.
|
||||||
|
|
||||||
|
Gespeichert werden nur die Note und der Text. **Kein Name, keine IP-Adresse,
|
||||||
|
keine Gerätekennung.** Wer einen Aushang scannt und eine Note vergibt, bleibt
|
||||||
|
anonym — das steht auch für die Scannenden sichtbar unter dem Formular.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Die Auswertung erreichst du in „Meine Codes“ über das Stern-Symbol in der
|
||||||
|
Zeile. Sie zeigt den Durchschnitt, die Verteilung der Noten und alle
|
||||||
|
Kommentare mit Datum.
|
||||||
|
|
||||||
|
**Ein praktischer Hinweis:** Ein Code pro Ort. Zwei Codes — einer im
|
||||||
|
Gastraum, einer am Ausgang — sagen mehr als einer für beides, weil du die
|
||||||
|
Auswertungen getrennt lesen kannst.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verwaltung
|
||||||
|
|
||||||
|
Der Bereich **Verwaltung** erscheint nur für Administratoren der Installation.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### QR-Typen
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
15 Kacheln sind viel, wenn ihr ohnehin nur drei davon braucht. Hier lassen
|
||||||
|
sich Typen abschalten; sie verschwinden dann aus der Auswahl.
|
||||||
|
|
||||||
|
**Bereits erstellte Codes eines abgeschalteten Typs funktionieren
|
||||||
|
unverändert weiter** — sie leiten weiter, zählen Scans und lassen sich
|
||||||
|
bearbeiten. Nur neue lassen sich nicht mehr anlegen.
|
||||||
|
|
||||||
|
### Gruppen
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Eine Gruppe bündelt Codes und die Menschen, die damit arbeiten. Jeder Code
|
||||||
|
gehört genau einer Gruppe; wer nicht in der Gruppe ist, sieht ihn nicht.
|
||||||
|
|
||||||
|
Die drei Rollen je Gruppe:
|
||||||
|
|
||||||
|
| Rolle | Darf |
|
||||||
|
| --- | --- |
|
||||||
|
| **Betrachter** | Codes sehen und herunterladen |
|
||||||
|
| **Bearbeiter** | zusätzlich anlegen, ändern und löschen |
|
||||||
|
| **Administrator** | zusätzlich Mitglieder und deren Rollen verwalten |
|
||||||
|
|
||||||
|
Darüber liegt der **Administrator der Installation**: Nur er legt Gruppen,
|
||||||
|
Benutzer und Domains überhaupt an.
|
||||||
|
|
||||||
|
### Domains
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Sollen Kurz-URLs unter verschiedenen Adressen laufen — etwa je Kunde eine —,
|
||||||
|
werden die Domains hier eingetragen und Gruppen zugeteilt. Eine Domain kann
|
||||||
|
mehreren Gruppen dienen; die Codes bleiben trotzdem strikt nach Gruppe
|
||||||
|
getrennt.
|
||||||
|
|
||||||
|
Damit das funktioniert, muss der Reverse Proxy jede dieser Domains auf diese
|
||||||
|
Anwendung leiten — siehe README, Abschnitt „Mehrere Domains“.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Konto
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Hier stehen die eigene Adresse, die Gruppen mit der jeweiligen Rolle, die
|
||||||
|
Passwortänderung und der Weg hinaus.
|
||||||
|
|
||||||
|
Meldet sich das Konto über Authentik an, steht statt der Passwortänderung ein
|
||||||
|
Hinweis darauf — das Passwort wird dann beim Anbieter geändert.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Häufige Fragen
|
||||||
|
|
||||||
|
**Der Code funktioniert auf dem Handy nicht, am Rechner aber schon.**
|
||||||
|
Dann zeigt die Kurz-URL auf eine Adresse, die das Handy nicht erreicht.
|
||||||
|
`BASE_URL` in der `.env` muss die Adresse sein, unter der die Anwendung **von
|
||||||
|
außen** erreichbar ist — nicht `localhost`.
|
||||||
|
|
||||||
|
**Ich habe den Code schon gedruckt und brauche ein anderes Ziel.**
|
||||||
|
Ist er dynamisch: „Meine Codes“ → Ziel bearbeiten. Ist er statisch: Der Code
|
||||||
|
muss neu erstellt und neu gedruckt werden. Deshalb bei allem Gedruckten
|
||||||
|
dynamisch nehmen.
|
||||||
|
|
||||||
|
**Der Scan-Zähler steht auf 0, obwohl jemand gescannt hat.**
|
||||||
|
Gezählt wird nur der Aufruf der Kurz-URL. Statische Codes zählen gar nicht,
|
||||||
|
weil sie diesen Server nie berühren. Bei Typen mit eigener Seite zählt der
|
||||||
|
Scan, nicht das Neuladen der Seite.
|
||||||
|
|
||||||
|
**Ein Kollege sieht meine Codes nicht.**
|
||||||
|
Er ist nicht in derselben Gruppe. Verwaltung → Gruppen → Mitglied hinzufügen.
|
||||||
|
|
||||||
|
**Der QR-Code wird nicht erkannt.**
|
||||||
|
Meist zu wenig Kontrast, ein zu kleiner Rand oder zu kleiner Druck.
|
||||||
|
Faustregel für Druck: mindestens 2 × 2 cm, bei Entfernung mehr — als grober
|
||||||
|
Anhalt ein Zehntel des Leseabstands.
|
||||||
|
|
||||||
|
**Kann ich ein Logo in die Mitte setzen?**
|
||||||
|
Nicht in dieser Anwendung. Wer es im Layoutprogramm macht: SVG nehmen,
|
||||||
|
Fehlerkorrektur **H** wählen und höchstens ein Fünftel der Fläche verdecken —
|
||||||
|
danach unbedingt mit mehreren Geräten testen.
|
||||||
|
After Width: | Height: | Size: 90 KiB |
|
After Width: | Height: | Size: 101 KiB |
|
After Width: | Height: | Size: 115 KiB |
|
After Width: | Height: | Size: 84 KiB |
|
After Width: | Height: | Size: 142 KiB |
|
After Width: | Height: | Size: 94 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 137 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 65 KiB |
@@ -0,0 +1,270 @@
|
|||||||
|
/**
|
||||||
|
* 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
|
||||||
|
* - 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";
|
||||||
|
|
||||||
|
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<</Type/Catalog/Pages 2 0 R>>endobj\n" +
|
||||||
|
"2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj\n" +
|
||||||
|
"3 0 obj<</Type/Page/Parent 2 0 R/MediaBox[0 0 595 842]>>endobj\n" +
|
||||||
|
"trailer<</Root 1 0 R>>\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",
|
||||||
|
});
|
||||||
|
|
||||||
|
async function schuss(context, pfad, datei, { ganzeSeite = true, vorher } = {}) {
|
||||||
|
const page = await context.newPage();
|
||||||
|
await page.goto(BASE + pfad, { waitUntil: "networkidle" });
|
||||||
|
if (vorher) await vorher(page);
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
await page.screenshot({ path: join(ZIEL, datei), fullPage: ganzeSeite });
|
||||||
|
await page.close();
|
||||||
|
console.log(" ✓", datei);
|
||||||
|
}
|
||||||
|
|
||||||
|
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: "Ziel 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");
|
||||||
|
|
||||||
|
await browser.close();
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------ 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}.`);
|
||||||
@@ -29,6 +29,7 @@
|
|||||||
"dotenv": "^17.2.4",
|
"dotenv": "^17.2.4",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "16.3.0",
|
"eslint-config-next": "16.3.0",
|
||||||
|
"playwright": "^1.62.1",
|
||||||
"prisma": "^7.9.1",
|
"prisma": "^7.9.1",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
@@ -5025,6 +5026,21 @@
|
|||||||
"url": "https://github.com/sponsors/isaacs"
|
"url": "https://github.com/sponsors/isaacs"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/fsevents": {
|
||||||
|
"version": "2.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||||
|
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/function-bind": {
|
"node_modules/function-bind": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||||
@@ -7095,6 +7111,38 @@
|
|||||||
"pathe": "^2.0.3"
|
"pathe": "^2.0.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/playwright": {
|
||||||
|
"version": "1.62.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz",
|
||||||
|
"integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright-core": "1.62.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"fsevents": "2.3.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright-core": {
|
||||||
|
"version": "1.62.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz",
|
||||||
|
"integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"playwright-core": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/pngjs": {
|
"node_modules/pngjs": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
|
||||||
|
|||||||
@@ -35,6 +35,7 @@
|
|||||||
"dotenv": "^17.2.4",
|
"dotenv": "^17.2.4",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "16.3.0",
|
"eslint-config-next": "16.3.0",
|
||||||
|
"playwright": "^1.62.1",
|
||||||
"prisma": "^7.9.1",
|
"prisma": "^7.9.1",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
|
|||||||