commit 0341c4eaeaf3db7fa1d9a5c2a9344222310426d5 Author: Lucas Orth Date: Mon Jul 20 12:21:08 2026 +0200 Initiale Ablage der umsortierten Skrift-Programme Struktur: Docker/ (skrift-backend, signature-service), Webseite/ (skrift-configurator, skrift-job-manager), Produktion/ (skrift-produktion, AutoRecover). Aufgeräumt: signature-to-svg entfernt, Backend-PayPal-Service entfernt (PayPal läuft client-seitig im Configurator), Dev-/Test-Artefakte und Deployment-Dokus bereinigt, .dockerignore repariert. CLAUDE.md mit Architektur ergänzt. Secrets (.env, config.json) via .gitignore ausgeschlossen. Co-Authored-By: Claude Opus 4.8 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6c8208e --- /dev/null +++ b/.gitignore @@ -0,0 +1,38 @@ +# ── Secrets (nicht ins Repo!) ─────────────────────────────── +.env +.env.* +!.env.example +# Produktions-Config mit SFTP-Passwort (Template: config.default.json) +Produktion/skrift-produktion/config.json + +# ── Node ──────────────────────────────────────────────────── +node_modules/ +npm-debug.log* + +# ── Python ────────────────────────────────────────────────── +.venv/ +venv/ +__pycache__/ +*.py[cod] +.pytest_cache/ + +# ── Laufzeit-/generierte Daten ────────────────────────────── +cache/ +output/ +*.log +# Produktions-Laufzeitzustand +Produktion/skrift-produktion/processed_orders.json +Produktion/skrift-produktion/archive.json +Produktion/skrift-produktion/order_data.json +Produktion/skrift-produktion/temp_signature/ + +# ── Build-Artefakte ───────────────────────────────────────── +build/ +dist/ +*.spec + +# ── OS / Editor ───────────────────────────────────────────── +.DS_Store +Thumbs.db +.vscode/ +.claude/settings.local.json diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..f215d87 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,127 @@ +# CLAUDE.md + +Behavioral guidelines to reduce common LLM coding mistakes. Merge with project-specific instructions as needed. + +**Tradeoff:** These guidelines bias toward caution over speed. For trivial tasks, use judgment. + +## 1. Think Before Coding + +**Don't assume. Don't hide confusion. Surface tradeoffs.** + +Before implementing: +- State your assumptions explicitly. If uncertain, ask. +- If multiple interpretations exist, present them - don't pick silently. +- If a simpler approach exists, say so. Push back when warranted. +- If something is unclear, stop. Name what's confusing. Ask. + +## 2. Simplicity First + +**Minimum code that solves the problem. Nothing speculative.** + +- No features beyond what was asked. +- No abstractions for single-use code. +- No "flexibility" or "configurability" that wasn't requested. +- No error handling for impossible scenarios. +- If you write 200 lines and it could be 50, rewrite it. + +Ask yourself: "Would a senior engineer say this is overcomplicated?" If yes, simplify. + +## 3. Surgical Changes + +**Touch only what you must. Clean up only your own mess.** + +When editing existing code: +- Don't "improve" adjacent code, comments, or formatting. +- Don't refactor things that aren't broken. +- Match existing style, even if you'd do it differently. +- If you notice unrelated dead code, mention it - don't delete it. + +When your changes create orphans: +- Remove imports/variables/functions that YOUR changes made unused. +- Don't remove pre-existing dead code unless asked. + +The test: Every changed line should trace directly to the user's request. + +## 4. Goal-Driven Execution + +**Define success criteria. Loop until verified.** + +Transform tasks into verifiable goals: +- "Add validation" → "Write tests for invalid inputs, then make them pass" +- "Fix the bug" → "Write a test that reproduces it, then make it pass" +- "Refactor X" → "Ensure tests pass before and after" + +For multi-step tasks, state a brief plan: +``` +1. [Step] → verify: [check] +2. [Step] → verify: [check] +3. [Step] → verify: [check] +``` + +Strong success criteria let you loop independently. Weak criteria ("make it work") require constant clarification. + +--- + +**These guidelines are working if:** fewer unnecessary changes in diffs, fewer rewrites due to overcomplication, and clarifying questions come before implementation rather than after mistakes. + +--- + +# Projekt: Skrift + +Handschriftliche Dokumente als Service. Kunden konfigurieren Briefe/Postkarten/Umschläge im Web; ein Backend rendert sie als SVG in echter Handschrift; ein Schreibroboter (Pen-Plotter) schreibt sie physisch. Alle Software in diesem Ordner gehört zu Skrift. Sprache im Projekt: Deutsch. + +## Ebenen und Komponenten + +Drei Ebenen, getrennt gehalten für einfachere Wartung: + +### `Docker/` — serverseitig, hinter Nginx Proxy Manager (NPM) +- **skrift-backend** (Node/Express, `:4000`, Domain `backend.skrift.de`) — **Main-Backend**. Generiert Brief-/Umschlag-SVGs: Text → Scriptalizer-API (natürliche Handschrift-Variation) → eigene Font-Engine (`src/lib/svg-font-engine.js`). Preview-Cache + finaler Order-Output. Platzhalter-System (`[[Name]]` → CSV). Kein PayPal im Backend — die PayPal-Zahlung läuft **rein client-seitig** im configurator über das PayPal-JS-SDK (`actions.order.create/capture` direkt gegen PayPal). SVG-Fonts unter `fonts/` (`tilda`=PremiumUltra79, `alva`=PremiumUltra23, `ellie`=PremiumUltra39). Struktur: `src/api/{routes,controllers,middleware}`, `src/services` (scriptalizer, placeholder), `src/lib` (svg-generator, svg-font-engine, page-layout). +- **signature-service** (FastAPI, `:8000`, Domain `api-sign.skrift.de`) — **zustandslos**. Findet unsichtbare Marker-Token (`§§SIG1§§`) in einem gedruckten PDF, platziert die zugeordnete Unterschrift-SVG relativ dazu und liefert **eine A4-SVG pro Seite** (`/render`, ZIP) bzw. ein Vorschau-PDF (`/preview`). Keine Plotter-Kalibrierung/Offsets im Service — Mechanik wird in der Druckersoftware behandelt. Geometrie-Logik in `core.py`. + +### `Webseite/` — WordPress-Plugins (öffentlich) +- **skrift-configurator** — **öffentlicher Haupt-Konfigurator**, Shortcode `[skrift_konfigurator]`. Spricht das Backend nur über einen **serverseitigen PHP-Proxy** an (`includes/api-proxy.php`, REST-Namespace `skrift/v1/proxy/*`), damit der API-Token nie ins Frontend gelangt. Liefert außerdem das Basis-CSS (`--sk-*`-Variablen, `.sk-*`-Klassen) sowie Admin-Settings (Backend-Verbindung, Preise, Produkte, Gutscheine). +- **skrift-job-manager** — Kunden-Auftragsportal, Shortcode `[skrift_kundenauftrag]`. **Hängt hart am configurator** (nutzt dessen CSS + Backend-Verbindungseinstellungen). Eigener Namespace `skrift/v1/job/*`; ruft Backend u.a. `/api/order/register`, `/api/order/files`. Signaturen: Kunde zeichnet auf einem `` → Striche werden client-seitig via `pathsToSvg()` als Polyline-SVG (`M/L`-Pfade) erzeugt und als Platzhalter `[[signatur1..3]]` mitgeschickt. + +### `Produktion/` — lokale Windows-Software +- **skrift-produktion** — **Main-Produktion**. FastAPI auf `127.0.0.1:8765` (öffnet Browser automatisch). Worker: `FTPPollWorker` pollt SFTP nach neuen Aufträgen, `MachinePollWorker` überwacht die Plotter-Maschine, `QueueWorker` arbeitet die Queue ab. Sendet SVGs an die Maschinen-API (Default `http://192.168.2.32:90`) mit Format-Templates (Maße/Position/Rotation) aus `config.json`. **Signaturen-Tab** ruft `signature-service /render` über den eigenen Endpunkt `POST /api/signature/print` und schickt die A4-Signaturen an den Plotter (Service-URL in `config.json` unter `app.signature_backend_url`, Default `http://localhost:8000`). +- **AutoRecover** — kleines win32-Skript (`auto_recover.py`). Erkennt das Java-„Error"-Fenster des Plotters (kein Papier) und klickt max. 3× „Recover". + +## Datenfluss (End-to-End) +1. Kunde konfiguriert im **configurator** (oder **job-manager**) → WordPress-Proxy → **skrift-backend** generiert SVGs. +2. **skrift-backend** schreibt den fertigen Order-Ordner nach `/var/skrift-output` (docker-compose-Volume). **Dieses Verzeichnis ist gleichzeitig das SFTP-Ziel**, auf das per SFTP zugegriffen wird — kein Zwischenschritt. +3. **skrift-produktion** holt den Auftrag per SFTP und schickt die SVGs formatgerecht an den **Plotter**. +4. Bei Unterschriften: gedrucktes PDF mit Marker-Token → **signature-service** platziert Signaturen → produktion sendet die A4-Signatur-SVGs an den Plotter. +5. **AutoRecover** hält den Druck bei Papierende am Laufen. + +## Befehle + +**skrift-backend** (`Docker/skrift-backend`) +```bash +npm install +npm start # node src/server.js → :4000 +npm run dev # nodemon +docker-compose up -d # Prod (Volume: /var/skrift-output, fonts/ read-only) +``` + +**signature-service** (`Docker/signature-service`) +```bash +pip install -r requirements.txt +uvicorn app:app --host 0.0.0.0 --port 8000 +pytest # ein Test: pytest tests/test_x.py::test_name +docker compose up -d --build +``` + +**signature-to-svg** (`Docker/signature-to-svg`) — `uvicorn app.main:app --host 0.0.0.0 --port 8765` bzw. `docker-compose up -d`. + +**skrift-produktion** (`Produktion/skrift-produktion`) — `Einrichten.bat` (venv + deps einmalig), dann `Starten.bat` (bzw. `Starten (Debug).bat`). Läuft auf `127.0.0.1:8765`. + +**AutoRecover** — `python auto_recover.py`; EXE-Build via `build_exe.bat`. + +WordPress-Plugins haben keinen Build-Schritt (reines PHP/JS). + +## Wichtige Hinweise / Fallstricke +- **NPM-Netz**: `signature-service` hängt im externen Docker-Netz `nginx-proxy-manager_default` und veröffentlicht keinen Host-Port — NPM proxyt intern per `container_name`. +- **`SCRIPTALIZER_ERR_FREQUENCY=0`** setzen: sonst erzeugt Scriptalizer absichtlich durchgestrichene „Fehler"-Wörter. +- **Secrets liegen im Repo**: `Docker/skrift-backend/.env` enthält reale Keys — nicht in Ausgaben/Commits streuen. +- Der job-manager ist **ohne den configurator nicht lauffähig** (CSS + Backend-Settings kommen von dort). +- `FRONTEND_BACKEND_ZUSAMMENFASSUNG.md` stammt aus der Zeit vor dieser Ordnerstruktur (spricht von „WordPress Plugin" / „Docker Backend") und ist teils veraltet — diese CLAUDE.md hat Vorrang. diff --git a/Docker/signature-service/.dockerignore b/Docker/signature-service/.dockerignore new file mode 100644 index 0000000..40e1ac3 --- /dev/null +++ b/Docker/signature-service/.dockerignore @@ -0,0 +1,6 @@ +__pycache__/ +*.pyc +tests/ +conftest.py +.pytest_cache/ +README.md diff --git a/Docker/signature-service/Dockerfile b/Docker/signature-service/Dockerfile new file mode 100644 index 0000000..1dde194 --- /dev/null +++ b/Docker/signature-service/Dockerfile @@ -0,0 +1,15 @@ +# Zustandsloser Signatur-Plotter-Service +FROM python:3.12-slim + +WORKDIR /app + +# Abhängigkeiten zuerst (besseres Layer-Caching) +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Quellcode +COPY models.py core.py app.py ./ + +EXPOSE 8000 + +CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/Docker/signature-service/README.md b/Docker/signature-service/README.md new file mode 100644 index 0000000..3002ffb --- /dev/null +++ b/Docker/signature-service/README.md @@ -0,0 +1,197 @@ +# Signatur-Plotter-Service + +Zustandsloser FastAPI-Service, der für einen Schreibroboter **A4-SVGs** erzeugt, +auf denen handschriftliche Unterschriften an exakt der richtigen Position sitzen. + +Im Druck-PDF stehen an jeder Signaturstelle unsichtbare Marker-Token in +transparenter Schrift (z.B. `§§SIG1§§`). Der Service sucht jeden Token im PDF, +liest dessen Koordinaten aus und platziert die zugeordnete SVG-Unterschrift +relativ dazu. Der Plotter druckt nur die erzeugte SVG – das bereits gedruckte +Dokument liefert den Rest. + +> **Keine Plotter-Kalibrierung im Service.** Mechanischer Versatz wird komplett in +> der Druckersoftware behandelt. Es gibt keine Offset-Logik und keine ENV-Variablen +> für Versatz. + +## Funktionsweise / Geometrie + +- PDF-Punkte werden in mm umgerechnet (`1 pt = 25.4/72 mm`). +- PyMuPDF nutzt den Ursprung oben links, Y wächst nach unten. +- Referenzpunkt ist die **linke Oberkante** des Marker-Tokens (`rect.x0`, `rect.y0`). +- `position: "genau"` → `y_sig = y_token` (Unterschrift sitzt **direkt** am Marker, **Standard**) +- `position: "ueber"` → `y_sig = y_token - abstand_mm - signatur_hoehe` +- `position: "unter"` → `y_sig = y_token + abstand_mm` +- Skalierung gleichmäßig: `skala = breite_mm / svg_originalbreite` + (Originalbreite aus der **viewBox**, nicht aus `width`/`height`). +- Eingebettet wird per ``. + +Die Vorschau legt die fertige A4-Signatur-SVG vektoriell (via PyMuPDF +`convert_to_pdf` + `show_pdf_page`) über eine Kopie der betroffenen Originalseiten +und gibt ein zusammengeführtes PDF zurück. Begründung siehe Kommentar in +[`core.py`](core.py) (`build_preview_pdf`): voll vektoriell, gleiche Geometrie wie +`/render`, ohne zusätzliche Pillow-Abhängigkeit. + +## Job-Definition + +Pro Eintrag. Ein Token darf **mehrfach** im PDF vorkommen (z.B. dieselbe Unterschrift +auf jeder Seite) – jedes Vorkommen wird platziert: + +| Feld | Typ | Default | Bedeutung | +|--------------|-------|---------|--------------------------------------------------| +| `token` | str | – | Unsichtbarer Token im PDF, z.B. `§§SIG1§§` | +| `svg_id` | str | – | Dateiname der hochgeladenen SVG | +| `position` | str | `genau` | `genau` (direkt am Marker), `ueber` oder `unter` | +| `breite_mm` | float | `45` | Zielbreite der Unterschrift | +| `abstand_mm` | float | `8` | Abstand zum Token (nur bei `ueber`/`unter` relevant) | +| `versatz_x_mm` | float | `0` | Feinjustierung links/rechts (+ = rechts, − = links) | +| `versatz_y_mm` | float | `0` | Feinjustierung oben/unten (+ = unten, − = oben) | +| `linienstaerke_mm` | float | `0.35` | Linienstärke der Kontur (Signaturen werden immer als Single-Stroke gezeichnet) | + +Beispiel (`job.json`): + +```json +[ + { "token": "§§SIG1§§", "svg_id": "unterschrift_a.svg", "position": "unter", "breite_mm": 45, "abstand_mm": 8 }, + { "token": "§§SIG2§§", "svg_id": "unterschrift_b.svg", "position": "ueber" } +] +``` + +## Endpunkte + +### `POST /render` +Findet **jedes Vorkommen** jedes Tokens und platziert die zugeordnete SVG an jeder +Stelle. **Rückgabe:** ZIP-Download mit **einer A4-SVG pro betroffener Seite** +(`210×297mm`, `viewBox "0 0 210 297"`), Dateiname `seite_.svg`. Jede Seiten-SVG +enthält alle Signaturen dieser Seite – nur die Unterschriften, Rest leer. + +### `POST /preview` +Gleiche Platzierungslogik. **Rückgabe:** zusammengeführtes PDF mit allen +betroffenen Seiten und der Unterschrift als Overlay – nur zur Sichtkontrolle. + +Übertragung jeweils als `multipart/form-data`: `pdf` als Datei, `svgs` als Dateien +(max. 3) und `job` als JSON-Form-Feld. + +## Fehlerbehandlung + +Es wird nie still übersprungen – Fehler kommen als klare Liste zurück: + +- Token gar nicht gefunden (0 Treffer) → **422** (Mehrfachtreffer sind erlaubt) +- derselbe Token mehrfach in der Job-Definition → **422** +- `svg_id` ohne hochgeladene Datei → **422** +- SVG ohne `viewBox` → **422** +- Mehr als 3 SVGs → **400** +- Ungültiges Job-JSON / fehlendes/leeres PDF → **400** +- Ungültige Felder (`position`, `breite_mm` …) → **422** + +Antwort-Format bei Fehlern: `{ "detail": [ "…", "…" ] }`. + +## Beispiel-curl + +`/render` (lädt das ZIP herunter): + +```bash +curl -X POST http://localhost:8000/render \ + -F "pdf=@dokument.pdf;type=application/pdf" \ + -F "svgs=@unterschrift_a.svg;type=image/svg+xml" \ + -F "svgs=@unterschrift_b.svg;type=image/svg+xml" \ + -F 'job=[{"token":"§§SIG1§§","svg_id":"unterschrift_a.svg","position":"unter","breite_mm":45,"abstand_mm":8},{"token":"§§SIG2§§","svg_id":"unterschrift_b.svg","position":"ueber"}]' \ + -o signaturen.zip +``` + +`/preview` (lädt das Vorschau-PDF herunter): + +```bash +curl -X POST http://localhost:8000/preview \ + -F "pdf=@dokument.pdf;type=application/pdf" \ + -F "svgs=@unterschrift_a.svg;type=image/svg+xml" \ + -F 'job=[{"token":"§§SIG1§§","svg_id":"unterschrift_a.svg","position":"unter"}]' \ + -o vorschau.pdf +``` + +## Lokal starten + +```bash +pip install -r requirements.txt +uvicorn app:app --host 0.0.0.0 --port 8000 +``` + +## Docker (lokal) + +```bash +docker build -t signatur-plotter . +docker run --rm -p 8000:8000 signatur-plotter +``` + +## Deployment hinter Nginx Proxy Manager + +Der Service läuft im selben Docker-Netz wie der NPM (`nginx-proxy-manager_default`) +und wird per Domain **`api-sign.skrift.de`** erreichbar gemacht. Start via Compose: + +```bash +docker compose up -d --build +``` + +Die [`docker-compose.yml`](docker-compose.yml) hängt den Container ins externe Netz +`nginx-proxy-manager_default` (kein Host-Port – NPM proxyt intern). Anschließend im +NPM einen **Proxy Host** anlegen: + +| Feld | Wert | +|------------------|-------------------------------| +| Domain Names | `api-sign.skrift.de` | +| Scheme | `http` | +| Forward Hostname | `skrift-signature-service` | +| Forward Port | `8000` | +| SSL | Let's Encrypt-Zertifikat aktivieren | + +Im Frontend wird die Backend-URL dann auf `https://api-sign.skrift.de` gesetzt +(siehe unten). + +## Tests + +```bash +pip install pytest +pytest +``` + +Die Tests decken Token-Suche, Geometrie (`ueber`/`unter`, viewBox-Offset) und die +Fehlerfälle (Token fehlt/mehrfach, SVG fehlt, keine viewBox) ab. + +## Frontend-Tab in der Skrift-Anwendung + +Der Upload-Bereich ist als eigener Tab **„Signaturen“** in die bestehende +Skrift-FTP-Anwendung integriert (`app/static/index.html`). Er ist über die +Hauptnavigation neben „Manuell drucken“ erreichbar und bietet: + +- Upload für genau **ein PDF** und bis zu **3 SVG-Unterschriften**, +- pro SVG eine Zuordnungszeile (Marker-Token, Position **genau** (Standard) / über / unter, + Breite, Abstand, **X-/Y-Versatz** zur Feinjustierung sowie **Linienstärke**; + Signaturen werden immer als Single-Stroke gezeichnet), +- die zuletzt verwendeten Werte werden **pro Signatur** (Dateiname) im Browser + gespeichert und beim erneuten Hochladen automatisch wiederhergestellt, +- ein **Standard-Template** für den Direktdruck ist in den App-Einstellungen unter + *Zuordnung → Signaturen* festlegbar (wird im Tab vorausgewählt), +- Buttons **Vorschau** (zeigt das Vorschau-PDF inline) und **Rendern** (ZIP-Download), +- **Direkt drucken**: Template + Maschine auswählen und die gerenderten A4-Signaturen + sofort an den Schreibroboter senden, +- eine gut sichtbare Fehlerliste (Token nicht/mehrfach gefunden, fehlende Zuordnung …). + +### Direktdruck-Pfad + +`Direkt drucken` ruft den Skrift-Backend-Endpunkt `POST /api/signature/print` auf. +Dieser rendert die Signaturen serverseitig über den Signatur-Service (`/render`), +entpackt das ZIP und schickt die A4-SVGs mit den Parametern des gewählten Templates +über den vorhandenen Maschinen-Client an den Plotter (gleicher Ablauf wie +„Manuell drucken“, inkl. Formatwechsel-Prüfung und Bestätigung im Tab +„Druckvorgang“). Da dies über den gleichen Origin läuft, ist hierfür kein CORS nötig. + +### Backend-URL setzen + +Die URL des Signatur-Service ist konfigurierbar (Default `http://localhost:8000`, +hinter dem Nginx Proxy Manager z.B. `https://api-sign.skrift.de`): + +- **Im Tab:** Feld *„Signatur-Service (Backend-URL)“* ausfüllen und *Speichern*. +- Persistiert wird in `config.json` unter `app.signature_backend_url` + (Endpunkte `GET`/`POST /api/signature/backend-url` der Skrift-App). + +Damit das Frontend (Port `8765`) den Service (Port `8000`) erreichen darf, ist im +Service **CORS** offen konfiguriert. diff --git a/Docker/signature-service/app.py b/Docker/signature-service/app.py new file mode 100644 index 0000000..8741bbd --- /dev/null +++ b/Docker/signature-service/app.py @@ -0,0 +1,139 @@ +""" +Signatur-Plotter-Service (FastAPI). + +Zustandsloser Service: nimmt ein PDF mit unsichtbaren Marker-Token, bis zu drei +Signatur-SVGs und eine Job-Definition entgegen und erzeugt A4-Plot-SVGs bzw. ein +Vorschau-PDF. Keine Datenbank, keine Persistenz, keine Plotter-Kalibrierung. +""" + +import io +import json +import zipfile +from typing import List + +from fastapi import FastAPI, File, Form, HTTPException, UploadFile +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import StreamingResponse +from pydantic import ValidationError + +from core import build_preview_pdf, place_all +from models import SignatureJob + +app = FastAPI(title="Signatur-Plotter-Service", version="1.0.0") + +# CORS offen halten, damit das Upload-Frontend (anderer Port/Origin) den Service erreicht. +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_methods=["*"], + allow_headers=["*"], +) + +MAX_SVGS = 3 + + +# ── Eingaben aufbereiten ──────────────────────────────────────────────── +def _parse_jobs(job_raw: str) -> List[SignatureJob]: + """Parst das Job-Definition-JSON-Formfeld zu einer Liste von SignatureJob.""" + try: + data = json.loads(job_raw) + except json.JSONDecodeError as e: + raise HTTPException(400, f"Job-Definition ist kein gültiges JSON: {e}") + + # Sowohl eine reine Liste als auch ein Wrapper-Objekt akzeptieren. + if isinstance(data, dict): + data = data.get("signaturen") or data.get("jobs") or [data] + if not isinstance(data, list) or not data: + raise HTTPException(400, "Job-Definition muss eine nicht-leere Liste sein.") + + jobs: List[SignatureJob] = [] + errors: List[str] = [] + for i, entry in enumerate(data, start=1): + try: + jobs.append(SignatureJob(**entry)) + except ValidationError as e: + for err in e.errors(): + loc = ".".join(str(x) for x in err["loc"]) + errors.append(f"Eintrag {i} ({loc}): {err['msg']}") + except TypeError: + errors.append(f"Eintrag {i}: ungültiges Format.") + if errors: + # 422 bei Validierungsfehlern der Job-Definition. + raise HTTPException(422, detail=errors) + + # Doppelte Token in der Job-Definition selbst sind ein Eingabefehler. + seen = {} + for j in jobs: + seen[j.token] = seen.get(j.token, 0) + 1 + dups = [t for t, c in seen.items() if c > 1] + if dups: + raise HTTPException(422, detail=[f"Token '{t}' mehrfach in der Job-Definition." for t in dups]) + + return jobs + + +async def _read_svgs(svgs: List[UploadFile]) -> dict: + """Liest die hochgeladenen SVGs in ein {dateiname: bytes}-Dict. Max. 3.""" + if len(svgs) > MAX_SVGS: + raise HTTPException(400, f"Maximal {MAX_SVGS} SVG-Dateien erlaubt (es waren {len(svgs)}).") + out = {} + for f in svgs: + out[f.filename] = await f.read() + return out + + +async def _prepare(pdf: UploadFile, svgs: List[UploadFile], job: str): + """Gemeinsame Aufbereitung für /render und /preview.""" + jobs = _parse_jobs(job) + svg_map = await _read_svgs(svgs) + pdf_bytes = await pdf.read() + if not pdf_bytes: + raise HTTPException(400, "PDF-Datei ist leer.") + pages, errors = place_all(pdf_bytes, svg_map, jobs) + if errors: + # 422: fachliche Validierung (Token nicht gefunden, SVG fehlt, keine viewBox …) + raise HTTPException(422, detail=errors) + return pdf_bytes, pages + + +# ── Endpunkte ─────────────────────────────────────────────────────────── +@app.get("/") +def health(): + return {"service": "Signatur-Plotter-Service", "status": "ok"} + + +@app.post("/render") +async def render( + pdf: UploadFile = File(...), + svgs: List[UploadFile] = File(default=[]), + job: str = Form(...), +): + """Erzeugt pro betroffener Seite eine A4-SVG und liefert alle als ZIP.""" + _, pages = await _prepare(pdf, svgs, job) + + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf: + for p in pages: + zf.writestr(p.filename, p.a4_svg) + buf.seek(0) + return StreamingResponse( + buf, + media_type="application/zip", + headers={"Content-Disposition": 'attachment; filename="signaturen.zip"'}, + ) + + +@app.post("/preview") +async def preview( + pdf: UploadFile = File(...), + svgs: List[UploadFile] = File(default=[]), + job: str = Form(...), +): + """Erzeugt ein Vorschau-PDF mit allen betroffenen Seiten und Signatur-Overlay.""" + pdf_bytes, pages = await _prepare(pdf, svgs, job) + out_pdf = build_preview_pdf(pdf_bytes, pages) + return StreamingResponse( + io.BytesIO(out_pdf), + media_type="application/pdf", + headers={"Content-Disposition": 'inline; filename="vorschau.pdf"'}, + ) diff --git a/Docker/signature-service/conftest.py b/Docker/signature-service/conftest.py new file mode 100644 index 0000000..c0c7b29 --- /dev/null +++ b/Docker/signature-service/conftest.py @@ -0,0 +1,2 @@ +# Leer – sorgt dafür, dass pytest das Service-Verzeichnis auf sys.path legt, +# damit `import core` / `import models` in den Tests funktioniert. diff --git a/Docker/signature-service/core.py b/Docker/signature-service/core.py new file mode 100644 index 0000000..4bac946 --- /dev/null +++ b/Docker/signature-service/core.py @@ -0,0 +1,315 @@ +""" +Kernlogik des Signatur-Plotter-Service. + +Hier liegt die gemeinsame Logik, die /render und /preview beide nutzen: + 1. Marker-Token im PDF finden (eindeutig) und Koordinaten auslesen. + 2. Die zugeordnete SVG geometrisch korrekt auf einer A4-SVG platzieren. + +Der Service ist zustandslos: alles arbeitet auf den übergebenen Bytes. +""" + +import re +import xml.etree.ElementTree as ET +from dataclasses import dataclass, field +from typing import List, Tuple + +import fitz # PyMuPDF + +from models import SignatureJob + +# ── Konstanten ────────────────────────────────────────────────────────── +PT_TO_MM = 25.4 / 72.0 # 1 pt = 25.4/72 mm +A4_WIDTH_MM = 210.0 +A4_HEIGHT_MM = 297.0 +SVG_NS = "http://www.w3.org/2000/svg" +XLINK_NS = "http://www.w3.org/1999/xlink" + +# Saubere Namensräume beim Serialisieren (kein ns0:-Präfix im Output) +ET.register_namespace("", SVG_NS) +ET.register_namespace("xlink", XLINK_NS) + +# Strukturelle Attribute der Wurzel-, die NICHT auf die Gruppe übernommen werden +_SKIP_ROOT_ATTRS = { + "viewbox", "width", "height", "xmlns", "x", "y", "id", + "version", "preserveaspectratio", "baseprofile", + # transform auf der äußersten wird laut Spec ignoriert – darf daher NICHT + # auf die Gruppe übernommen werden, sonst verzieht/verschiebt es die Signatur. + "transform", +} + + +class PlacementError(Exception): + """Eingabe-/Validierungsfehler, der dem Aufrufer als Fehlermeldung gemeldet wird.""" + + +@dataclass +class ViewBox: + """Aus der SVG gelesene viewBox plus eingebetteter Inhalt.""" + + minx: float + miny: float + vw: float + vh: float + inner: str # serialisierte Kind-Elemente der + group_attrs: dict = field(default_factory=dict) # übernommene Styling-Attribute + + +@dataclass +class PageRender: + """Ergebnis pro betroffener Seite: eine A4-SVG mit allen Signaturen dieser Seite.""" + + page_index: int # 0-basiert + a4_svg: str # fertige A4-SVG als String + filename: str # seite_.svg + tokens: list = field(default_factory=list) # welche Token auf der Seite platziert wurden + + +# ── SVG einlesen ──────────────────────────────────────────────────────── +def parse_svg(svg_bytes: bytes) -> ViewBox: + """Liest viewBox und Inhalt einer SVG. Wirft PlacementError bei fehlender viewBox.""" + try: + root = ET.fromstring(svg_bytes) + except ET.ParseError as e: + raise PlacementError(f"SVG nicht lesbar ({e}).") + + # viewBox case-insensitiv suchen (XML ist eigentlich case-sensitiv, aber wir sind tolerant) + vb_raw = root.get("viewBox") or root.get("viewbox") + if not vb_raw: + raise PlacementError("SVG ohne viewBox – Skalierung nicht möglich.") + + parts = re.split(r"[\s,]+", vb_raw.strip()) + if len(parts) != 4: + raise PlacementError("viewBox hat nicht genau 4 Werte.") + try: + minx, miny, vw, vh = (float(p) for p in parts) + except ValueError: + raise PlacementError("viewBox enthält ungültige Zahlen.") + if vw <= 0 or vh <= 0: + raise PlacementError("viewBox-Breite/Höhe muss > 0 sein.") + + # Inhalt der (alle Kinder) als String – Wrapper- entfällt, wir nutzen . + inner = "".join(ET.tostring(child, encoding="unicode") for child in root) + + # Styling-Attribute der Wurzel (z.B. fill/stroke) auf die spätere Gruppe übertragen, + # damit als Präsentationsattribut gesetzte Standardfarben nicht verloren gehen. + group_attrs = {} + for k, v in root.attrib.items(): + local = k.split("}")[-1].lower() + if local not in _SKIP_ROOT_ATTRS: + group_attrs[k.split("}")[-1]] = v + + return ViewBox(minx, miny, vw, vh, inner, group_attrs) + + +# ── Token-Suche ───────────────────────────────────────────────────────── +def find_token_occurrences(doc: fitz.Document, token: str) -> List[Tuple[int, fitz.Rect]]: + """ + Sucht den Token im gesamten PDF und liefert ALLE Vorkommen. + + Der Token darf mehrfach vorkommen (z.B. dieselbe Unterschrift auf jeder Seite) – + jedes Vorkommen wird platziert. Rückgabe: Liste von (page_index, rect), evtl. leer. + """ + hits: List[Tuple[int, fitz.Rect]] = [] + for page_index in range(doc.page_count): + for rect in doc[page_index].search_for(token): + hits.append((page_index, rect)) + return hits + + +# ── Geometrie ─────────────────────────────────────────────────────────── +def signatur_geometrie(x0_pt: float, y0_pt: float, vb: ViewBox, + position: str, breite_mm: float, abstand_mm: float, + versatz_x_mm: float = 0.0, versatz_y_mm: float = 0.0): + """ + Berechnet Platzierung der Signatur in mm. + + Referenzpunkt ist die linke Oberkante des Tokens (rect.x0, rect.y0). + PyMuPDF: Ursprung oben links, Y wächst nach unten. + versatz_x_mm/versatz_y_mm verschieben das Ergebnis zusätzlich + (+x = rechts, +y = unten) zur Feinjustierung. + + Rückgabe: (x_sig_mm, y_sig_mm, skala, signatur_hoehe_mm) + """ + # Token-Position von Punkten in mm + x_token_mm = x0_pt * PT_TO_MM + y_token_mm = y0_pt * PT_TO_MM + + # Gleichmäßige Skalierung anhand der viewBox-Breite (nicht width/height!) + skala = breite_mm / vb.vw + signatur_hoehe_mm = vb.vh * skala + + if position == "ueber": + y_sig = y_token_mm - abstand_mm - signatur_hoehe_mm + elif position == "unter": + y_sig = y_token_mm + abstand_mm + else: # "genau" – Signatur sitzt direkt an der Marker-Position (kein Abstand) + y_sig = y_token_mm + + # Feinjustierung links/rechts/oben/unten + x_sig = x_token_mm + versatz_x_mm + y_sig = y_sig + versatz_y_mm + + return x_sig, y_sig, skala, signatur_hoehe_mm + + +def build_signature_group(vb: ViewBox, rect: fitz.Rect, job: SignatureJob) -> str: + """Erzeugt die positionierte -Gruppe einer einzelnen Signatur (ohne A4-Wrapper).""" + x_sig, y_sig, skala, _ = signatur_geometrie( + rect.x0, rect.y0, vb, job.position, job.breite_mm, job.abstand_mm, + job.versatz_x_mm, job.versatz_y_mm, + ) + + # viewBox-Offset (minx/miny) in die Translation einrechnen, damit die linke + # Oberkante des Signatur-Inhalts exakt auf (x_sig, y_sig) liegt. + tx = x_sig - vb.minx * skala + ty = y_sig - vb.miny * skala + + # Positionierung und Signatur-eigene Attribute getrennt halten: + # äußere Gruppe = nur translate/scale, innere Gruppe = Styling der Original- + # (fill/stroke …). So kollidiert nie ein mitgeführtes Attribut mit dem transform. + attrs = dict(vb.group_attrs) + + # Signaturen werden immer als Single-Stroke gezeichnet: offene Kurven als Linie + # statt gefüllt. stroke-width in viewBox-Einheiten, damit nach scale(skala) die + # gewünschte mm-Stärke herauskommt. Greift auf Pfade ohne eigene fill/stroke-Angabe. + sw = job.linienstaerke_mm / skala if skala else job.linienstaerke_mm + stroke = attrs.get("stroke") + if not stroke or stroke == "none": + stroke = "#000000" + attrs.update({ + "fill": "none", + "stroke": stroke, + "stroke-width": f"{sw:.4f}", + "stroke-linecap": "round", + "stroke-linejoin": "round", + }) + + attr = "".join(f' {k}="{_xml_escape(str(v))}"' for k, v in attrs.items()) + inner_group = f"{vb.inner}" if attr else vb.inner + return ( + f'' + f"{inner_group}" + ) + + +def wrap_a4(groups: List[str]) -> str: + """Verpackt Signatur-Gruppen in eine A4-Plot-SVG (nur Signaturen, Rest transparent).""" + return ( + f'' + f"{''.join(groups)}" + ) + + +def build_a4_svg(vb: ViewBox, rect: fitz.Rect, job: SignatureJob) -> str: + """Bequemlichkeit: A4-SVG mit genau einer Signatur (v.a. für Tests).""" + return wrap_a4([build_signature_group(vb, rect, job)]) + + +def _xml_escape(v: str) -> str: + return (v.replace("&", "&").replace('"', """) + .replace("<", "<").replace(">", ">")) + + +# ── Hauptlogik: alle Jobs platzieren ──────────────────────────────────── +def place_all(pdf_bytes: bytes, svgs: dict, jobs: List[SignatureJob]): + """ + Platziert alle Jobs. Sammelt ALLE Fehler (kein stilles Überspringen). + + Ein Token darf MEHRFACH vorkommen (Unterschrift auf mehreren Seiten) – jedes + Vorkommen wird platziert. Pro betroffener Seite entsteht eine A4-SVG, die alle + Signaturen dieser Seite enthält. Nur 0 Treffer ist ein Fehler. + + Rückgabe: (pages, errors) + pages: List[PageRender] – je betroffene Seite eine A4-SVG + errors: List[str] – leer, wenn alles geklappt hat + """ + errors: List[str] = [] + # Pro Seite die platzierten Signatur-Gruppen sammeln: page_index -> [(token, group)] + page_items: dict = {} + + try: + doc = fitz.open(stream=pdf_bytes, filetype="pdf") + except Exception as e: + return [], [f"PDF konnte nicht geöffnet werden: {e}"] + + try: + for job in jobs: + # 1. Ist die zugeordnete SVG hochgeladen? + if job.svg_id not in svgs: + errors.append( + f"Token '{job.token}': SVG '{job.svg_id}' wurde nicht hochgeladen." + ) + continue + + # 2. viewBox lesbar? + try: + vb = parse_svg(svgs[job.svg_id]) + except PlacementError as e: + errors.append(f"Token '{job.token}': {e}") + continue + + # 3. Token im PDF vorhanden? (Mehrfachtreffer sind ausdrücklich erlaubt.) + occurrences = find_token_occurrences(doc, job.token) + if not occurrences: + errors.append(f"Token '{job.token}' nicht im PDF gefunden.") + continue + + for page_index, rect in occurrences: + group = build_signature_group(vb, rect, job) + # rect.x0 (x-Position des Tokens) mitführen, um später links→rechts zu ordnen + page_items.setdefault(page_index, []).append((rect.x0, job.token, group)) + finally: + doc.close() + + if errors: + return [], errors + + pages: List[PageRender] = [] + for page_index in sorted(page_items): + # Signaturen einer Seite links→rechts sortieren: der Plotter schreibt in + # dieser Richtung, also linke Signatur immer vor der rechten abarbeiten. + items = sorted(page_items[page_index], key=lambda t: t[0]) + a4 = wrap_a4([group for _, _, group in items]) + pages.append(PageRender( + page_index=page_index, + a4_svg=a4, + filename=f"seite_{page_index + 1}.svg", + tokens=[token for _, token, _ in items], + )) + return pages, errors + + +# ── Vorschau (vektoriell zusammengeführtes PDF) ───────────────────────── +def build_preview_pdf(pdf_bytes: bytes, pages: List[PageRender]) -> bytes: + """ + Baut ein Vorschau-PDF mit allen betroffenen Seiten und den Signaturen als Overlay. + + Gewählter Weg: Die fertige A4-Signatur-SVG (pro Seite) wird via PyMuPDF nach PDF + konvertiert und mit show_pdf_page() deckungsgleich über eine Kopie der + Originalseite gelegt. Begründung: bleibt voll vektoriell (keine Rasterungs- + Artefakte), nutzt exakt dieselbe Geometrie wie /render und kommt ohne zusätzliche + Pillow-Abhängigkeit aus. Es wird vorausgesetzt, dass die PDF-Seiten A4 sind. + """ + src = fitz.open(stream=pdf_bytes, filetype="pdf") + out = fitz.open() + + try: + for p in sorted(pages, key=lambda x: x.page_index): + # Originalseite 1:1 übernehmen … + out.insert_pdf(src, from_page=p.page_index, to_page=p.page_index) + page = out[-1] + # … und die A4-Signatur-SVG dieser Seite darüberlegen. + sig_svg = fitz.open(stream=p.a4_svg.encode("utf-8"), filetype="svg") + try: + sig_pdf = fitz.open("pdf", sig_svg.convert_to_pdf()) + page.show_pdf_page(page.rect, sig_pdf, 0) + sig_pdf.close() + finally: + sig_svg.close() + data = out.tobytes() + finally: + out.close() + src.close() + return data diff --git a/Docker/signature-service/docker-compose.yml b/Docker/signature-service/docker-compose.yml new file mode 100644 index 0000000..d06ba64 --- /dev/null +++ b/Docker/signature-service/docker-compose.yml @@ -0,0 +1,30 @@ +# Signatur-Plotter-Service hinter Nginx Proxy Manager. +# +# Der Container hängt im selben Docker-Netz wie der NPM (nginx-proxy-manager_default) +# und wird NICHT direkt nach außen veröffentlicht – NPM terminiert TLS und leitet die +# Domain api-sign.skrift.de an diesen Container weiter. +# +# Im NPM als Proxy Host anlegen: +# Domain Names : api-sign.skrift.de +# Scheme : http +# Forward Hostname : skrift-signature-service (= container_name) +# Forward Port : 8000 +# Websockets/Block : nach Bedarf, SSL-Zertifikat (Let's Encrypt) aktivieren. + +services: + signature-service: + build: . + image: skrift-signature-service + container_name: skrift-signature-service + restart: unless-stopped + # Nur im Docker-Netz erreichbar (kein host-port nötig, NPM proxyt intern). + expose: + - "8000" + networks: + - npm + +networks: + # Bereits vorhandenes Netz des Nginx Proxy Managers mitbenutzen. + npm: + name: nginx-proxy-manager_default + external: true diff --git a/Docker/signature-service/models.py b/Docker/signature-service/models.py new file mode 100644 index 0000000..5ad9e75 --- /dev/null +++ b/Docker/signature-service/models.py @@ -0,0 +1,29 @@ +"""Pydantic-Modelle für die Job-Definition des Signatur-Plotter-Service.""" + +from typing import Literal + +from pydantic import BaseModel, Field, field_validator + + +class SignatureJob(BaseModel): + """Ein Eintrag der Job-Definition: ordnet genau einen Marker-Token einer SVG zu.""" + + token: str # unsichtbarer Token im PDF, z.B. "§§SIG1§§" + svg_id: str # Dateiname der hochgeladenen SVG + # Lage relativ zum Token. "genau" = direkt an der Marker-Position (Standard), + # "ueber"/"unter" = mit Abstand darüber/darunter. + position: Literal["genau", "ueber", "unter"] = "genau" + breite_mm: float = Field(default=45.0, gt=0) # Zielbreite der Unterschrift + abstand_mm: float = Field(default=8.0, ge=0) # Abstand (nur bei ueber/unter relevant) + # Feinjustierung relativ zur berechneten Position (darf negativ sein): + versatz_x_mm: float = 0.0 # + = nach rechts, − = nach links + versatz_y_mm: float = 0.0 # + = nach unten, − = nach oben + # Signaturen werden grundsätzlich als Single-Stroke gezeichnet (keine Füllung). + linienstaerke_mm: float = Field(default=0.35, gt=0) # Linienstärke der Kontur + + @field_validator("token", "svg_id") + @classmethod + def _not_blank(cls, v: str) -> str: + if not v or not v.strip(): + raise ValueError("darf nicht leer sein") + return v diff --git a/Docker/signature-service/requirements.txt b/Docker/signature-service/requirements.txt new file mode 100644 index 0000000..f35d4e3 --- /dev/null +++ b/Docker/signature-service/requirements.txt @@ -0,0 +1,5 @@ +fastapi>=0.111.0 +uvicorn>=0.29.0 +PyMuPDF>=1.24.0 +python-multipart>=0.0.9 +pydantic>=2.0.0 diff --git a/Docker/signature-service/tests/test_core.py b/Docker/signature-service/tests/test_core.py new file mode 100644 index 0000000..8acdf66 --- /dev/null +++ b/Docker/signature-service/tests/test_core.py @@ -0,0 +1,286 @@ +"""Minimale Tests für die Kernlogik: Token-Suche, Geometrie, Fehlerfälle.""" + +import re + +import fitz +import pytest + +from core import ( + PT_TO_MM, + PlacementError, + build_a4_svg, + find_token_occurrences, + parse_svg, + place_all, + signatur_geometrie, +) +from models import SignatureJob + +# ── Test-Hilfen ───────────────────────────────────────────────────────── +SVG_OK = ( + b'' + b'' +) +SVG_NO_VIEWBOX = ( + b'' + b'' +) + + +def make_pdf(tokens_at): + """Erzeugt ein A4-PDF (1 Seite) mit den Token an gegebenen Punkt-Positionen.""" + doc = fitz.open() + page = doc.new_page(width=595.0, height=842.0) # A4 in pt + for token, (x, y) in tokens_at: + page.insert_text((x, y), token, fontsize=11) + pdf_bytes = doc.tobytes() + doc.close() + return pdf_bytes + + +def _translate_from_svg(a4_svg): + """Liest tx, ty aus dem ersten translate(...) des A4-SVG.""" + m = re.search(r"translate\(([-\d.]+),([-\d.]+)\)", a4_svg) + assert m, "kein translate gefunden" + return float(m.group(1)), float(m.group(2)) + + +def _scale_from_svg(a4_svg): + m = re.search(r"scale\(([-\d.]+)\)", a4_svg) + assert m, "kein scale gefunden" + return float(m.group(1)) + + +# ── viewBox / SVG ─────────────────────────────────────────────────────── +def test_parse_svg_liest_viewbox(): + vb = parse_svg(SVG_OK) + assert (vb.minx, vb.miny, vb.vw, vb.vh) == (0.0, 0.0, 100.0, 40.0) + assert "' + b'' + ) + vb = parse_svg(svg) + rect = fitz.Rect(72.0, 72.0, 120.0, 84.0) + job = SignatureJob(token="§§SIG1§§", svg_id="s.svg", position="unter") + a4 = build_a4_svg(vb, rect, job) + tx, ty = _translate_from_svg(a4) + skala = _scale_from_svg(a4) + # tx = x_sig - minx*skala + assert tx == pytest.approx(72.0 * PT_TO_MM - 10.0 * skala, abs=1e-3) + + +def test_versatz_verschiebt_links_rechts_oben_unten(): + vb = parse_svg(SVG_OK) + rect = fitz.Rect(72.0, 72.0, 120.0, 84.0) + base = SignatureJob(token="§§S§§", svg_id="s.svg", position="genau") + moved = SignatureJob(token="§§S§§", svg_id="s.svg", position="genau", + versatz_x_mm=5.0, versatz_y_mm=-3.0) + a4_base = build_a4_svg(vb, rect, base) + a4_moved = build_a4_svg(vb, rect, moved) + bx, by = _translate_from_svg(a4_base) + mx, my = _translate_from_svg(a4_moved) + assert mx == pytest.approx(bx + 5.0, abs=1e-3) # +x → nach rechts + assert my == pytest.approx(by - 3.0, abs=1e-3) # −y → nach oben + + +def test_immer_single_stroke_fill_none_und_stroke(): + """Signaturen werden immer als Single-Stroke gezeichnet (fill:none + stroke).""" + vb = parse_svg(SVG_OK) + rect = fitz.Rect(72.0, 72.0, 120.0, 84.0) + job = SignatureJob(token="§§S§§", svg_id="s.svg", position="genau", + breite_mm=50.0, linienstaerke_mm=0.5) + a4 = build_a4_svg(vb, rect, job) + assert 'fill="none"' in a4 + assert "stroke=" in a4 and "stroke-width=" in a4 + # stroke-width in viewBox-Einheiten: 0.5mm / skala (skala = 50/100 = 0.5) = 1.0 + import re + sw = float(re.search(r'stroke-width="([\d.]+)"', a4).group(1)) + assert sw == pytest.approx(1.0, abs=1e-3) + + +def test_root_transform_wird_nicht_uebernommen(): + """Ein transform auf der äußersten darf die Platzierung nicht verfälschen.""" + svg = ( + b'' + ) + vb = parse_svg(svg) + assert "transform" not in vb.group_attrs # nicht mitgeführt + assert vb.group_attrs.get("fill") == "blue" # echtes Styling bleibt + rect = fitz.Rect(72.0, 72.0, 120.0, 84.0) + a4 = build_a4_svg(vb, rect, SignatureJob(token="§§S§§", svg_id="s.svg")) + # Genau ein transform (die Positionierung), nicht zwei. + assert a4.count("transform=") == 1 + + +# ── Token-Suche ───────────────────────────────────────────────────────── +def test_find_token_einmal(): + pdf = make_pdf([("§§SIG1§§", (100.0, 200.0))]) + doc = fitz.open(stream=pdf, filetype="pdf") + occ = find_token_occurrences(doc, "§§SIG1§§") + doc.close() + assert len(occ) == 1 + page_index, rect = occ[0] + assert page_index == 0 + assert rect.x0 == pytest.approx(100.0, abs=2.0) # nahe Einfügepunkt + + +def test_find_token_nicht_gefunden(): + pdf = make_pdf([("§§SIG1§§", (100.0, 200.0))]) + doc = fitz.open(stream=pdf, filetype="pdf") + assert find_token_occurrences(doc, "§§SIG9§§") == [] + doc.close() + + +def test_find_token_mehrfach_erlaubt(): + # Derselbe Token mehrfach (z.B. Unterschrift auf mehreren Stellen) → alle Treffer. + pdf = make_pdf([("§§SIG1§§", (100.0, 200.0)), ("§§SIG1§§", (100.0, 400.0))]) + doc = fitz.open(stream=pdf, filetype="pdf") + occ = find_token_occurrences(doc, "§§SIG1§§") + doc.close() + assert len(occ) == 2 + + +# ── place_all: Gesamtfluss + Fehlerliste ──────────────────────────────── +def make_multipage_pdf(per_page_tokens): + """Erzeugt ein A4-PDF mit mehreren Seiten; pro Seite eine Liste (token, (x,y)).""" + doc = fitz.open() + for tokens in per_page_tokens: + page = doc.new_page(width=595.0, height=842.0) + for token, (x, y) in tokens: + page.insert_text((x, y), token, fontsize=11) + data = doc.tobytes() + doc.close() + return data + + +def test_place_all_erfolg(): + pdf = make_pdf([("§§SIG1§§", (100.0, 200.0))]) + jobs = [SignatureJob(token="§§SIG1§§", svg_id="unterschrift.svg", position="unter")] + pages, errors = place_all(pdf, {"unterschrift.svg": SVG_OK}, jobs) + assert errors == [] + assert len(pages) == 1 + p = pages[0] + assert p.filename == "seite_1.svg" + assert p.tokens == ["§§SIG1§§"] + assert p.a4_svg.startswith(" process.exit(r.statusCode === 200 ? 0 : 1))" + +# Start application +CMD ["node", "src/server.js"] diff --git a/Docker/skrift-backend/HANDSCHRIFT_VARIATIONEN.md b/Docker/skrift-backend/HANDSCHRIFT_VARIATIONEN.md new file mode 100644 index 0000000..79af986 --- /dev/null +++ b/Docker/skrift-backend/HANDSCHRIFT_VARIATIONEN.md @@ -0,0 +1,198 @@ +# Handschrift-Variationen - Konfiguration + +## Aktuelle Einstellungen + +### 1. Scriptalizer Error Frequency +``` +SCRIPTALIZER_ERR_FREQUENCY=0 +``` +- **Bedeutung:** Keine durchgestrichenen Wörter oder Tippfehler +- **Ergebnis:** Saubere, fehlerfreie Handschrift + +--- + +### 2. Wortabstands-Variation: **15%** + +**Implementierung:** `src/lib/svg-font-engine.js` + +```javascript +// Leerzeichen mit 15% Variation +const baseSpace = fontSizePx * 0.4; +const variation = baseSpace * 0.15 * (Math.sin(wordIndex * 2.5) * 0.5 + 0.5); +const spacePx = baseSpace + variation; +``` + +**Effekt:** +- Basis-Wortabstand: 40% der Schriftgröße (≈10.4 px bei 26px Font) +- Variation: ±1.56 px (15% von 10.4px) +- Natürliche, ungleichmäßige Abstände zwischen Wörtern + +--- + +### 3. Wort-Rotation (Schräglage): **±5%** (≈±2.5°) + +**Implementierung:** `src/lib/svg-font-engine.js` + +```javascript +// Rotation zwischen -2.5° und +2.5° (±0.044 radians) +const rotationVariation = 0.044 * (Math.sin(wordIndex * 3.7) * 0.5 + 0.5) - 0.022; +wordRotation = rotationVariation; + +// Matrix-Transformation mit Rotation +const cosR = Math.cos(wordRotation); +const sinR = Math.sin(wordRotation); +const transform = `matrix(${scale * cosR},${scale * sinR},${-scale * sinR},${-scale * cosR},${x},${baselineY})`; +``` + +**Effekt:** +- Jedes Wort bekommt eine individuelle, leichte Schräglage +- Rotation: -2.5° bis +2.5° (±0.044 Radians) +- Macht die Handschrift natürlicher und lebendiger +- Jedes Wort ist leicht unterschiedlich geneigt + +--- + +## Variation-Pattern + +### Sinuswellen-Basis +Beide Variationen verwenden Sinuswellen für natürliche, nicht-gleichförmige Muster: + +```javascript +Math.sin(wordIndex * 2.5) // Wortabstand (langsame Oszillation) +Math.sin(wordIndex * 3.7) // Rotation (schnellere Oszillation) +``` + +**Vorteil:** +- Keine zufälligen Sprünge +- Sanfte, natürliche Übergänge +- Reproduzierbar (gleicher Text → gleiche Variation) +- Kein Seed-Management nötig + +--- + +## Visuelle Beispiele + +### Ohne Variation (alt): +``` +Hallo Max Mustermann aus Berlin +``` +Alle Wörter perfekt gerade, gleiche Abstände + +### Mit Variation (neu): +``` +Hallo Max Mustermann aus Berlin + ↗ → ↘ → ↗ +``` +- Unterschiedliche Wortabstände (15% Variation) +- Leichte Schräglage pro Wort (±2.5°) + +--- + +## Technische Details + +### SVG Matrix-Transformation + +**Original (ohne Rotation):** +```xml + +``` + +**Neu (mit Rotation):** +```xml + +``` + +Die kleinen Werte in Position 2 und 3 der Matrix erzeugen die Rotation: +- Position 2 (b): `scale * sin(rotation)` ≈ 0.00004 +- Position 3 (c): `-scale * sin(rotation)` ≈ -0.00004 + +--- + +## Anpassung der Variationen + +### Wortabstand ändern + +**Datei:** `src/lib/svg-font-engine.js` (Zeile 187-189) + +```javascript +// Aktuell: 15% +const variation = baseSpace * 0.15 * (Math.sin(wordIndex * 2.5) * 0.5 + 0.5); + +// Mehr Variation (z.B. 25%): +const variation = baseSpace * 0.25 * (Math.sin(wordIndex * 2.5) * 0.5 + 0.5); + +// Weniger Variation (z.B. 8%): +const variation = baseSpace * 0.08 * (Math.sin(wordIndex * 2.5) * 0.5 + 0.5); +``` + +--- + +### Rotation (Schräglage) ändern + +**Datei:** `src/lib/svg-font-engine.js` (Zeile 200-202) + +```javascript +// Aktuell: ±2.5° (0.044 radians) +const rotationVariation = 0.044 * (Math.sin(wordIndex * 3.7) * 0.5 + 0.5) - 0.022; + +// Stärker schräg (z.B. ±5°): +const rotationVariation = 0.087 * (Math.sin(wordIndex * 3.7) * 0.5 + 0.5) - 0.0435; + +// Weniger schräg (z.B. ±1°): +const rotationVariation = 0.0175 * (Math.sin(wordIndex * 3.7) * 0.5 + 0.5) - 0.00875; + +// Keine Rotation (deaktivieren): +const rotationVariation = 0; +``` + +**Umrechnung Grad → Radians:** +``` +Radians = Grad × (π / 180) +±1° = ±0.0175 rad +±2.5° = ±0.044 rad +±5° = ±0.087 rad +``` + +--- + +## Test-Beispiel + +```bash +curl -X POST http://localhost:4000/api/preview/batch \ + -H "Content-Type: application/json" \ + -d '{ + "sessionId": "variation-demo", + "letters": [{ + "index": 0, + "format": "a4", + "font": "tilda", + "text": "Dies ist ein Test mit natürlicher Handschrift-Variation.\n\nDie Wörter haben unterschiedliche Abstände und eine leichte Schräglage.\n\nDas macht das Schriftbild authentischer!", + "placeholders": {} + }] + }' +``` + +--- + +## Performance + +**Impact:** +- Minimaler Overhead durch sin/cos Berechnungen +- Pro Zeichen: ~4 zusätzliche Operationen +- Bei 2000 Zeichen: ~0.5ms zusätzliche Verarbeitungszeit +- Vernachlässigbar im Vergleich zur Scriptalizer API (1-3 Sekunden) + +--- + +## Kompatibilität + +✅ **Alle SVG-Viewer** unterstützen Matrix-Transformationen +✅ **Alle Browser** (Chrome, Firefox, Safari, Edge) +✅ **Plotter-Software** verarbeitet transformierte Pfade korrekt +✅ **Keine Änderung der Pfad-Daten** (nur Transformation) + +--- + +**Version:** 1.1.0 +**Letzte Änderung:** 2026-01-02 +**Status:** Produktionsreif ✅ diff --git a/Docker/skrift-backend/README.md b/Docker/skrift-backend/README.md new file mode 100644 index 0000000..38ac946 --- /dev/null +++ b/Docker/skrift-backend/README.md @@ -0,0 +1,461 @@ +# Skrift Backend - Handwritten Document Generator + +Docker-basiertes Backend für die Generierung von handschriftlichen Dokumenten (Briefe, Postkarten, Umschläge) mit SVG-Output. + +## Features + +- **Preview-System**: Batch-Generierung von Vorschauen mit Caching (30 Briefe pro Batch) +- **Scriptalizer Integration**: Nutzt externe API für natürliche Handschrift-Variationen +- **SVG-Generierung**: Eigene Font-Engine für hochqualitative SVG-Ausgabe +- **Multi-Format Support**: A4, A6 (Hoch-/Querformat), C6, DIN Lang Umschläge +- **Platzhalter-System**: Automatische Ersetzung von `[[Platzhalter]]` mit CSV-Export +- **Rate Limiting**: Schutz vor API-Spam (konfigurierbar) +- **Docker-Ready**: Vollständig containerisiert mit docker-compose + +## Quick Start + +### 1. Konfiguration + +```bash +cp .env.example .env +# .env bearbeiten und Scriptalizer License Key eintragen +``` + +### 2. Lokal testen (ohne Docker) + +```bash +npm install +npm start +``` + +Server läuft auf: `http://localhost:4000` + +### 3. Mit Docker deployen + +```bash +docker-compose up -d +``` + +## API-Endpunkte + +### Health Check + +``` +GET /health +``` + +**Response:** +```json +{ + "status": "healthy", + "timestamp": "2026-01-15T12:00:00Z", + "uptime": 12345, + "scriptalizer": "configured", + "storage": { + "cache": true, + "output": true + } +} +``` + +--- + +### Preview Batch Generierung + +``` +POST /api/preview/batch +``` + +**Request Body:** +```json +{ + "sessionId": "uuid-abc-123", + "batchIndex": 0, + "forceRegenerate": false, + "config": { + "font": "tilda", + "letters": [ + { + "index": 0, + "format": "a4", + "text": "Hallo [[Vorname]], dein Code ist [[Gutscheincode]]...", + "placeholders": { + "Vorname": "Max", + "Nachname": "Mustermann", + "Strasse": "Hauptstr. 1", + "PLZ": "10115", + "Ort": "Berlin", + "Gutscheincode": "SAVE20" + } + } + ], + "envelopes": [ + { + "index": 0, + "format": "c6", + "type": "recipient", + "data": { + "Vorname": "Max", + "Nachname": "Mustermann", + "Strasse": "Hauptstr. 1", + "PLZ": "10115", + "Ort": "Berlin" + } + } + ] + } +} +``` + +**Response:** +```json +{ + "sessionId": "uuid-abc-123", + "files": [ + { + "type": "letter", + "index": 0, + "url": "/api/preview/uuid-abc-123/letter_000.svg" + }, + { + "type": "envelope", + "index": 0, + "url": "/api/preview/uuid-abc-123/envelope_000.svg" + } + ], + "csvUrl": "/api/preview/uuid-abc-123/platzhalter.csv", + "expiresAt": "2026-01-15T14:00:00Z" +} +``` + +**Rate Limit:** 2 Requests/Minute pro sessionId + +--- + +### Preview-Datei abrufen + +``` +GET /api/preview/:sessionId/:filename +``` + +**Beispiel:** +``` +GET /api/preview/uuid-abc-123/letter_000.svg +``` + +**Response:** SVG-Datei (Content-Type: image/svg+xml) + +--- + +### Bestellung finalisieren (aus Cache) + +``` +POST /api/order/finalize +``` + +**Request Body:** +```json +{ + "sessionId": "uuid-abc-123", + "orderNumber": "SK-2026-01-15-001" +} +``` + +**Response:** +```json +{ + "orderNumber": "SK-2026-01-15-001", + "outputPath": "/app/output/SK-2026-01-15-001", + "files": { + "letters": 100, + "envelopes": 100, + "csv": "platzhalter.csv" + }, + "timestamp": "2026-01-15T12:30:00Z" +} +``` + +--- + +### Bestellung neu generieren (ohne Cache) + +``` +POST /api/order/generate +``` + +**Request Body:** +```json +{ + "orderNumber": "SK-2026-01-15-002", + "config": { + "font": "tilda", + "letters": [...], + "envelopes": [...] + } +} +``` + +**Response:** Gleich wie `/api/order/finalize` + +--- + +## Formate + +### Schriftstücke (Letters) +- `a4` - A4 Hochformat (210 × 297 mm) +- `a6p` - A6 Hochformat (105 × 148 mm) +- `a6l` - A6 Querformat (148 × 105 mm) + +### Umschläge (Envelopes) +- `c6` - C6 Umschlag (162 × 114 mm) +- `din_lang` - DIN Lang Umschlag (220 × 110 mm) + +### Fonts +- `tilda` - PremiumUltra79 +- `alva` - PremiumUltra23 +- `ellie` - PremiumUltra39 + +--- + +## Umschlag-Typen + +### Empfänger-Adresse (type: "recipient") +Adresse wird **unten links** positioniert (kein Sichtfenster). + +```json +{ + "type": "recipient", + "data": { + "Vorname": "Max", + "Nachname": "Mustermann", + "Strasse": "Hauptstr. 1", + "PLZ": "10115", + "Ort": "Berlin" + } +} +``` + +### Individueller Text (type: "custom") +Text wird **mittig zentriert** positioniert. Max. 150 Zeichen. + +```json +{ + "type": "custom", + "data": { + "customText": "Für meine großartige Freundin Caro" + } +} +``` + +--- + +## Verzeichnisstruktur + +``` +/app/ +├── cache/ +│ └── previews/ +│ └── {sessionId}/ +│ ├── letter_000.svg +│ ├── envelope_000.svg +│ ├── platzhalter.csv +│ └── metadata.json +│ +├── output/ +│ └── {orderNumber}/ +│ ├── schriftstuecke/ +│ │ ├── brief_000.svg +│ │ └── ... +│ ├── umschlaege/ +│ │ ├── umschlag_000.svg +│ │ └── ... +│ ├── platzhalter.csv +│ └── order-metadata.json +│ +└── fonts/ + ├── tilda.svg + ├── alva.svg + └── ellie.svg +``` + +--- + +## Umgebungsvariablen + +```bash +# Node Environment +NODE_ENV=production + +# Scriptalizer API +SCRIPTALIZER_LICENSE_KEY=your-key-here +SCRIPTALIZER_ERR_FREQUENCY=10 + +# Preview System +BATCH_SIZE=30 +CACHE_LIFETIME_HOURS=2 +RATE_LIMIT_PER_MINUTE=2 + +# Server +PORT=4000 +CORS_ORIGIN=* +``` + +--- + +## Deployment + +### Auf Server (mit Docker) + +```bash +# .env Datei erstellen mit production values +docker-compose up -d + +# Logs ansehen +docker-compose logs -f + +# Stoppen +docker-compose down +``` + +### Nginx Proxy Manager Setup + +1. Proxy Host erstellen +2. Domain: `api.skrift.de` (oder deine Domain) +3. Forward Hostname/IP: `localhost` +4. Forward Port: `4000` +5. SSL Zertifikat über NPM erstellen + +--- + +## Entwicklung + +### Lokales Testen + +```bash +npm run dev # Mit nodemon +``` + +### Scriptalizer Separator Test + +```bash +npm run test:separator +``` + +### Logs + +```bash +# Docker logs +docker-compose logs -f skrift-backend + +# Lokale logs +# Output in console +``` + +--- + +## Integration mit N8N + +N8N kann direkt auf den `/app/output/{orderNumber}/` Ordner zugreifen: + +```javascript +// N8N Workflow (Beispiel) +const fs = require('fs'); +const orderPath = '/var/skrift-output/SK-2026-01-15-001'; + +// Lese alle SVGs +const letters = fs.readdirSync(`${orderPath}/schriftstuecke`); + +// Sende an Plotter +for (const file of letters) { + await sendToPlotter(`${orderPath}/schriftstuecke/${file}`); +} +``` + +--- + +## Fehlerbehandlung + +### HTTP Status Codes + +- `200` - Success +- `400` - Bad Request (z.B. ungültige Parameter) +- `404` - Not Found (z.B. Session nicht gefunden) +- `410` - Gone (z.B. Cache abgelaufen) +- `429` - Too Many Requests (Rate Limit) +- `500` - Internal Server Error +- `503` - Service Unavailable (z.B. Scriptalizer down) + +### Typische Fehler + +**Rate Limit überschritten:** +```json +{ + "error": "Zu viele Vorschau-Anfragen. Bitte warten Sie.", + "retryAfter": 45, + "message": "Limit: 2 Anfragen pro Minute" +} +``` + +**Scriptalizer Fehler:** +```json +{ + "error": "Scriptalizer request failed: timeout" +} +``` + +**Cache abgelaufen:** +```json +{ + "error": "Preview-Session abgelaufen. Bitte neu generieren." +} +``` + +--- + +## Limits + +- **Scriptalizer API**: 10.000 Calls/Tag +- **Batch Size**: 30 Briefe pro Request +- **Input Size**: 48KB pro Scriptalizer Call +- **Rate Limit**: 2 Preview-Requests/Minute +- **Cache Lifetime**: 2 Stunden + +--- + +## Troubleshooting + +### Fonts nicht gefunden + +```bash +# Fonts kopieren +cp /path/to/fonts/*.svg ./fonts/ +``` + +### Scriptalizer API Fehler + +```bash +# License Key prüfen +cat .env | grep SCRIPTALIZER_LICENSE_KEY + +# Test-Script ausführen +npm run test:separator +``` + +### Permissions Fehler + +```bash +# Cache/Output Ordner Permissions +chmod -R 755 cache output +``` + +--- + +## Weitere Infos + +- **Scriptalizer API**: [www.scriptalizer.co.uk](https://www.scriptalizer.co.uk) +- **Support**: Siehe Issues in Repository + +--- + +**Version**: 1.0.0 +**Last Updated**: 2026-01-01 diff --git a/Docker/skrift-backend/TILDA_API_BEISPIEL.md b/Docker/skrift-backend/TILDA_API_BEISPIEL.md new file mode 100644 index 0000000..5fd0239 --- /dev/null +++ b/Docker/skrift-backend/TILDA_API_BEISPIEL.md @@ -0,0 +1,331 @@ +# Tilda Font API-Aufruf Beispiel + +## Konfiguration +- **Error Frequency:** 0 (keine durchgestrichenen Wörter) +- **Wortabstand-Variation:** ±5% für natürlicheres Schriftbild +- **Font:** Tilda (Scriptalizer: PremiumUltra79) + +--- + +## 1. Preview-Generierung (empfohlen) + +### Endpoint +``` +POST http://localhost:4000/api/preview/batch +``` + +### Request Headers +``` +Content-Type: application/json +``` + +### Request Body +```json +{ + "sessionId": "meine-session-12345", + "letters": [ + { + "index": 0, + "format": "a4", + "font": "tilda", + "text": "Sehr geehrte Damen und Herren,\n\nhiermit möchten wir Ihnen mitteilen, dass Ihre Bestellung erfolgreich bearbeitet wurde.\n\nWir bedanken uns für Ihr Vertrauen und freuen uns auf eine weitere Zusammenarbeit.\n\nMit freundlichen Grüßen\nIhr Skrift-Team", + "placeholders": {} + } + ] +} +``` + +### cURL Beispiel +```bash +curl -X POST http://localhost:4000/api/preview/batch \ + -H "Content-Type: application/json" \ + -d '{ + "sessionId": "meine-session-12345", + "letters": [ + { + "index": 0, + "format": "a4", + "font": "tilda", + "text": "Sehr geehrte Damen und Herren,\n\nhiermit möchten wir Ihnen mitteilen, dass Ihre Bestellung erfolgreich bearbeitet wurde.\n\nWir bedanken uns für Ihr Vertrauen und freuen uns auf eine weitere Zusammenarbeit.\n\nMit freundlichen Grüßen\nIhr Skrift-Team", + "placeholders": {} + } + ] + }' +``` + +### Response +```json +{ + "sessionId": "meine-session-12345", + "files": [ + { + "index": 0, + "filename": "letter_000.svg", + "url": "/api/preview/meine-session-12345/letter_000.svg" + } + ], + "csvUrl": "/api/preview/meine-session-12345/placeholders.csv" +} +``` + +### Preview SVG abrufen +```bash +curl http://localhost:4000/api/preview/meine-session-12345/letter_000.svg -o preview.svg +``` + +--- + +## 2. Mit Platzhaltern + +### Request Body +```json +{ + "sessionId": "platzhalter-test", + "letters": [ + { + "index": 0, + "format": "a4", + "font": "tilda", + "text": "Sehr geehrte/r [[Vorname]] [[Nachname]],\n\nhiermit bestätigen wir Ihre Bestellung [[Bestellnummer]].\n\nIhr persönlicher Gutscheincode: [[Gutscheincode]]\nGültig bis: [[Ablaufdatum]]\n\nMit freundlichen Grüßen", + "placeholders": { + "Vorname": "Max", + "Nachname": "Mustermann", + "Bestellnummer": "SK-2026-001", + "Gutscheincode": "SAVE20", + "Ablaufdatum": "31.12.2026" + } + } + ] +} +``` + +--- + +## 3. Mehrere Briefe (Batch) + +### Request Body +```json +{ + "sessionId": "batch-test", + "letters": [ + { + "index": 0, + "format": "a4", + "font": "tilda", + "text": "Sehr geehrte/r [[Vorname]] [[Nachname]],\n\nvielen Dank für Ihre Bestellung!", + "placeholders": { + "Vorname": "Max", + "Nachname": "Mustermann" + } + }, + { + "index": 1, + "format": "a4", + "font": "tilda", + "text": "Sehr geehrte/r [[Vorname]] [[Nachname]],\n\nvielen Dank für Ihre Bestellung!", + "placeholders": { + "Vorname": "Anna", + "Nachname": "Schmidt" + } + }, + { + "index": 2, + "format": "a4", + "font": "tilda", + "text": "Sehr geehrte/r [[Vorname]] [[Nachname]],\n\nvielen Dank für Ihre Bestellung!", + "placeholders": { + "Vorname": "Thomas", + "Nachname": "Müller" + } + } + ] +} +``` + +**Hinweis:** Bis zu 30 Briefe pro Batch möglich (konfigurierbar) + +--- + +## 4. Bestellung finalisieren + +Nach der Vorschau kann die Bestellung finalisiert werden: + +### Endpoint +``` +POST http://localhost:4000/api/order/finalize +``` + +### Request Body +```json +{ + "sessionId": "meine-session-12345", + "orderNumber": "SK-2026-01-02-001" +} +``` + +### cURL Beispiel +```bash +curl -X POST http://localhost:4000/api/order/finalize \ + -H "Content-Type: application/json" \ + -d '{ + "sessionId": "meine-session-12345", + "orderNumber": "SK-2026-01-02-001" + }' +``` + +### Response +```json +{ + "orderNumber": "SK-2026-01-02-001", + "outputPath": "/app/output/SK-2026-01-02-001", + "files": [ + "letter_000.svg", + "placeholders.csv" + ], + "timestamp": "2026-01-02T09:11:00.000Z" +} +``` + +--- + +## 5. Direkte Bestellungs-Generierung (ohne Preview) + +### Endpoint +``` +POST http://localhost:4000/api/order/generate +``` + +### Request Body +```json +{ + "orderNumber": "SK-2026-01-02-002", + "letters": [ + { + "index": 0, + "format": "a4", + "font": "tilda", + "text": "Sehr geehrte Damen und Herren,\n\nhiermit bestätigen wir den Eingang Ihrer Bestellung.\n\nMit freundlichen Grüßen", + "placeholders": {} + } + ] +} +``` + +### cURL Beispiel +```bash +curl -X POST http://localhost:4000/api/order/generate \ + -H "Content-Type: application/json" \ + -d '{ + "orderNumber": "SK-2026-01-02-002", + "letters": [ + { + "index": 0, + "format": "a4", + "font": "tilda", + "text": "Sehr geehrte Damen und Herren,\n\nhiermit bestätigen wir den Eingang Ihrer Bestellung.\n\nMit freundlichen Grüßen", + "placeholders": {} + } + ] + }' +``` + +--- + +## Verfügbare Formate + +- **a4**: A4 Hochformat (210 × 297 mm) +- **a6p**: A6 Hochformat (105 × 148 mm) +- **a6l**: A6 Querformat (148 × 105 mm) + +--- + +## Verfügbare Fonts + +- **tilda**: Scriptalizer PremiumUltra79 +- **alva**: Scriptalizer PremiumUltra23 +- **ellie**: Scriptalizer PremiumUltra39 + +--- + +## Features + +✅ **Keine Fehler:** Error Frequency = 0 (keine durchgestrichenen Wörter) +✅ **Wortabstand-Variation:** ±5% natürliche Variation für realistischeres Schriftbild +✅ **Platzhalter:** Automatische Ersetzung von [[Platzhalter]] +✅ **CSV-Export:** Automatische Generierung der Platzhalter-Tabelle +✅ **Batch-Verarbeitung:** Bis zu 30 Briefe pro Request +✅ **Preview-Caching:** 2 Stunden Cache für schnellere Finalisierung +✅ **Rate Limiting:** 2 Requests/Minute Schutz + +--- + +## Output + +Die generierten SVG-Dateien befinden sich in: +- **Preview:** `/app/cache/previews/{sessionId}/` +- **Finale Bestellung:** `/app/output/{orderNumber}/` + +Struktur: +``` +output/SK-2026-01-02-001/ +├── letter_000.svg +├── letter_001.svg +├── placeholders.csv +└── order-metadata.json +``` + +--- + +## Beispiel Output + +**letter_000.svg:** +```xml + + + + + +``` + +Dateigröße: ~100-120 KB pro A4-Seite + +--- + +## WordPress Integration Beispiel + +```javascript +// Im WordPress Plugin +const response = await fetch('https://api.skrift.de/api/preview/batch', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + sessionId: generateUUID(), + letters: [ + { + index: 0, + format: 'a4', + font: 'tilda', + text: brieftext, + placeholders: platzhalterDaten + } + ] + }) +}); + +const data = await response.json(); +// Zeige Preview: data.files[0].url +``` + +--- + +**Stand:** 2026-01-02 +**Version:** 1.0.0 diff --git a/Docker/skrift-backend/docker-compose.yml b/Docker/skrift-backend/docker-compose.yml new file mode 100644 index 0000000..6dec2fd --- /dev/null +++ b/Docker/skrift-backend/docker-compose.yml @@ -0,0 +1,37 @@ +version: '3.8' + +services: + skrift-backend: + build: . + container_name: skrift-backend + restart: unless-stopped + ports: + - "4000:4000" + environment: + - NODE_ENV=${NODE_ENV:-production} + - PORT=4000 + - SCRIPTALIZER_LICENSE_KEY=${SCRIPTALIZER_LICENSE_KEY} + - SCRIPTALIZER_ERR_FREQUENCY=${SCRIPTALIZER_ERR_FREQUENCY:-10} + - BATCH_SIZE=${BATCH_SIZE:-30} + - CACHE_LIFETIME_HOURS=${CACHE_LIFETIME_HOURS:-2} + - RATE_LIMIT_PER_MINUTE=${RATE_LIMIT_PER_MINUTE:-2} + volumes: + - ./fonts:/app/fonts:ro # SVG Fonts (read-only) + - skrift-cache:/app/cache # Preview cache (temporary) + - /var/skrift-output:/app/output # Output files for N8N + networks: + - skrift-network + healthcheck: + test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:4000/health"] + interval: 30s + timeout: 3s + retries: 3 + start_period: 5s + +volumes: + skrift-cache: + driver: local + +networks: + skrift-network: + driver: bridge diff --git a/Docker/skrift-backend/fonts/alva.svg b/Docker/skrift-backend/fonts/alva.svg new file mode 100644 index 0000000..1f2d1c7 --- /dev/null +++ b/Docker/skrift-backend/fonts/alva.svg @@ -0,0 +1,2224 @@ + + + + Created by FontForge 20200511 at Fri Mar 22 09:00:32 2024 By convertio Copyright: Quantum Enterprises + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Docker/skrift-backend/fonts/ellie.svg b/Docker/skrift-backend/fonts/ellie.svg new file mode 100644 index 0000000..aa1584e --- /dev/null +++ b/Docker/skrift-backend/fonts/ellie.svg @@ -0,0 +1,2033 @@ + + + + +Created by FontForge 20200306 at Sun Aug 14 09:09:30 2022 + By convertio +Copyright: Quantum Enterprises + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Docker/skrift-backend/fonts/tilda.svg b/Docker/skrift-backend/fonts/tilda.svg new file mode 100644 index 0000000..fc517bd --- /dev/null +++ b/Docker/skrift-backend/fonts/tilda.svg @@ -0,0 +1,2319 @@ + + + + +Created by FontForge 20200511 at Wed Nov 19 10:12:13 2025 + By convertio +Copyright: Quantum Enterprises + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Docker/skrift-backend/package-lock.json b/Docker/skrift-backend/package-lock.json new file mode 100644 index 0000000..5d64b96 --- /dev/null +++ b/Docker/skrift-backend/package-lock.json @@ -0,0 +1,1624 @@ +{ + "name": "skrift-backend", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "skrift-backend", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "@paypal/paypal-server-sdk": "^1.0.0", + "cors": "^2.8.5", + "dotenv": "^16.4.5", + "express": "^4.19.0", + "multer": "^2.0.2", + "uuid": "^9.0.1", + "xml2js": "^0.6.2" + }, + "devDependencies": { + "nodemon": "^3.0.2" + } + }, + "node_modules/@apimatic/authentication-adapters": { + "version": "0.5.14", + "resolved": "https://registry.npmjs.org/@apimatic/authentication-adapters/-/authentication-adapters-0.5.14.tgz", + "integrity": "sha512-V7nhHShPrU8LfjKKHoVJNS50SveSL77CexVuS4aeQyXx99HwdQVJwl2MK0KAYM6/b2ufQbJ7Eee2fzQT0TVXSQ==", + "license": "MIT", + "dependencies": { + "@apimatic/core-interfaces": "^0.2.14", + "@apimatic/http-headers": "^0.3.8", + "@apimatic/http-query": "^0.3.9", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.15.0 || >=16.0.0" + } + }, + "node_modules/@apimatic/axios-client-adapter": { + "version": "0.3.21", + "resolved": "https://registry.npmjs.org/@apimatic/axios-client-adapter/-/axios-client-adapter-0.3.21.tgz", + "integrity": "sha512-pr/XvAvH9FjbpwM+B7vHQxM7alocOX1kLNtSpXKW3yxTYxksF3ydnUuQ85rRbCoNpyfMOIjnRBCNUBzX5p2Hnw==", + "license": "MIT", + "dependencies": { + "@apimatic/convert-to-stream": "^0.1.9", + "@apimatic/core-interfaces": "^0.2.14", + "@apimatic/file-wrapper": "^0.3.9", + "@apimatic/http-headers": "^0.3.8", + "@apimatic/http-query": "^0.3.9", + "@apimatic/json-bigint": "^1.2.0", + "@apimatic/proxy": "^0.1.4", + "axios": "^1.8.4", + "detect-browser": "^5.3.0", + "detect-node": "^2.1.0", + "form-data": "^4.0.1", + "lodash.flatmap": "^4.5.0", + "tiny-warning": "^1.0.3", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.15.0 || >=16.0.0" + } + }, + "node_modules/@apimatic/convert-to-stream": { + "version": "0.1.9", + "resolved": "https://registry.npmjs.org/@apimatic/convert-to-stream/-/convert-to-stream-0.1.9.tgz", + "integrity": "sha512-C9NEKnDZoTRBRVeUGXVyAEmy6P5o+8oLwEckTKj0iBlExJLEXNt14nf4wxfzRO1KR8j5Bw8S6yStKCrQzcVERA==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=14.15.0 || >=16.0.0" + } + }, + "node_modules/@apimatic/core": { + "version": "0.10.29", + "resolved": "https://registry.npmjs.org/@apimatic/core/-/core-0.10.29.tgz", + "integrity": "sha512-QhORiq0QbjlDMrw8ZZsAeG2DzE6QgGz5ukD5w2MOWE/3iIWnUDEROjmc2SfhyiGsE3GoEJ8cyhMMdjlOioP6ww==", + "license": "MIT", + "dependencies": { + "@apimatic/convert-to-stream": "^0.1.9", + "@apimatic/core-interfaces": "^0.2.14", + "@apimatic/file-wrapper": "^0.3.9", + "@apimatic/http-headers": "^0.3.8", + "@apimatic/http-query": "^0.3.9", + "@apimatic/json-bigint": "^1.2.0", + "@apimatic/schema": "^0.7.21", + "detect-browser": "^5.3.0", + "detect-node": "^2.1.0", + "form-data": "^4.0.1", + "lodash.defaultsdeep": "^4.6.1", + "lodash.flatmap": "^4.5.0", + "tiny-warning": "^1.0.3", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.15.0 || >=16.0.0" + } + }, + "node_modules/@apimatic/core-interfaces": { + "version": "0.2.14", + "resolved": "https://registry.npmjs.org/@apimatic/core-interfaces/-/core-interfaces-0.2.14.tgz", + "integrity": "sha512-PQmSU32ndxtDddMCjbkNY/sVvDwQAsHUGKrdG5aGVE7iw/qvB2Tm2zyCarOB5TlDr4OB+/tuLCVhji0icx6MHg==", + "license": "MIT", + "dependencies": { + "@apimatic/file-wrapper": "^0.3.9", + "@apimatic/json-bigint": "^1.2.0", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.15.0 || >=16.0.0" + } + }, + "node_modules/@apimatic/file-wrapper": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@apimatic/file-wrapper/-/file-wrapper-0.3.9.tgz", + "integrity": "sha512-Fh3UE7UPs2v4wkJdsD+uJFF147+7X0qkQfKBdeLZx6mZ5RmBJOBbS6ApvstQTV279YsHiiedKUZGJ6XLoVU+pQ==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=14.15.0 || >=16.0.0" + } + }, + "node_modules/@apimatic/http-headers": { + "version": "0.3.8", + "resolved": "https://registry.npmjs.org/@apimatic/http-headers/-/http-headers-0.3.8.tgz", + "integrity": "sha512-ShvCuT39hYfBTI+H1I16m5i6XZCyUy2kQJ6Jhfj78TwsW5r6AyCbzW7DEro8GN2nNYRU1+E/hrgH6J85YmriOA==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=14.15.0 || >=16.0.0" + } + }, + "node_modules/@apimatic/http-query": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@apimatic/http-query/-/http-query-0.3.9.tgz", + "integrity": "sha512-D6nqXcCR3P6iWbJ9uFXyyF2z1PEhTbGFbHNNuwF1NQ4tnThQk67DW9ou7/XcWi21zLh9MUchDWw9I0iE+5F2xA==", + "license": "MIT", + "dependencies": { + "@apimatic/core-interfaces": "^0.2.14", + "@apimatic/file-wrapper": "^0.3.9", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.15.0 || >=16.0.0" + } + }, + "node_modules/@apimatic/json-bigint": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@apimatic/json-bigint/-/json-bigint-1.2.0.tgz", + "integrity": "sha512-+bmVzYMdZu0Ya5L+my4FXFUih54OvQA/qlZsFOYdOoostyUuB27UDrVWQs/WVCmS0ADdo5vTU0eeTrrBkHoySw==", + "license": "MIT" + }, + "node_modules/@apimatic/oauth-adapters": { + "version": "0.4.18", + "resolved": "https://registry.npmjs.org/@apimatic/oauth-adapters/-/oauth-adapters-0.4.18.tgz", + "integrity": "sha512-MpRyBgKWg3yINQR85tBPmtU/596P0gdj4RmQ3s4D1LRuwDzqRO8GpRq74J8hxQ7vLQvH0OfGh6zzhr1KI4dQRQ==", + "license": "MIT", + "dependencies": { + "@apimatic/core-interfaces": "^0.2.14", + "@apimatic/file-wrapper": "^0.3.9", + "@apimatic/http-headers": "^0.3.8", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.15.0 || >=16.0.0" + } + }, + "node_modules/@apimatic/proxy": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/@apimatic/proxy/-/proxy-0.1.4.tgz", + "integrity": "sha512-Vzgfu7wcA5aEJyj2SjQ00Tb06fhBof8gDo1kSsF6sZBm4QjdFywN5AMbQwhfFOKjHqcsNmJspdeqcdymUQ77jA==", + "license": "MIT", + "dependencies": { + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6" + }, + "engines": { + "node": ">=14.15.0 || >=16.0.0" + } + }, + "node_modules/@apimatic/schema": { + "version": "0.7.21", + "resolved": "https://registry.npmjs.org/@apimatic/schema/-/schema-0.7.21.tgz", + "integrity": "sha512-RCke4toXjA7fBRxQVa1GR+Lj9utVOEJ3voDI26dhk+bZuAac4UXPzkTEaIO3AIe/o8pcKCOkpNIzhzm57Cv2Qg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.15.0 || >=16.0.0" + } + }, + "node_modules/@paypal/paypal-server-sdk": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@paypal/paypal-server-sdk/-/paypal-server-sdk-1.1.0.tgz", + "integrity": "sha512-MaJQmSVpQb5nEeZWZDTtGRxeaHj6O51G9Olmm3zQ5E4XNgR4emAI08CKC2ZPhBldVoxAoBVjLyvKjGV5lC1n2Q==", + "license": "MIT", + "dependencies": { + "@apimatic/authentication-adapters": "^0.5.4", + "@apimatic/axios-client-adapter": "^0.3.7", + "@apimatic/core": "^0.10.16", + "@apimatic/oauth-adapters": "^0.4.8", + "@apimatic/schema": "^0.7.14" + }, + "engines": { + "node": ">=14.17.0" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/append-field": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", + "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", + "license": "MIT" + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz", + "integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.4", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "dev": true, + "license": "MIT" + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/body-parser": { + "version": "1.20.4", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.14.0", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "dev": true, + "license": "MIT" + }, + "node_modules/concat-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", + "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", + "engines": [ + "node >= 6.0" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.0.2", + "typedarray": "^0.0.6" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "license": "MIT" + }, + "node_modules/cors": { + "version": "2.8.5", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/debug": { + "version": "2.6.9", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/detect-browser": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/detect-browser/-/detect-browser-5.3.0.tgz", + "integrity": "sha512-53rsFbGdwMwlF7qvCt0ypLM5V5/Mbl0szB7GPN8y9NCcbknYOeVVXdrXEq+90IwAfrrzt6Hd+u2E2ntakICU8w==", + "license": "MIT" + }, + "node_modules/detect-node": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", + "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==", + "license": "MIT" + }, + "node_modules/dotenv": { + "version": "16.6.1", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "4.22.1", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.3", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.14.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "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": { + "version": "1.1.2", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/http-proxy-agent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/http-proxy-agent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/https-proxy-agent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ignore-by-default": { + "version": "1.0.1", + "dev": true, + "license": "ISC" + }, + "node_modules/inherits": { + "version": "2.0.4", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/lodash.defaultsdeep": { + "version": "4.6.1", + "resolved": "https://registry.npmjs.org/lodash.defaultsdeep/-/lodash.defaultsdeep-4.6.1.tgz", + "integrity": "sha512-3j8wdDzYuWO3lM3Reg03MuQR957t287Rpcxp1njpEa8oDrikb+FwGdW3n+FELh/A6qib6yPit0j/pv9G/yeAqA==", + "license": "MIT" + }, + "node_modules/lodash.flatmap": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.flatmap/-/lodash.flatmap-4.5.0.tgz", + "integrity": "sha512-/OcpcAGWlrZyoHGeHh3cAoa6nGdX6QYtmzNP84Jqol6UEQQ2gIaU3H+0eICcjcKGl0/XF8LWOujNn9lffsnaOg==", + "license": "MIT" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "license": "MIT", + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "license": "MIT" + }, + "node_modules/multer": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.0.2.tgz", + "integrity": "sha512-u7f2xaZ/UG8oLXHvtF/oWTRvT44p9ecwBBqTwgJVq0+4BW1g8OW01TyMEGWBHbyMOYVHXslaut7qEQ1meATXgw==", + "license": "MIT", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.6.0", + "concat-stream": "^2.0.0", + "mkdirp": "^0.5.6", + "object-assign": "^4.1.1", + "type-is": "^1.6.18", + "xtend": "^4.0.2" + }, + "engines": { + "node": ">= 10.16.0" + } + }, + "node_modules/negotiator": { + "version": "0.6.3", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/nodemon": { + "version": "3.1.11", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^3.5.2", + "debug": "^4", + "ignore-by-default": "^1.0.1", + "minimatch": "^3.1.2", + "pstree.remy": "^1.1.8", + "semver": "^7.5.3", + "simple-update-notifier": "^2.0.0", + "supports-color": "^5.5.0", + "touch": "^3.1.0", + "undefsafe": "^2.0.5" + }, + "bin": { + "nodemon": "bin/nodemon.js" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nodemon" + } + }, + "node_modules/nodemon/node_modules/debug": { + "version": "4.4.3", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/nodemon/node_modules/ms": { + "version": "2.1.3", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.12", + "license": "MIT" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, + "node_modules/pstree.remy": { + "version": "1.1.8", + "dev": true, + "license": "MIT" + }, + "node_modules/qs": { + "version": "6.14.1", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "license": "MIT" + }, + "node_modules/sax": { + "version": "1.4.3", + "license": "BlueOak-1.0.0" + }, + "node_modules/semver": { + "version": "7.7.3", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "0.19.2", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.0", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/simple-update-notifier": { + "version": "2.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/supports-color": { + "version": "5.5.0", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/tiny-warning": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz", + "integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==", + "license": "MIT" + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/touch": { + "version": "3.1.1", + "dev": true, + "license": "ISC", + "bin": { + "nodetouch": "bin/nodetouch.js" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/type-is": { + "version": "1.6.18", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "license": "MIT" + }, + "node_modules/undefsafe": { + "version": "2.0.5", + "dev": true, + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/uuid": { + "version": "9.0.1", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/xml2js": { + "version": "0.6.2", + "license": "MIT", + "dependencies": { + "sax": ">=0.6.0", + "xmlbuilder": "~11.0.0" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/xmlbuilder": { + "version": "11.0.1", + "license": "MIT", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + } + } +} diff --git a/Docker/skrift-backend/package.json b/Docker/skrift-backend/package.json new file mode 100644 index 0000000..de9e2bf --- /dev/null +++ b/Docker/skrift-backend/package.json @@ -0,0 +1,29 @@ +{ + "name": "skrift-backend", + "version": "1.0.0", + "description": "Backend for Skrift handwritten document configurator", + "main": "src/server.js", + "scripts": { + "start": "node src/server.js", + "dev": "nodemon src/server.js" + }, + "keywords": [ + "skrift", + "handwriting", + "svg", + "scriptalizer" + ], + "author": "", + "license": "ISC", + "dependencies": { + "cors": "^2.8.5", + "dotenv": "^16.4.5", + "express": "^4.19.0", + "multer": "^2.0.2", + "uuid": "^9.0.1", + "xml2js": "^0.6.2" + }, + "devDependencies": { + "nodemon": "^3.0.2" + } +} diff --git a/Docker/skrift-backend/src/api/controllers/order-controller.js b/Docker/skrift-backend/src/api/controllers/order-controller.js new file mode 100644 index 0000000..0fdffe8 --- /dev/null +++ b/Docker/skrift-backend/src/api/controllers/order-controller.js @@ -0,0 +1,541 @@ +const fs = require('fs').promises; +const fsSync = require('fs'); +const path = require('path'); +const config = require('../../config'); +const { scriptalizeBatch } = require('../../services/scriptalizer-service'); +const { generateLetterSVG, generateEnvelopeSVG } = require('../../lib/svg-generator'); +const { replacePlaceholders, generateAllCSVs } = require('../../services/placeholder-service'); +const { TAB_MARKER } = require('../../lib/svg-font-engine'); + +/** + * POST /api/order/finalize + * Finalize order by copying cached previews to output directory + */ +async function finalizeOrder(req, res, next) { + try { + const { sessionId, orderNumber } = req.body; + + // Validation + if (!sessionId || !orderNumber) { + return res.status(400).json({ + error: 'Invalid request', + message: 'sessionId and orderNumber are required' + }); + } + + console.log(`[Order] Finalizing order: ${orderNumber} from session: ${sessionId}`); + + // Check if session cache exists + const sessionDir = path.join(config.paths.previews, sessionId); + + try { + await fs.access(sessionDir); + } catch { + return res.status(404).json({ + error: 'Session not found', + message: `Preview cache not found for session: ${sessionId}. Please generate previews first.` + }); + } + + // Check cache expiration + const metadataPath = path.join(sessionDir, '.metadata.json'); + try { + const metadataContent = await fs.readFile(metadataPath, 'utf8'); + const metadata = JSON.parse(metadataContent); + + const expiresAt = new Date(metadata.expiresAt); + if (new Date() > expiresAt) { + return res.status(410).json({ + error: 'Cache expired', + message: 'Preview cache has expired. Please regenerate previews.', + expiredAt: metadata.expiresAt + }); + } + } catch (err) { + console.warn(`[Order] Metadata not found for session: ${sessionId}`); + } + + // Create output directory and subdirectories + const outputDir = path.join(config.paths.output, orderNumber); + const envelopesDir = path.join(outputDir, 'umschlaege'); + await fs.mkdir(outputDir, { recursive: true }); + await fs.mkdir(envelopesDir, { recursive: true }); + + // Copy files from cache to output, separating letters and envelopes + const files = await fs.readdir(sessionDir); + const copiedLetters = []; + const copiedEnvelopes = []; + const copiedOther = []; + + for (const file of files) { + // Skip metadata file + if (file === '.metadata.json') { + continue; + } + + const sourcePath = path.join(sessionDir, file); + + // Determine destination based on file type + if (file.startsWith('envelope_')) { + // Umschläge in Unterordner 'umschlaege' + const destPath = path.join(envelopesDir, file); + await fs.copyFile(sourcePath, destPath); + copiedEnvelopes.push(file); + } else if (file.startsWith('letter_')) { + // Briefe im Hauptordner + const destPath = path.join(outputDir, file); + await fs.copyFile(sourcePath, destPath); + copiedLetters.push(file); + } else { + // Andere Dateien (CSV, etc.) im Hauptordner + const destPath = path.join(outputDir, file); + await fs.copyFile(sourcePath, destPath); + copiedOther.push(file); + } + } + + // Create order metadata + const meta = req.body.metadata || {}; + const orderMetadata = { + orderNumber, + sessionId, + finalizedAt: new Date().toISOString(), + letterCount: copiedLetters.length, + envelopeCount: copiedEnvelopes.length, + letters: copiedLetters, + envelopes: copiedEnvelopes, + other: copiedOther, + letterFormat: meta.letterFormat || null, + envelopeFormat: meta.envelopeFormat || null, + envelopeBeschriftung: meta.envelopeBeschriftung || null, + }; + + const orderMetadataPath = path.join(outputDir, 'order-metadata.json'); + await fs.writeFile(orderMetadataPath, JSON.stringify(orderMetadata, null, 2), 'utf8'); + + const totalFiles = copiedLetters.length + copiedEnvelopes.length + copiedOther.length; + console.log(`[Order] Finalized order ${orderNumber}: ${copiedLetters.length} letters, ${copiedEnvelopes.length} envelopes, ${copiedOther.length} other files`); + + // Response + res.status(200).json({ + orderNumber, + outputPath: outputDir, + letters: copiedLetters, + envelopes: copiedEnvelopes, + other: copiedOther, + totalFiles, + envelopesGenerated: 0, + timestamp: orderMetadata.finalizedAt + }); + + } catch (err) { + console.error('[Order] Error finalizing order:', err); + next(err); + } +} + +/** + * POST /api/order/generate + * Generate order from scratch without using cache + */ +async function generateOrder(req, res, next) { + try { + const { orderNumber, letters } = req.body; + + // Validation + if (!orderNumber) { + return res.status(400).json({ + error: 'Invalid request', + message: 'orderNumber is required' + }); + } + + if (!letters || !Array.isArray(letters) || letters.length === 0) { + return res.status(400).json({ + error: 'Invalid request', + message: 'Letters array is required and must not be empty' + }); + } + + console.log(`[Order] Generating order: ${orderNumber}`); + console.log(`[Order] Total documents: ${letters.length}`); + + // Debug: Zeige welche Dokument-Typen ankommen + const letterCount = letters.filter(l => l.type !== 'envelope').length; + const envelopeCount = letters.filter(l => l.type === 'envelope').length; + console.log(`[Order] Letters: ${letterCount}, Envelopes: ${envelopeCount}`); + console.log(`[Order] Document types:`, letters.map(l => ({ index: l.index, type: l.type, envelopeType: l.envelopeType }))); + + // Create output directory and subdirectories + const outputDir = path.join(config.paths.output, orderNumber); + const envelopesDir = path.join(outputDir, 'umschlaege'); + await fs.mkdir(outputDir, { recursive: true }); + await fs.mkdir(envelopesDir, { recursive: true }); + + // Step 1: Prepare texts and replace placeholders + const processedLetters = letters.map((letter, loopIndex) => { + let text = letter.text || ''; + + // Signatur-Platzhalter separat extrahieren – sie werden NICHT als Text ersetzt + const sigPlaceholders = {}; + const nonSigPlaceholders = {}; + if (letter.placeholders && typeof letter.placeholders === 'object') { + Object.entries(letter.placeholders).forEach(([k, v]) => { + if (/^signatur\d+$/i.test(k)) sigPlaceholders[k.toLowerCase()] = v; + else nonSigPlaceholders[k] = v; + }); + text = replacePlaceholders(text, nonSigPlaceholders); + } + + return { + ...letter, + index: letter.index !== undefined ? letter.index : loopIndex, + loopIndex, + processedText: text, + signatures: sigPlaceholders, + format: letter.format || 'a4', + font: letter.font || 'tilda', + type: letter.type || 'letter', + envelopeType: letter.envelopeType || 'recipient', + realisticHandwriting: letter.realisticHandwriting ?? false, + }; + }); + + // Step 2: Group letters by font for batch scriptalization + const lettersByFont = {}; + + processedLetters.forEach(letter => { + const font = letter.font; + if (!lettersByFont[font]) { + lettersByFont[font] = []; + } + lettersByFont[font].push(letter); + }); + + // Step 3: Scriptalize texts by font + const scriptalizedMap = new Map(); + + for (const [font, fontLetters] of Object.entries(lettersByFont)) { + // Tabs (\t) und Signatur-Marker ([[signaturN]]) vor Scriptalisierung extrahieren, + // danach mit TAB_MARKER bzw. [[signaturN]] restaurieren. + const scriptalizerInputs = []; + const splitMaps = []; + + for (const letter of fontLetters) { + const raw = letter.processedText; + const parts = raw.split(/(\t|\[\[signatur\d+\]\]|[&|])/gi); + const segments = [], markers = []; + for (let i = 0; i < parts.length; i++) { + if (i % 2 === 0) segments.push(parts[i]); + else markers.push(parts[i]); + } + // Leere Segmente NICHT an den Scriptalizer senden + const startIdx = scriptalizerInputs.length; + const hasContent = segments.map(s => s.trim().length > 0); + segments.forEach((s, j) => { if (hasContent[j]) scriptalizerInputs.push(s); }); + splitMaps.push({ loopIndex: letter.loopIndex, startIdx, segments, markers, hasContent, fallback: raw }); + } + + console.log(`[Order] Scriptalizing ${scriptalizerInputs.length} segments with font: ${font}`); + + try { + const scriptalizedAll = await scriptalizeBatch(scriptalizerInputs, font, config.scriptalizer.errFrequency); + + for (const { loopIndex, startIdx, segments, markers, hasContent, fallback } of splitMaps) { + const nonEmptyCount = hasContent.filter(Boolean).length; + if (!nonEmptyCount && !markers.length) { scriptalizedMap.set(loopIndex, fallback); continue; } + let si = startIdx; + const segs = segments.map((_s, j) => hasContent[j] ? (scriptalizedAll[si++] || '') : ''); + let reconstructed = segs[0] || ''; + for (let i = 0; i < markers.length; i++) { + reconstructed += (markers[i] === '\t' ? TAB_MARKER : markers[i]); + reconstructed += (segs[i + 1] || ''); + } + scriptalizedMap.set(loopIndex, reconstructed); + } + } catch (err) { + console.error(`[Order] Scriptalization failed for font ${font}:`, err.message); + return res.status(500).json({ + error: 'Scriptalization failed', + message: `Failed to scriptalize texts with font ${font}: ${err.message}` + }); + } + } + + // Step 4: Generate SVG files - separate letters and envelopes + const generatedLetters = []; + const generatedEnvelopes = []; + + for (let i = 0; i < processedLetters.length; i++) { + const letter = processedLetters[i]; + // Verwende loopIndex für scriptalizedMap Zuordnung + const scriptalizedText = scriptalizedMap.get(letter.loopIndex) || letter.processedText; + + let svgResult; + + try { + if (letter.type === 'envelope') { + svgResult = generateEnvelopeSVG( + scriptalizedText, + letter.format, + letter.envelopeType, + { font: letter.font, realisticHandwriting: letter.realisticHandwriting ?? false } + ); + } else { + svgResult = generateLetterSVG( + scriptalizedText, + letter.format, + { font: letter.font, realisticHandwriting: letter.realisticHandwriting ?? false, signatures: letter.signatures || {} } + ); + } + } catch (err) { + console.error(`[Order] SVG generation failed for letter ${i}:`, err.message); + return res.status(500).json({ + error: 'SVG generation failed', + message: `Failed to generate SVG for letter ${i}: ${err.message}` + }); + } + + // Save SVG file - unterscheide zwischen Briefen und Umschlägen + const absoluteIndex = letter.index !== undefined ? letter.index : i; + + if (letter.type === 'envelope') { + const filename = `envelope_${String(absoluteIndex).padStart(3, '0')}.svg`; + const filepath = path.join(envelopesDir, filename); + await fs.writeFile(filepath, svgResult.svg, 'utf8'); + generatedEnvelopes.push(filename); + } else { + const filename = `letter_${String(absoluteIndex).padStart(3, '0')}.svg`; + const filepath = path.join(outputDir, filename); + await fs.writeFile(filepath, svgResult.svg, 'utf8'); + generatedLetters.push(filename); + } + } + + console.log(`[Order] Generated ${generatedLetters.length} letters, ${generatedEnvelopes.length} envelopes`); + + // Step 5: Generate all CSV files if placeholders are present + const hasPlaceholders = processedLetters.some(l => l.placeholders && Object.keys(l.placeholders).length > 0); + const csvFiles = []; + + if (hasPlaceholders) { + try { + const csvs = generateAllCSVs(processedLetters); + + // Brief-Platzhalter CSV + if (csvs.placeholders) { + const filename = 'brief_platzhalter.csv'; + await fs.writeFile(path.join(outputDir, filename), csvs.placeholders, 'utf8'); + csvFiles.push(filename); + console.log(`[Order] Generated brief_platzhalter.csv`); + } + + // Empfänger CSV (für recipientData-Modus) + if (csvs.recipients) { + const filename = 'empfaenger.csv'; + await fs.writeFile(path.join(outputDir, filename), csvs.recipients, 'utf8'); + csvFiles.push(filename); + console.log(`[Order] Generated empfaenger.csv`); + } + + // Umschlag-Platzhalter CSV (für customText-Modus) + if (csvs.envelopePlaceholders) { + const filename = 'umschlag_platzhalter.csv'; + await fs.writeFile(path.join(outputDir, filename), csvs.envelopePlaceholders, 'utf8'); + csvFiles.push(filename); + console.log(`[Order] Generated umschlag_platzhalter.csv`); + } + } catch (err) { + console.error('[Order] CSV generation failed:', err.message); + // Don't fail the request, CSV is optional + } + } + + // Step 6: Create order metadata + const meta = req.body.metadata || {}; + const orderMetadata = { + orderNumber, + generatedAt: new Date().toISOString(), + letterCount: generatedLetters.length, + envelopeCount: generatedEnvelopes.length, + letters: generatedLetters, + envelopes: generatedEnvelopes, + fonts: Object.keys(lettersByFont), + hasPlaceholders, + csvFiles, + letterFormat: meta.letterFormat || null, + envelopeFormat: meta.envelopeFormat || null, + envelopeBeschriftung: meta.envelopeBeschriftung || null, + }; + + const orderMetadataPath = path.join(outputDir, 'order-metadata.json'); + await fs.writeFile(orderMetadataPath, JSON.stringify(orderMetadata, null, 2), 'utf8'); + + console.log(`[Order] Order ${orderNumber} generated successfully`); + + // Response + res.status(200).json({ + orderNumber, + outputPath: outputDir, + letters: generatedLetters, + envelopes: generatedEnvelopes, + csvFiles, + totalFiles: generatedLetters.length + generatedEnvelopes.length + csvFiles.length, + timestamp: orderMetadata.generatedAt + }); + + } catch (err) { + console.error('[Order] Error generating order:', err); + next(err); + } +} + +/** + * POST /api/order/motif + * Upload a motif image for an order + */ +async function uploadMotif(req, res, next) { + try { + const { orderNumber } = req.body; + + // Validation + if (!orderNumber) { + return res.status(400).json({ + error: 'Invalid request', + message: 'orderNumber is required' + }); + } + + if (!req.file) { + return res.status(400).json({ + error: 'Invalid request', + message: 'No file uploaded' + }); + } + + console.log(`[Order] Uploading motif for order: ${orderNumber}`); + console.log(`[Order] File: ${req.file.originalname}, Size: ${req.file.size} bytes, Type: ${req.file.mimetype}`); + + // Create output directory if it doesn't exist + const outputDir = path.join(config.paths.output, orderNumber); + await fs.mkdir(outputDir, { recursive: true }); + + // Determine file extension + const originalExt = path.extname(req.file.originalname).toLowerCase() || '.png'; + const filename = `motif${originalExt}`; + const filepath = path.join(outputDir, filename); + + // Write file to disk + await fs.writeFile(filepath, req.file.buffer); + + console.log(`[Order] Motif saved to: ${filepath}`); + + // Response + res.status(200).json({ + success: true, + orderNumber, + filename, + path: filepath, + size: req.file.size, + mimetype: req.file.mimetype + }); + + } catch (err) { + console.error('[Order] Error uploading motif:', err); + next(err); + } +} + +/** + * POST /api/order/files + * Speichert eine bereits vom WordPress-Frontend hochgeladene Datei + * (Adress-/Platzhalter-Excel, Briefdokument) im Auftragsordner ab, + * damit sie zusammen mit den generierten Umschlägen vorliegt. + */ +async function uploadOrderFile(req, res, next) { + try { + const { orderNumber, fileType } = req.body; + + if (!orderNumber || !fileType) { + return res.status(400).json({ + error: 'Invalid request', + message: 'orderNumber and fileType are required' + }); + } + + if (!req.file) { + return res.status(400).json({ + error: 'Invalid request', + message: 'No file uploaded' + }); + } + + const outputDir = path.join(config.paths.output, orderNumber, 'dateien'); + await fs.mkdir(outputDir, { recursive: true }); + + const ext = path.extname(req.file.originalname) || ''; + const filename = `${fileType}${ext}`; + const filepath = path.join(outputDir, filename); + + await fs.writeFile(filepath, req.file.buffer); + + console.log(`[Order] File saved for order ${orderNumber}: ${filename}`); + + res.status(200).json({ + success: true, + orderNumber, + fileType, + filename, + path: filepath, + size: req.file.size, + }); + + } catch (err) { + console.error('[Order] Error saving order file:', err); + next(err); + } +} + +/** + * POST /api/order/register + * Speichert Metadaten für einen Auftrag ohne SVG-Generierung (z.B. gedruckte Briefe) + */ +async function registerOrder(req, res, next) { + try { + const { orderNumber, metadata } = req.body; + + if (!orderNumber) { + return res.status(400).json({ error: 'Invalid request', message: 'orderNumber is required' }); + } + + const outputDir = path.join(config.paths.output, orderNumber); + await fs.mkdir(outputDir, { recursive: true }); + + const orderMetadata = { + orderNumber, + registeredAt: new Date().toISOString(), + ...(metadata || {}), + }; + + await fs.writeFile( + path.join(outputDir, 'order-metadata.json'), + JSON.stringify(orderMetadata, null, 2), + 'utf8' + ); + + console.log(`[Order] Registered order ${orderNumber}`); + res.status(200).json({ orderNumber, registeredAt: orderMetadata.registeredAt }); + + } catch (err) { + console.error('[Order] Error registering order:', err); + next(err); + } +} + +module.exports = { + finalizeOrder, + generateOrder, + uploadMotif, + uploadOrderFile, + registerOrder, +}; diff --git a/Docker/skrift-backend/src/api/controllers/preview-controller.js b/Docker/skrift-backend/src/api/controllers/preview-controller.js new file mode 100644 index 0000000..c0fcbd4 --- /dev/null +++ b/Docker/skrift-backend/src/api/controllers/preview-controller.js @@ -0,0 +1,362 @@ +const fs = require("fs").promises; +const fsSync = require("fs"); +const path = require("path"); +const { v4: uuidv4 } = require("uuid"); +const config = require("../../config"); +const { scriptalizeBatch } = require("../../services/scriptalizer-service"); +const { + generateLetterSVG, + generateEnvelopeSVG, +} = require("../../lib/svg-generator"); +const { + replacePlaceholders, + generateAllCSVs, +} = require("../../services/placeholder-service"); +const { TAB_MARKER } = require("../../lib/svg-font-engine"); + +/** + * POST /api/preview/batch + * Generate preview batch with rate limiting + */ +async function generateBatch(req, res, next) { + try { + const { sessionId, letters } = req.body; + + // Validation + if (!letters || !Array.isArray(letters) || letters.length === 0) { + return res.status(400).json({ + error: "Invalid request", + message: "Letters array is required and must not be empty", + }); + } + + // KEIN Limit mehr - Frontend sendet ALLE Dokumente auf einmal + // Backend teilt sie intern in 25er Batches für Scriptalizer auf + console.log(`[Preview] Received ${letters.length} letters (no limit)`); + + // Optional: Warn if extremely large + if (letters.length > 1000) { + console.warn(`[Preview] Large batch detected: ${letters.length} letters`); + } + + // Generate or validate sessionId + const finalSessionId = sessionId || uuidv4(); + + console.log( + `[Preview] Starting batch generation for session: ${finalSessionId}` + ); + console.log(`[Preview] Batch size: ${letters.length} letters`); + + // Create session cache directory + const sessionDir = path.join(config.paths.previews, finalSessionId); + await fs.mkdir(sessionDir, { recursive: true }); + + // Step 1: Prepare texts and replace placeholders + console.log( + "[Preview] Received indices from frontend:", + letters.map((l) => l.index) + ); + + const processedLetters = letters.map((letter, loopIndex) => { + let text = letter.text || ""; + + // Signatur-Platzhalter separat extrahieren – sie werden NICHT als Text ersetzt + const sigPlaceholders = {}; + const nonSigPlaceholders = {}; + if (letter.placeholders && typeof letter.placeholders === "object") { + Object.entries(letter.placeholders).forEach(([k, v]) => { + if (/^signatur\d+$/i.test(k)) sigPlaceholders[k.toLowerCase()] = v; + else nonSigPlaceholders[k] = v; + }); + text = replacePlaceholders(text, nonSigPlaceholders); + } + + return { + ...letter, + index: letter.index !== undefined ? letter.index : loopIndex, + processedText: text, + signatures: sigPlaceholders, + format: letter.format || "a4", + font: letter.font || "tilda", + type: letter.type || "letter", + envelopeType: letter.envelopeType || "recipient", + realisticHandwriting: letter.realisticHandwriting ?? false, + }; + }); + + console.log( + "[Preview] Final indices being used for files:", + processedLetters.map((l) => l.index) + ); + + // Step 2: Batch scriptalize texts. + // Tabs (\t) und Signatur-Marker ([[signaturN]]) werden vor der Scriptalisierung + // extrahiert, anschliessend wieder restauriert (TAB_MARKER für Tabs, {{signaturN}} unverändert). + const font = processedLetters[0]?.font || "tilda"; + const scriptalizerInputs = []; + const splitMaps = []; // pro Brief: { segments[], markers[] } + + for (const letter of processedLetters) { + const raw = letter.processedText; + // Splitten an \t UND [[signaturN]] (mit capture-Gruppe → Marker bleiben erhalten) + const parts = raw.split(/(\t|\[\[signatur\d+\]\]|[&|])/gi); + const segments = [], markers = []; + for (let i = 0; i < parts.length; i++) { + if (i % 2 === 0) segments.push(parts[i]); + else markers.push(parts[i]); + } + // Leere Segmente NICHT an den Scriptalizer senden (API könnte sie ablehnen) + const startIdx = scriptalizerInputs.length; + const hasContent = segments.map(s => s.trim().length > 0); + segments.forEach((s, j) => { if (hasContent[j]) scriptalizerInputs.push(s); }); + splitMaps.push({ startIdx, segments, markers, hasContent }); + } + + console.log( + `[Preview] Scriptalizing ${scriptalizerInputs.length} segments with font: ${font}` + ); + + let scriptalizedAll; + try { + scriptalizedAll = await scriptalizeBatch( + scriptalizerInputs, + font, + config.scriptalizer.errFrequency + ); + } catch (err) { + console.error("[Preview] Scriptalization failed:", err.message); + return res.status(500).json({ + error: "Scriptalization failed", + message: err.message, + }); + } + + // Segmente und Marker wieder zusammensetzen; \t → TAB_MARKER, [[signaturN]] unverändert + // Leere Segmente werden als '' wiederhergestellt (wurden nicht scriptalisiert) + const scriptalizedTexts = splitMaps.map(({ startIdx, segments, markers, hasContent }) => { + let si = startIdx; + const segs = segments.map((_s, j) => hasContent[j] ? (scriptalizedAll[si++] || '') : ''); + let result = segs[0] || ''; + for (let i = 0; i < markers.length; i++) { + result += (markers[i] === '\t' ? TAB_MARKER : markers[i]); + result += (segs[i + 1] || ''); + } + return result; + }); + + if (scriptalizedTexts.length !== processedLetters.length) { + console.warn( + `[Preview] Scriptalization mismatch: expected ${processedLetters.length}, got ${scriptalizedTexts.length}` + ); + } + + // Step 3: Generate SVG files + const files = []; + let hasOverflow = false; + + for (let i = 0; i < processedLetters.length; i++) { + const letter = processedLetters[i]; + const scriptalizedText = scriptalizedTexts[i] || letter.processedText; + + let svgResult; + + try { + if (letter.type === "envelope") { + svgResult = generateEnvelopeSVG( + scriptalizedText, + letter.format, + letter.envelopeType, + { font: letter.font, realisticHandwriting: letter.realisticHandwriting ?? false } + ); + } else { + // Briefe: Zeileninfo mit zurückgeben + svgResult = generateLetterSVG(scriptalizedText, letter.format, { + font: letter.font, + realisticHandwriting: letter.realisticHandwriting ?? false, + signatures: letter.signatures || {}, + }); + } + } catch (err) { + console.error( + `[Preview] SVG generation failed for letter ${i}:`, + err.message + ); + return res.status(500).json({ + error: "SVG generation failed", + message: `Failed to generate SVG for letter ${i}: ${err.message}`, + }); + } + + // Track overflow + if (svgResult.overflow) { + hasOverflow = true; + console.log(`[Preview] Letter ${i} has overflow: ${svgResult.lineCount}/${svgResult.lineLimit} lines`); + } + + // Save SVG file - verwende den absoluten Index aus letter + // Unterscheide zwischen Briefen und Umschlägen im Dateinamen + const absoluteIndex = letter.index !== undefined ? letter.index : i; + const prefix = letter.type === 'envelope' ? 'envelope' : 'letter'; + const filename = `${prefix}_${String(absoluteIndex).padStart(3, "0")}.svg`; + const filepath = path.join(sessionDir, filename); + + await fs.writeFile(filepath, svgResult.svg, "utf8"); + + // Empfänger-Info für bessere Fehlermeldung + const recipientName = letter.placeholders?.name || letter.placeholders?.vorname || `Brief ${absoluteIndex + 1}`; + + files.push({ + index: absoluteIndex, + filename, + url: `/api/preview/${finalSessionId}/${filename}`, + lineCount: svgResult.lineCount, + lineLimit: svgResult.lineLimit, + overflow: svgResult.overflow, + recipientName: letter.type !== 'envelope' ? recipientName : null, + }); + } + + console.log(`[Preview] Generated ${files.length} SVG files`); + + // Step 4: Generate all CSV files (placeholders, recipients, envelope placeholders, free addresses) + const csvUrls = {}; + const hasPlaceholders = processedLetters.some((l) => l.placeholders && Object.keys(l.placeholders).length > 0); + const hasFreeAddresses = processedLetters.some((l) => l.type === 'envelope' && l.envelopeType === 'free'); + + if (hasPlaceholders || hasFreeAddresses) { + try { + const csvs = generateAllCSVs(processedLetters); + + // Brief-Platzhalter CSV + if (csvs.placeholders) { + const filename = "brief_platzhalter.csv"; + await fs.writeFile(path.join(sessionDir, filename), csvs.placeholders, "utf8"); + csvUrls.placeholders = `/api/preview/${finalSessionId}/${filename}`; + console.log(`[Preview] Generated brief_platzhalter.csv`); + } + + // Empfänger CSV (für recipientData-Modus) + if (csvs.recipients) { + const filename = "empfaenger.csv"; + await fs.writeFile(path.join(sessionDir, filename), csvs.recipients, "utf8"); + csvUrls.recipients = `/api/preview/${finalSessionId}/${filename}`; + console.log(`[Preview] Generated empfaenger.csv`); + } + + // Umschlag-Platzhalter CSV (für customText-Modus) + if (csvs.envelopePlaceholders) { + const filename = "umschlag_platzhalter.csv"; + await fs.writeFile(path.join(sessionDir, filename), csvs.envelopePlaceholders, "utf8"); + csvUrls.envelopePlaceholders = `/api/preview/${finalSessionId}/${filename}`; + console.log(`[Preview] Generated umschlag_platzhalter.csv`); + } + + // Freie Adressen CSV (für free-Modus) + if (csvs.freeAddresses) { + const filename = "freie_adressen.csv"; + await fs.writeFile(path.join(sessionDir, filename), csvs.freeAddresses, "utf8"); + csvUrls.freeAddresses = `/api/preview/${finalSessionId}/${filename}`; + console.log(`[Preview] Generated freie_adressen.csv`); + } + } catch (err) { + console.error("[Preview] CSV generation failed:", err.message); + // Don't fail the request, CSV is optional + } + } + + // Step 5: Save session metadata (no expiration) + const metadataPath = path.join(sessionDir, ".metadata.json"); + const metadata = { + sessionId: finalSessionId, + createdAt: new Date().toISOString(), + letterCount: files.length, + font, + hasPlaceholders, + }; + + await fs.writeFile(metadataPath, JSON.stringify(metadata, null, 2), "utf8"); + + console.log( + `[Preview] Batch generation complete for session: ${finalSessionId}` + ); + + // Response + res.status(200).json({ + sessionId: finalSessionId, + files, + csvUrls, + hasOverflow, + overflowFiles: files.filter(f => f.overflow), + }); + } catch (err) { + console.error("[Preview] Unexpected error:", err); + next(err); + } +} + +/** + * GET /api/preview/:sessionId/:filename + * Serve cached preview files + */ +async function servePreview(req, res, next) { + try { + const { sessionId, filename } = req.params; + + // Validate inputs + if (!sessionId || !filename) { + return res.status(400).json({ + error: "Invalid request", + message: "sessionId and filename are required", + }); + } + + // Prevent directory traversal + const sanitizedFilename = path.basename(filename); + const filePath = path.join( + config.paths.previews, + sessionId, + sanitizedFilename + ); + + // Check if file exists + try { + await fs.access(filePath); + } catch { + return res.status(404).json({ + error: "File not found", + message: `Preview file not found: ${sanitizedFilename}`, + }); + } + + // No cache expiration check - files are served until manually deleted + + // Determine content type + const ext = path.extname(sanitizedFilename).toLowerCase(); + let contentType = "application/octet-stream"; + + if (ext === ".svg") { + contentType = "image/svg+xml"; + } else if (ext === ".csv") { + contentType = "text/csv"; + } + + // Serve file - NO CACHING + res.setHeader("Content-Type", contentType); + res.setHeader("Cache-Control", "no-store, no-cache, must-revalidate, proxy-revalidate"); + res.setHeader("Pragma", "no-cache"); + res.setHeader("Expires", "0"); + + const fileContent = await fs.readFile(filePath); + res.send(fileContent); + } catch (err) { + console.error("[Preview] Error serving file:", err); + next(err); + } +} + +// No automatic cache cleanup - files are kept until manually deleted + +module.exports = { + generateBatch, + servePreview, +}; diff --git a/Docker/skrift-backend/src/api/middleware/auth.js b/Docker/skrift-backend/src/api/middleware/auth.js new file mode 100644 index 0000000..0c9f8fd --- /dev/null +++ b/Docker/skrift-backend/src/api/middleware/auth.js @@ -0,0 +1,72 @@ +/** + * Authentication Middleware + * Validiert API-Token in Request-Headers + */ + +const config = require('../../config'); + +/** + * Middleware: API-Token validieren + */ +function authenticateApiToken(req, res, next) { + // API-Token aus Umgebungsvariablen + const validToken = config.auth.apiToken; + + // Wenn kein Token konfiguriert ist, alle Requests erlauben (Development) + if (!validToken) { + console.warn('[Auth] WARNING: No API token configured - authentication disabled!'); + return next(); + } + + // Token aus verschiedenen Quellen prüfen + const token = + req.headers['x-api-token'] || // Custom Header + req.headers['authorization']?.replace('Bearer ', '') || // Bearer Token + req.query.api_token; // Query Parameter (nur für Tests) + + // Kein Token vorhanden + if (!token) { + return res.status(401).json({ + error: 'Unauthorized', + message: 'API token required. Please provide X-API-Token header or Authorization: Bearer ', + }); + } + + // Token validieren + if (token !== validToken) { + return res.status(403).json({ + error: 'Forbidden', + message: 'Invalid API token', + }); + } + + // Token ist gültig + next(); +} + +/** + * Optionale Authentifizierung - Warnung aber kein Fehler + */ +function optionalAuth(req, res, next) { + const validToken = config.auth.apiToken; + + if (!validToken) { + return next(); + } + + const token = + req.headers['x-api-token'] || + req.headers['authorization']?.replace('Bearer ', '') || + req.query.api_token; + + if (!token || token !== validToken) { + console.warn('[Auth] Unauthenticated request to', req.path); + } + + next(); +} + +module.exports = { + authenticateApiToken, + optionalAuth, +}; diff --git a/Docker/skrift-backend/src/api/middleware/error-handler.js b/Docker/skrift-backend/src/api/middleware/error-handler.js new file mode 100644 index 0000000..20bb20e --- /dev/null +++ b/Docker/skrift-backend/src/api/middleware/error-handler.js @@ -0,0 +1,13 @@ +module.exports = (err, req, res, next) => { + console.error('Error occurred:', err); + + const statusCode = err.statusCode || 500; + const message = err.message || 'Internal Server Error'; + + res.status(statusCode).json({ + error: { + message, + ...(process.env.NODE_ENV === 'development' && { stack: err.stack }) + } + }); +}; diff --git a/Docker/skrift-backend/src/api/middleware/rate-limiter.js b/Docker/skrift-backend/src/api/middleware/rate-limiter.js new file mode 100644 index 0000000..f91db85 --- /dev/null +++ b/Docker/skrift-backend/src/api/middleware/rate-limiter.js @@ -0,0 +1,53 @@ +const config = require('../../config'); + +// Simple in-memory rate limiter +const rateLimitStore = new Map(); + +// Cleanup old entries every minute +setInterval(() => { + const now = Date.now(); + const oneMinute = 60 * 1000; + + for (const [key, data] of rateLimitStore.entries()) { + if (now - data.windowStart > oneMinute) { + rateLimitStore.delete(key); + } + } +}, 60 * 1000); + +module.exports = (req, res, next) => { + const sessionId = req.body.sessionId || req.params.sessionId || 'anonymous'; + const now = Date.now(); + const oneMinute = 60 * 1000; + + let rateData = rateLimitStore.get(sessionId); + + if (!rateData || now - rateData.windowStart > oneMinute) { + // New window + rateData = { + windowStart: now, + requests: [] + }; + rateLimitStore.set(sessionId, rateData); + } + + // Remove requests older than 1 minute + rateData.requests = rateData.requests.filter(timestamp => now - timestamp < oneMinute); + + // Check limit + if (rateData.requests.length >= config.preview.rateLimitPerMinute) { + const oldestRequest = Math.min(...rateData.requests); + const retryAfter = Math.ceil((oneMinute - (now - oldestRequest)) / 1000); + + return res.status(429).json({ + error: 'Zu viele Vorschau-Anfragen. Bitte warten Sie.', + retryAfter, + message: `Limit: ${config.preview.rateLimitPerMinute} Anfragen pro Minute` + }); + } + + // Add current request + rateData.requests.push(now); + + next(); +}; diff --git a/Docker/skrift-backend/src/api/middleware/request-logger.js b/Docker/skrift-backend/src/api/middleware/request-logger.js new file mode 100644 index 0000000..1fbf736 --- /dev/null +++ b/Docker/skrift-backend/src/api/middleware/request-logger.js @@ -0,0 +1,14 @@ +module.exports = (req, res, next) => { + const start = Date.now(); + + res.on('finish', () => { + const duration = Date.now() - start; + const timestamp = new Date().toISOString(); + + console.log( + `[${timestamp}] ${req.method} ${req.path} - ${res.statusCode} (${duration}ms)` + ); + }); + + next(); +}; diff --git a/Docker/skrift-backend/src/api/routes/health-routes.js b/Docker/skrift-backend/src/api/routes/health-routes.js new file mode 100644 index 0000000..6192eda --- /dev/null +++ b/Docker/skrift-backend/src/api/routes/health-routes.js @@ -0,0 +1,34 @@ +const express = require('express'); +const router = express.Router(); +const fs = require('fs'); +const config = require('../../config'); + +router.get('/', (req, res) => { + const health = { + status: 'healthy', + timestamp: new Date().toISOString(), + uptime: process.uptime(), + scriptalizer: config.scriptalizer.licenseKey ? 'configured' : 'missing', + storage: { + cache: fs.existsSync(config.paths.cache) && isWritable(config.paths.cache), + output: fs.existsSync(config.paths.output) && isWritable(config.paths.output) + } + }; + + const allHealthy = health.scriptalizer === 'configured' && + health.storage.cache && + health.storage.output; + + res.status(allHealthy ? 200 : 503).json(health); +}); + +function isWritable(path) { + try { + fs.accessSync(path, fs.constants.W_OK); + return true; + } catch { + return false; + } +} + +module.exports = router; diff --git a/Docker/skrift-backend/src/api/routes/order-routes.js b/Docker/skrift-backend/src/api/routes/order-routes.js new file mode 100644 index 0000000..87af099 --- /dev/null +++ b/Docker/skrift-backend/src/api/routes/order-routes.js @@ -0,0 +1,135 @@ +const express = require('express'); +const router = express.Router(); +const multer = require('multer'); +const orderController = require('../controllers/order-controller'); +const { authenticateApiToken } = require('../middleware/auth'); + +// Max. Uploadgröße für Auftragsdateien (Brief-PDF, Adress-/Platzhalter-Tabellen). +// 10 MB reichten für hochauflösende bzw. gescannte Brief-PDFs nicht aus – multer +// verwarf die Datei still, bevor der Controller lief. +const MAX_UPLOAD_BYTES = 120 * 1024 * 1024; // 120MB + +// Multer konfigurieren für Datei-Uploads (temporär im Speicher) +const upload = multer({ + storage: multer.memoryStorage(), + limits: { + fileSize: MAX_UPLOAD_BYTES, + }, + fileFilter: (req, file, cb) => { + // Erlaubte Dateitypen + const allowedTypes = ['image/png', 'image/jpeg', 'image/webp', 'image/svg+xml', 'application/pdf']; + const allowedExts = ['.png', '.jpg', '.jpeg', '.webp', '.svg', '.pdf']; + + const ext = file.originalname.toLowerCase().substring(file.originalname.lastIndexOf('.')); + + if (allowedTypes.includes(file.mimetype) || allowedExts.includes(ext)) { + cb(null, true); + } else { + cb(new Error('Nur PNG, JPG, WEBP, SVG und PDF Dateien sind erlaubt'), false); + } + } +}); + +/** + * POST /api/order/finalize + * Finalize order by copying cached previews to output directory + * + * Request body: + * { + * sessionId: string, + * orderNumber: string + * } + * + * Response: + * { + * orderNumber: string, + * outputPath: string, + * files: string[], + * timestamp: string + * } + */ +router.post('/finalize', authenticateApiToken, orderController.finalizeOrder); + +/** + * POST /api/order/generate + * Generate order from scratch without using cache + * + * Request body: + * { + * orderNumber: string, + * letters: [ + * { + * text: string, + * format: 'a4' | 'a6p' | 'a6l' | 'c6' | 'din_lang', + * font: 'tilda' | 'alva' | 'ellie', + * type?: 'letter' | 'envelope', + * envelopeType?: 'recipient' | 'custom', + * placeholders?: { [key: string]: string } + * } + * ] + * } + * + * Response: + * { + * orderNumber: string, + * outputPath: string, + * files: string[], + * timestamp: string + * } + */ +router.post('/generate', authenticateApiToken, orderController.generateOrder); + +/** + * POST /api/order/motif + * Upload a motif image for an order + * + * Request: multipart/form-data + * - motif: file (PNG, JPG, WEBP, SVG, PDF) + * - orderNumber: string + * + * Response: + * { + * success: boolean, + * filename: string, + * path: string + * } + */ +router.post('/motif', authenticateApiToken, upload.single('motif'), orderController.uploadMotif); + +// Multer für Auftragsdateien (Adress-/Platzhalter-Excel, Briefdokument) +const uploadOrderFile = multer({ + storage: multer.memoryStorage(), + limits: { + fileSize: MAX_UPLOAD_BYTES, + }, + fileFilter: (req, file, cb) => { + const allowedExts = ['.xlsx', '.xls', '.csv', '.pdf', '.doc', '.docx']; + const ext = file.originalname.toLowerCase().substring(file.originalname.lastIndexOf('.')); + + if (allowedExts.includes(ext)) { + cb(null, true); + } else { + cb(new Error('Nur XLSX, XLS, CSV, PDF, DOC oder DOCX Dateien sind erlaubt'), false); + } + } +}); + +/** + * POST /api/order/files + * Speichert eine bereits vom WordPress-Frontend hochgeladene Datei + * (Adresse, Platzhalter, Brief, Brief-Excel) im Auftragsordner + * + * Request: multipart/form-data + * - file: file (XLSX, XLS, CSV, PDF, DOC, DOCX) + * - orderNumber: string + * - fileType: 'adresse' | 'platzhalter' | 'brief' | 'brief_excel' + */ +router.post('/files', authenticateApiToken, uploadOrderFile.single('file'), orderController.uploadOrderFile); + +/** + * POST /api/order/register + * Metadaten-Auftrag ohne SVG-Generierung (gedruckte Briefe) + */ +router.post('/register', authenticateApiToken, orderController.registerOrder); + +module.exports = router; diff --git a/Docker/skrift-backend/src/api/routes/preview-routes.js b/Docker/skrift-backend/src/api/routes/preview-routes.js new file mode 100644 index 0000000..0b98dc4 --- /dev/null +++ b/Docker/skrift-backend/src/api/routes/preview-routes.js @@ -0,0 +1,47 @@ +const express = require('express'); +const router = express.Router(); +const previewController = require('../controllers/preview-controller'); +const { authenticateApiToken } = require('../middleware/auth'); + +/** + * POST /api/preview/batch + * Generate preview batch (no rate limiting, no batch size limit) + * Backend automatically splits into 25-letter batches for Scriptalizer API + * + * Request body: + * { + * sessionId: string, + * letters: [ + * { + * text: string, + * format: 'a4' | 'a6p' | 'a6l' | 'c6' | 'din_lang', + * font: 'tilda' | 'alva' | 'ellie', + * type?: 'letter' | 'envelope', + * envelopeType?: 'recipient' | 'custom', + * placeholders?: { [key: string]: string } + * } + * ] + * } + * + * Response: + * { + * sessionId: string, + * files: [ + * { + * index: number, + * filename: string, + * url: string + * } + * ], + * csvUrl?: string + * } + */ +router.post('/batch', authenticateApiToken, previewController.generateBatch); + +/** + * GET /api/preview/:sessionId/:filename + * Serve cached preview SVG files + */ +router.get('/:sessionId/:filename', previewController.servePreview); + +module.exports = router; diff --git a/Docker/skrift-backend/src/config/index.js b/Docker/skrift-backend/src/config/index.js new file mode 100644 index 0000000..717554e --- /dev/null +++ b/Docker/skrift-backend/src/config/index.js @@ -0,0 +1,53 @@ +require('dotenv').config(); +const path = require('path'); + +const isProduction = process.env.NODE_ENV === 'production'; + +module.exports = { + env: process.env.NODE_ENV || 'development', + port: parseInt(process.env.PORT, 10) || 4000, + + scriptalizer: { + licenseKey: process.env.SCRIPTALIZER_LICENSE_KEY, + errFrequency: parseInt(process.env.SCRIPTALIZER_ERR_FREQUENCY, 10) || 0, + endpoint: 'https://www.scriptalizer.co.uk/QuantumScriptalize.asmx/Scriptalize', + fontMap: { + tilda: 'PremiumUltra79', + alva: 'PremiumUltra23', + ellie: 'PremiumUltra39' + }, + separator: '|||', // Triple pipe separator (tested and working) + maxInputSize: 48000 // 48KB limit + }, + + preview: { + // No batch size limit - frontend can send any number of letters + // Backend splits into 25-letter batches for Scriptalizer API internally + scriptalizerBatchSize: 25 + // No cache lifetime - files are kept until manually cleaned + // No rate limiting + }, + + paths: { + cache: isProduction ? '/app/cache' : path.join(__dirname, '../../cache'), + previews: isProduction ? '/app/cache/previews' : path.join(__dirname, '../../cache/previews'), + output: isProduction ? '/app/output' : path.join(__dirname, '../../output'), + fonts: isProduction ? '/app/fonts' : path.join(__dirname, '../../fonts') + }, + + cors: { + origin: process.env.CORS_ORIGIN || '*', + credentials: true + }, + + auth: { + apiToken: process.env.API_TOKEN || null + }, + + paypal: { + clientId: process.env.PAYPAL_CLIENT_ID || '', + clientSecret: process.env.PAYPAL_CLIENT_SECRET || '', + // 'sandbox' oder 'live' + environment: process.env.PAYPAL_ENVIRONMENT || 'sandbox' + } +}; diff --git a/Docker/skrift-backend/src/lib/page-layout.js b/Docker/skrift-backend/src/lib/page-layout.js new file mode 100644 index 0000000..ca19801 --- /dev/null +++ b/Docker/skrift-backend/src/lib/page-layout.js @@ -0,0 +1,124 @@ +const PX_PER_MM = 3.78; // 96dpi +const FONT_SIZE_PX = 26; + +// Maximale Zeilenanzahl pro Format (für Textvalidierung) +const LINE_LIMITS = { + a4: 27, + a6p: 14, + a6l: 11, +}; + +// Layout Konfiguration pro Format + Orientation +const FORMAT_LAYOUTS = { + // A4 Hochformat + a4: { + marginTopMm: 25, + marginLeftMm: 20, + lineHeightFactor: 1.35, + }, + + // A6 Hochformat + a6p: { + marginTopMm: 8, + marginLeftMm: 8, + lineHeightFactor: 1.3, + }, + + // A6 Querformat (landscape) + a6l: { + marginTopMm: 8, + marginLeftMm: 8, + marginRightMm: 14, + lineHeightFactor: 1.2, + }, + + // DIN Lang Kuvert (Querformat) + din_lang: { + marginTopMm: 50, + marginLeftMm: 10, // Links unten in Ecke + lineHeightFactor: 1.2, + }, + + // C6 Kuvert (Querformat) + c6: { + marginTopMm: 60, + marginLeftMm: 10, // Links unten in Ecke + lineHeightFactor: 1.2, + }, +}; + +// Seiten-Dimensionen +const PAGE_FORMATS = { + a4: { widthMm: 210, heightMm: 297 }, + a6p: { widthMm: 105, heightMm: 148 }, + a6l: { widthMm: 148, heightMm: 105 }, // Querformat: getauscht + din_lang: { widthMm: 220, heightMm: 110 }, + c6: { widthMm: 162, heightMm: 114 }, +}; + +// mm in Pixel umrechnen +function mmToPx(mm) { + return mm * PX_PER_MM; +} + +// Layout für Format holen +function getLayoutForFormat(format) { + const normalizedFormat = String(format).toLowerCase(); + const layoutConfig = FORMAT_LAYOUTS[normalizedFormat]; + + if (!layoutConfig) { + // Fallback auf a4 + return { + marginTopPx: mmToPx(FORMAT_LAYOUTS.a4.marginTopMm), + marginLeftPx: mmToPx(FORMAT_LAYOUTS.a4.marginLeftMm), + lineHeightPx: FONT_SIZE_PX * FORMAT_LAYOUTS.a4.lineHeightFactor, + }; + } + + return { + marginTopPx: mmToPx(layoutConfig.marginTopMm), + marginLeftPx: mmToPx(layoutConfig.marginLeftMm), + marginRightPx: layoutConfig.marginRightMm ? mmToPx(layoutConfig.marginRightMm) : null, + lineHeightPx: FONT_SIZE_PX * layoutConfig.lineHeightFactor, + }; +} + +// Seiten-Dimensionen holen +function getPageDimensions(format) { + const normalizedFormat = String(format).toLowerCase(); + const dimensions = PAGE_FORMATS[normalizedFormat] || PAGE_FORMATS.a4; + + return { + widthMm: dimensions.widthMm, + heightMm: dimensions.heightMm, + widthPx: mmToPx(dimensions.widthMm), + heightPx: mmToPx(dimensions.heightMm), + }; +} + +// XML Escaping +function escapeXml(str) { + return String(str) + .replace(/&/g, '&') + .replace(//g, '>'); +} + +// Zeilenlimit für Format holen +function getLineLimit(format) { + const normalizedFormat = String(format).toLowerCase(); + return LINE_LIMITS[normalizedFormat] || LINE_LIMITS.a4; +} + +module.exports = { + PX_PER_MM, + FONT_SIZE_PX, + FORMAT_LAYOUTS, + PAGE_FORMATS, + LINE_LIMITS, + mmToPx, + getLayoutForFormat, + getPageDimensions, + getLineLimit, + escapeXml +}; diff --git a/Docker/skrift-backend/src/lib/svg-font-engine.js b/Docker/skrift-backend/src/lib/svg-font-engine.js new file mode 100644 index 0000000..3bc69e7 --- /dev/null +++ b/Docker/skrift-backend/src/lib/svg-font-engine.js @@ -0,0 +1,283 @@ +const fs = require('fs'); +const path = require('path'); +const config = require('../config'); + +// Cache je Fontdatei +const FONT_CACHE = {}; + +// Attribut aus einem Tag lesen (mit Wortgrenze, um z.B. 'd' vs 'id' zu unterscheiden) +function getAttr(tag, name) { + const re = new RegExp(`\\b${name}="([^"]*)"`); + const m = tag.match(re); + return m ? m[1] : null; +} + +// XML-Entities im unicode-Attribut dekodieren (z.B. < → <, & → &, " → ") +function decodeXmlEntities(str) { + return str + .replace(/&#x([0-9a-fA-F]+);/g, (_, hex) => String.fromCodePoint(parseInt(hex, 16))) + .replace(/&#([0-9]+);/g, (_, dec) => String.fromCodePoint(parseInt(dec, 10))) + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/&/g, '&'); +} + +// SVG-Font parsen und Metriken plus Glyphen extrahieren +function parseSvgFont(fontFileName) { + if (FONT_CACHE[fontFileName]) return FONT_CACHE[fontFileName]; + + const fontPath = path.join(config.paths.fonts, fontFileName); + const svg = fs.readFileSync(fontPath, 'utf8'); + + const fontFaceMatch = svg.match(//); + if (!fontFaceMatch) { + throw new Error(`Kein in ${fontFileName} gefunden`); + } + + const fontFaceTag = fontFaceMatch[0]; + const unitsPerEm = parseFloat(getAttr(fontFaceTag, 'units-per-em') || '1000'); + const ascent = parseFloat(getAttr(fontFaceTag, 'ascent') || '0'); + const descent = parseFloat(getAttr(fontFaceTag, 'descent') || '0'); + + const glyphRegex = //g; + const glyphs = {}; + let m; + + while ((m = glyphRegex.exec(svg)) !== null) { + const tag = m[0]; + const unicode = decodeXmlEntities(getAttr(tag, 'unicode') || ''); + const d = getAttr(tag, 'd'); + const adv = parseFloat(getAttr(tag, 'horiz-adv-x') || '0'); + + if (!unicode || !d) continue; + + glyphs[unicode] = { + d, + adv, + }; + } + + const fontData = { + unitsPerEm, + ascent, + descent, + glyphs, + }; + + FONT_CACHE[fontFileName] = fontData; + return fontData; +} + +// Zeichen, das nach der Scriptalisierung als Tabulator-Marker eingebettet wird (Unicode PUA) +const TAB_MARKER = ''; + + + +/** + * Breite einer Textzeile in Pixeln messen. + * Unterstützt TAB_MARKER: springt zur nächsten Tabstop-Position (6 × fontSizePx). + */ +function measureTextWidthPx({ text, fontFileName, fontSizePx }) { + const { unitsPerEm, glyphs } = parseSvgFont(fontFileName); + const scale = fontSizePx / unitsPerEm; + const tabStopPx = fontSizePx * 6; + + let x = 0; + + for (const ch of String(text || '')) { + if (ch === TAB_MARKER) { + x = Math.ceil((x + 1) / tabStopPx) * tabStopPx; + continue; + } + + const glyph = glyphs[ch]; + + if (!glyph) { + // Fallback für Leerzeichen / unbekannte Zeichen + const spacePx = fontSizePx * 0.4; + x += spacePx; + continue; + } + + const advPx = (glyph.adv || unitsPerEm * 0.5) * scale; + x += advPx; + } + + return x; +} + +/** + * Text nach verfügbarer Breite umbrechen. + * Garantiert: keine Zeile wird breiter als maxWidthPx. + * Unterstützt TAB_MARKER als Tabulator-Sprung innerhalb einer Zeile. + */ +function wrapTextToLinesByWidth({ + text, + maxWidthPx, + fontFileName, + fontSizePx, +}) { + const paragraphs = String(text || '').split(/\r?\n/); + const allLines = []; + + const measure = (t) => + measureTextWidthPx({ text: t, fontFileName, fontSizePx }); + + for (const para of paragraphs) { + // Tokens: Wörter und TAB_MARKER getrennt ermitteln. + // TAB_MARKER durch einen Platzhalter ersetzen, mit \s+ splitten, dann zurücktauschen. + const TAB_PLACEHOLDER = ''; + const normalizedPara = para.split(TAB_MARKER).join(` ${TAB_PLACEHOLDER} `); + const rawWords = normalizedPara.split(/\s+/).filter(Boolean); + const tokens = rawWords.map(w => (w === TAB_PLACEHOLDER ? TAB_MARKER : w)); + + if (tokens.length === 0) { + allLines.push(''); + continue; + } + + let currentLine = ''; + let afterTab = false; + + for (const token of tokens) { + if (token === TAB_MARKER) { + currentLine += TAB_MARKER; + afterTab = true; + continue; + } + + // Wort alleine zu breit → Zeichenweise brechen + if (measure(token) > maxWidthPx) { + if (currentLine) { + allLines.push(currentLine); + currentLine = ''; + afterTab = false; + } + + let buf = ''; + for (const ch of token) { + const candidate = buf + ch; + if (measure(candidate) > maxWidthPx && buf) { + allLines.push(buf); + buf = ch; + } else { + buf = candidate; + } + } + if (buf) { currentLine = buf; afterTab = false; } + continue; + } + + if (!currentLine) { + currentLine = token; + afterTab = false; + continue; + } + + // Nach einem Tab kein Leerzeichen als Trenner einfügen + const sep = afterTab ? '' : ' '; + const candidate = `${currentLine}${sep}${token}`; + if (measure(candidate) <= maxWidthPx) { + currentLine = candidate; + afterTab = false; + } else { + allLines.push(currentLine); + currentLine = token; + afterTab = false; + } + } + + if (currentLine) { + allLines.push(currentLine); + } + } + + return allLines; +} + +/** + * Textzeilen in Pfade mit Transform-Matrix umwandeln. + */ +function layoutTextToPaths({ + lines, + fontFileName, + fontSizePx, + startX, + startY, + lineHeightPx, + realisticHandwriting = false, +}) { + const { unitsPerEm, glyphs } = parseSvgFont(fontFileName); + + const scale = fontSizePx / unitsPerEm; + + const resultPaths = []; + let baselineY = startY; + + for (const line of lines) { + let x = startX; + let drift = 0; + const lineSlope = realisticHandwriting ? (Math.random() - 0.5) * 0.01 : 0; + + const tabStopPx = fontSizePx * 6; + + for (let i = 0; i < line.length; i++) { + const ch = line[i]; + + // Tabulator-Marker → zur nächsten Tabstop-Position springen + if (ch === TAB_MARKER) { + const relX = x - startX; + const nextTab = Math.ceil((relX + 1) / tabStopPx) * tabStopPx; + x = startX + nextTab; + continue; + } + + const glyph = glyphs[ch]; + + if (!glyph) { + // Leerzeichen mit leicht variierender Breite + const spacePx = fontSizePx * 0.4 * (realisticHandwriting ? (1 + (Math.random() - 0.5) * 0.08) : 1); + x += spacePx; + if (realisticHandwriting) { + drift += (Math.random() - 0.5) * 0.3; + drift = Math.max(-3, Math.min(3, drift)); + } + continue; + } + + const d = glyph.d; + const advPx = (glyph.adv || unitsPerEm * 0.5) * scale; + + const yJitter = realisticHandwriting ? (Math.random() - 0.5) * 2.5 : 0; + if (realisticHandwriting) { + drift += (Math.random() - 0.5) * 0.4; + drift = Math.max(-3, Math.min(3, drift)); + } + + const charY = baselineY + yJitter + drift + lineSlope * (x - startX); + + const angleDeg = realisticHandwriting ? 5 + (Math.random() - 0.5) * 2 : 0; + const pivotX = x + advPx / 2; + const pivotY = charY - fontSizePx * 0.3; + + const transform = `rotate(${angleDeg.toFixed(3)},${pivotX.toFixed(1)},${pivotY.toFixed(1)}) matrix(${scale},0,0,${-scale},${x},${charY})`; + + resultPaths.push({ d, transform }); + + x += advPx; + } + + baselineY += lineHeightPx; + } + + return resultPaths; +} + +module.exports = { + TAB_MARKER, + measureTextWidthPx, + wrapTextToLinesByWidth, + layoutTextToPaths +}; diff --git a/Docker/skrift-backend/src/lib/svg-generator.js b/Docker/skrift-backend/src/lib/svg-generator.js new file mode 100644 index 0000000..490988e --- /dev/null +++ b/Docker/skrift-backend/src/lib/svg-generator.js @@ -0,0 +1,341 @@ +const { + FONT_SIZE_PX, + getLayoutForFormat, + getPageDimensions, + getLineLimit, + escapeXml +} = require('./page-layout'); + +const { + layoutTextToPaths, + wrapTextToLinesByWidth, + measureTextWidthPx +} = require('./svg-font-engine'); + +// Höhe einer Signatur in Seiten-Pixeln (Signatur-Canvas war 600×160; wir reservieren 160px * scale) +const SIG_CANVAS_W = 600; +const SIG_CANVAS_H = 160; + +/** + * Extrahiert Path- und Polyline-Elemente aus einem SVG-String und gibt sie als + * umgeformte -Gruppe zurück, die an Position (x, y) mit Skalierung scale eingebettet ist. + */ +function buildSignatureElement(svgString, x, y, scale) { + if (!svgString || typeof svgString !== 'string') return ''; + + // Nur -Elemente extrahieren (unsere Signatur-SVGs enthalten ausschließlich Paths) + const pathRegex = //gi; + const elements = []; + let m; + while ((m = pathRegex.exec(svgString)) !== null) { + // Stroke-Farbe auf Druckfarbe setzen, stroke-width relativ zur Skalierung anpassen + const attrs = m[1] + .replace(/stroke="[^"]*"/i, 'stroke="#000000"') + .replace(/stroke-width="[^"]*"/i, `stroke-width="${(2.5 / scale).toFixed(2)}"`); + elements.push(``); + } + if (!elements.length) return ''; + + return ` + ${elements.join('\n ')} +`; +} + +// Mapping der Handschriften auf SVG-Font-Dateien +const SVG_FONT_CONFIG = { + tilda: { file: 'tilda.svg' }, + alva: { file: 'alva.svg' }, + ellie: { file: 'ellie.svg' }, +}; + +function normalizeFontKey(font) { + if (!font) return 'tilda'; + const f = String(font).toLowerCase(); + if (['tilda', 'alva', 'ellie'].includes(f)) return f; + return 'tilda'; +} + +/** + * Generiert ein Schriftstück (Brief/Postkarte) als SVG + * @param {string} text - Der scriptalisierte Text + * @param {string} format - Format: a4, a6p, a6l + * @param {object} options - { font: 'tilda', alignment: 'left' } + */ +function generateLetterSVG(text, format, options = {}) { + const fontKey = normalizeFontKey(options.font); + const fontConfig = SVG_FONT_CONFIG[fontKey]; + const signatures = options.signatures || {}; + + const { widthPx, heightPx, widthMm, heightMm } = getPageDimensions(format); + const { marginTopPx, marginLeftPx, marginRightPx, lineHeightPx } = getLayoutForFormat(format); + const maxWidthPx = widthPx - marginLeftPx - (marginRightPx ?? marginLeftPx); + + // ── Signatur-Marker aus Text extrahieren ────────────────────────────────── + const sigMarkerRe = /\[\[(signatur\d+)\]\]/gi; + const hasSigMarkers = sigMarkerRe.test(String(text || '')); + + if (!hasSigMarkers) { + // ── Standard-Pfad (ohne Signaturen) ────────────────────────────────────── + const lines = wrapTextToLinesByWidth({ + text: String(text || ''), + maxWidthPx, + fontFileName: fontConfig.file, + fontSizePx: FONT_SIZE_PX, + }); + + const firstBaselineY = marginTopPx + FONT_SIZE_PX; + const paths = layoutTextToPaths({ + lines, + fontFileName: fontConfig.file, + fontSizePx: FONT_SIZE_PX, + startX: marginLeftPx, + startY: firstBaselineY, + lineHeightPx, + realisticHandwriting: options.realisticHandwriting ?? false, + }); + + const pathElements = paths + .map(p => ``) + .join('\n '); + + const lineLimit = getLineLimit(format); + return { + svg: ` + + ${pathElements} +`.trim(), + lineCount: lines.length, + lineLimit, + overflow: lines.length > lineLimit, + }; + } + + // ── Pfad mit Signaturen: Text in Segmente aufteilen ─────────────────────── + // Jede Signatur-Zeile ist 2 Zeilen hoch; bis zu 3 Signaturen nebeneinander. + const sigHeightPx = 2 * lineHeightPx; + const sigSlotW = maxWidthPx / 3; // Breite pro Signatur-Slot + const sigScaleH = sigHeightPx / SIG_CANVAS_H; + const sigScaleW = sigSlotW / SIG_CANVAS_W; + const sigScale = Math.min(sigScaleH, sigScaleW); // gleichmäßig skalieren + const sigPaddingPx = lineHeightPx * 0.3; // kleiner Abstand ober/unterhalb + + const rawText = String(text || ''); + const segments = []; + sigMarkerRe.lastIndex = 0; + let lastEnd = 0; + let matchSig; + while ((matchSig = sigMarkerRe.exec(rawText)) !== null) { + if (matchSig.index > lastEnd) { + segments.push({ type: 'text', content: rawText.slice(lastEnd, matchSig.index) }); + } + segments.push({ type: 'sig', key: matchSig[1].toLowerCase() }); + lastEnd = matchSig.index + matchSig[0].length; + } + if (lastEnd < rawText.length) { + segments.push({ type: 'text', content: rawText.slice(lastEnd) }); + } + + const allSvgElements = []; + let currentY = marginTopPx + FONT_SIZE_PX; + let totalLineCount = 0; + const lineLimit = getLineLimit(format); + + let si = 0; + while (si < segments.length) { + const seg = segments[si]; + + if (seg.type === 'text') { + const trimmed = seg.content.replace(/^\n+/, '').replace(/\n+$/, ''); + if (!trimmed) { si++; continue; } + const lines = wrapTextToLinesByWidth({ + text: seg.content, + maxWidthPx, + fontFileName: fontConfig.file, + fontSizePx: FONT_SIZE_PX, + }); + const paths = layoutTextToPaths({ + lines, + fontFileName: fontConfig.file, + fontSizePx: FONT_SIZE_PX, + startX: marginLeftPx, + startY: currentY, + lineHeightPx, + realisticHandwriting: options.realisticHandwriting ?? false, + }); + paths.forEach(p => allSvgElements.push( + `` + )); + currentY += lines.length * lineHeightPx; + totalLineCount += lines.length; + si++; + } else { + // Aufeinanderfolgende Signaturen als Zeile nebeneinander rendern + const sigRow = []; + while (si < segments.length && segments[si].type === 'sig') { + sigRow.push(segments[si]); + si++; + } + currentY += sigPaddingPx; + sigRow.forEach((sigSeg, slotIdx) => { + const svgString = signatures[sigSeg.key] || null; + if (svgString) { + const sigX = marginLeftPx + slotIdx * sigSlotW; + const sigEl = buildSignatureElement(svgString, sigX, currentY, sigScale); + if (sigEl) allSvgElements.push(sigEl); + } + }); + currentY += sigHeightPx + sigPaddingPx; + totalLineCount += 2; // ganze Zeile zählt als 2 Zeilen, unabhängig von Anzahl + } + } + + return { + svg: ` + + ${allSvgElements.join('\n ')} +`.trim(), + lineCount: totalLineCount, + lineLimit, + overflow: totalLineCount > lineLimit, + }; +} + +/** + * Generiert einen Umschlag als SVG + * @param {string} text - Der scriptalisierte Text + * @param {string} format - Format: c6, din_lang + * @param {string} type - Typ: 'recipient' (links), 'free' (links, freie Adresse), 'custom' (mittig) + * @param {object} options - { font: 'tilda' } + */ +function generateEnvelopeSVG(text, format, type, options = {}) { + const fontKey = normalizeFontKey(options.font); + const fontConfig = SVG_FONT_CONFIG[fontKey]; + + const { widthPx, heightPx, widthMm, heightMm } = getPageDimensions(format); + const { marginTopPx, marginLeftPx, lineHeightPx } = getLayoutForFormat(format); + + let lines; + let startX; + let startY; + + if (type === 'custom') { + // Mittig zentriert, 70% der Umschlagsbreite + const maxWidthPx = widthPx * 0.7; + + lines = wrapTextToLinesByWidth({ + text: String(text || ''), + maxWidthPx, + fontFileName: fontConfig.file, + fontSizePx: FONT_SIZE_PX, + }); + + // Zentrierung berechnen + const totalTextHeight = lines.length * lineHeightPx; + startY = (heightPx - totalTextHeight) / 2 + FONT_SIZE_PX; + + // Jede Zeile einzeln zentrieren + // startX wird später pro Zeile berechnet + startX = 0; // Dummy-Wert, wird für jede Zeile überschrieben + + } else { + // type === 'recipient' / 'free': Links, max. 60% der Umschlagsbreite + const maxWidthPx = Math.min(widthPx - marginLeftPx - 20, widthPx * 0.6); + + lines = wrapTextToLinesByWidth({ + text: String(text || ''), + maxWidthPx, + fontFileName: fontConfig.file, + fontSizePx: FONT_SIZE_PX, + }); + + startX = marginLeftPx; + startY = marginTopPx + FONT_SIZE_PX; + } + + let paths = []; + + if (type === 'custom') { + // Bei custom type: Jede Zeile einzeln zentrieren + let currentY = startY; + for (const line of lines) { + const lineWidth = measureTextWidthPx({ + text: line, + fontFileName: fontConfig.file, + fontSizePx: FONT_SIZE_PX + }); + const centeredX = (widthPx - lineWidth) / 2; + + const linePaths = layoutTextToPaths({ + lines: [line], + fontFileName: fontConfig.file, + fontSizePx: FONT_SIZE_PX, + startX: centeredX, + startY: currentY, + lineHeightPx, + realisticHandwriting: options.realisticHandwriting ?? false, + }); + + paths.push(...linePaths); + currentY += lineHeightPx; + } + } else { + // Bei recipient type: Normal links ausrichten + paths = layoutTextToPaths({ + lines, + fontFileName: fontConfig.file, + fontSizePx: FONT_SIZE_PX, + startX, + startY, + lineHeightPx, + realisticHandwriting: options.realisticHandwriting ?? false, + }); + } + + const pathElements = paths + .map( + (p) => `` + ) + .join('\n '); + + const svg = ` + + ${pathElements} +`; + + // Overflow-Erkennung: zu viele Zeilen für die verfügbare Fläche + const overflowStartY = (type === 'custom') + ? (heightPx - lines.length * lineHeightPx) / 2 + FONT_SIZE_PX + : marginTopPx + FONT_SIZE_PX; + const available = heightPx - overflowStartY - FONT_SIZE_PX * 0.5; + const lineLimit = Math.max(1, Math.floor(available / lineHeightPx) + 1); + const lineCount = lines.length; + + return { svg: svg.trim(), overflow: lineCount > lineLimit, lineCount, lineLimit }; +} + +module.exports = { + generateLetterSVG, + generateEnvelopeSVG +}; diff --git a/Docker/skrift-backend/src/server.js b/Docker/skrift-backend/src/server.js new file mode 100644 index 0000000..3f8358b --- /dev/null +++ b/Docker/skrift-backend/src/server.js @@ -0,0 +1,53 @@ +const express = require('express'); +const cors = require('cors'); +const config = require('./config'); + +const previewRoutes = require('./api/routes/preview-routes'); +const orderRoutes = require('./api/routes/order-routes'); +const healthRoutes = require('./api/routes/health-routes'); + +const errorHandler = require('./api/middleware/error-handler'); +const requestLogger = require('./api/middleware/request-logger'); + +const app = express(); + +// Middleware +app.use(cors(config.cors)); +app.use(express.json({ limit: '10mb' })); +app.use(express.urlencoded({ extended: true, limit: '10mb' })); +app.use(requestLogger); + +// Routes +app.use('/health', healthRoutes); +app.use('/api/preview', previewRoutes); +app.use('/api/order', orderRoutes); + +// Error handling +app.use(errorHandler); + +// Start server +app.listen(config.port, () => { + console.log('╔════════════════════════════════════════════════════════════╗'); + console.log('║ Skrift Backend Server ║'); + console.log('╚════════════════════════════════════════════════════════════╝'); + console.log(`Environment: ${config.env}`); + console.log(`Port: ${config.port}`); + console.log(`Batch Size: Unlimited (auto-split to ${config.preview.scriptalizerBatchSize} for API)`); + console.log(`Cache: Disabled`); + console.log(`Rate Limit: Disabled`); + console.log(`Scriptalizer: ${config.scriptalizer.licenseKey ? 'Configured ✓' : 'Missing ✗'}`); + console.log('════════════════════════════════════════════════════════════'); + console.log(`Server running at http://localhost:${config.port}`); + console.log('════════════════════════════════════════════════════════════\n'); +}); + +// Graceful shutdown +process.on('SIGTERM', () => { + console.log('SIGTERM received, shutting down gracefully...'); + process.exit(0); +}); + +process.on('SIGINT', () => { + console.log('\nSIGINT received, shutting down gracefully...'); + process.exit(0); +}); diff --git a/Docker/skrift-backend/src/services/placeholder-service.js b/Docker/skrift-backend/src/services/placeholder-service.js new file mode 100644 index 0000000..b01adac --- /dev/null +++ b/Docker/skrift-backend/src/services/placeholder-service.js @@ -0,0 +1,283 @@ +/** + * Ersetzt Platzhalter im Text + * @param {string} text - Text mit Platzhaltern wie [[Vorname]] + * @param {object} placeholders - Objekt mit Platzhalter-Werten {Vorname: 'Max', ...} + * @returns {string} - Text mit ersetzten Platzhaltern + */ +function replacePlaceholders(text, placeholders) { + if (!placeholders || typeof placeholders !== 'object') { + return text; + } + + let result = text; + + for (const [key, value] of Object.entries(placeholders)) { + const placeholder = `[[${key}]]`; + // Case-insensitive replacement: 'i' flag + const regex = new RegExp(placeholder.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi'); + result = result.replace(regex, value || ''); + } + + return result; +} + +/** + * Escaped einen Wert für CSV + */ +function escapeCSV(value) { + const str = String(value || ''); + if (str.includes(',') || str.includes('\n') || str.includes('"')) { + return `"${str.replace(/"/g, '""')}"`; + } + return str; +} + +/** + * Generiert CSV aus Platzhalter-Daten (für Briefe) + * @param {Array} letters - Array von Letter-Objekten mit placeholders + * @returns {string} - CSV-String + */ +function generatePlaceholderCSV(letters) { + if (!Array.isArray(letters) || letters.length === 0) { + return ''; + } + + // Nur Briefe filtern (keine Umschläge) + const letterItems = letters.filter(l => l.type !== 'envelope'); + if (letterItems.length === 0) { + return ''; + } + + // Sammle alle unique Keys + const allKeys = new Set(); + letterItems.forEach(letter => { + if (letter.placeholders) { + Object.keys(letter.placeholders).forEach(key => allKeys.add(key)); + } + }); + + if (allKeys.size === 0) { + return ''; + } + + const keys = ['BriefNr', ...Array.from(allKeys).sort()]; + + // Header + const header = keys.join(','); + + // Rows + const rows = letterItems.map((letter, index) => { + const briefNr = String(letter.index !== undefined ? letter.index : index).padStart(3, '0'); + const values = [briefNr]; + + for (let i = 1; i < keys.length; i++) { + const key = keys[i]; + const value = letter.placeholders?.[key] || ''; + values.push(escapeCSV(value)); + } + + return values.join(','); + }); + + return [header, ...rows].join('\n'); +} + +/** + * Generiert CSV für Empfängerdaten (Umschläge im recipientData-Modus) + * @param {Array} letters - Array von Letter-Objekten + * @returns {string} - CSV-String + */ +function generateRecipientCSV(letters) { + if (!Array.isArray(letters) || letters.length === 0) { + return ''; + } + + // Nur Umschläge im recipient-Modus filtern + const envelopes = letters.filter(l => l.type === 'envelope' && l.envelopeType === 'recipient'); + if (envelopes.length === 0) { + return ''; + } + + // Prüfe ob Empfängerdaten vorhanden sind (vorname, name, strasse, etc.) + const recipientKeys = ['vorname', 'name', 'strasse', 'hausnummer', 'plz', 'ort', 'land']; + const hasRecipientData = envelopes.some(env => + env.placeholders && recipientKeys.some(key => env.placeholders[key]) + ); + + if (!hasRecipientData) { + return ''; + } + + const keys = ['UmschlagNr', ...recipientKeys]; + const header = keys.join(','); + + const rows = envelopes.map((envelope, index) => { + const umschlagNr = String(envelope.index !== undefined ? envelope.index : index).padStart(3, '0'); + const values = [umschlagNr]; + + for (let i = 1; i < keys.length; i++) { + const key = keys[i]; + const value = envelope.placeholders?.[key] || ''; + values.push(escapeCSV(value)); + } + + return values.join(','); + }); + + return [header, ...rows].join('\n'); +} + +/** + * Generiert CSV für freie Adressen (Umschläge im free-Modus) + * @param {Array} letters - Array von Letter-Objekten + * @returns {string} - CSV-String + */ +function generateFreeAddressCSV(letters) { + if (!Array.isArray(letters) || letters.length === 0) { + return ''; + } + + // Nur Umschläge im free-Modus filtern + const envelopes = letters.filter(l => l.type === 'envelope' && l.envelopeType === 'free'); + if (envelopes.length === 0) { + return ''; + } + + // Freie Adressen haben bis zu 5 Zeilen + const keys = ['UmschlagNr', 'Zeile1', 'Zeile2', 'Zeile3', 'Zeile4', 'Zeile5']; + const header = keys.join(','); + + const rows = envelopes.map((envelope, index) => { + const umschlagNr = String(envelope.index !== undefined ? envelope.index : index).padStart(3, '0'); + + // Text in Zeilen aufteilen + const textLines = (envelope.text || '').split('\n'); + const values = [umschlagNr]; + + for (let i = 0; i < 5; i++) { + values.push(escapeCSV(textLines[i] || '')); + } + + return values.join(','); + }); + + return [header, ...rows].join('\n'); +} + +/** + * Generiert CSV für Umschlag-Platzhalter (Umschläge im customText-Modus) + * @param {Array} letters - Array von Letter-Objekten + * @returns {string} - CSV-String + */ +function generateEnvelopePlaceholderCSV(letters) { + if (!Array.isArray(letters) || letters.length === 0) { + console.log('[CSV] generateEnvelopePlaceholderCSV: No letters'); + return ''; + } + + // Nur Umschläge im custom-Modus filtern + const envelopes = letters.filter(l => l.type === 'envelope' && l.envelopeType === 'custom'); + console.log('[CSV] Found custom envelopes:', envelopes.length); + console.log('[CSV] Envelope details:', envelopes.map(e => ({ type: e.type, envelopeType: e.envelopeType, placeholders: e.placeholders }))); + + if (envelopes.length === 0) { + return ''; + } + + // Sammle alle unique Keys (außer Standard-Empfängerfelder) + const recipientKeys = ['vorname', 'name', 'strasse', 'hausnummer', 'plz', 'ort', 'land']; + const allKeys = new Set(); + envelopes.forEach(envelope => { + if (envelope.placeholders) { + console.log('[CSV] Envelope placeholders keys:', Object.keys(envelope.placeholders)); + Object.keys(envelope.placeholders).forEach(key => { + // Nur nicht-Empfängerfelder sammeln + if (!recipientKeys.includes(key.toLowerCase())) { + allKeys.add(key); + console.log('[CSV] Added key:', key); + } else { + console.log('[CSV] Skipped recipient key:', key); + } + }); + } + }); + + console.log('[CSV] All collected keys:', Array.from(allKeys)); + + if (allKeys.size === 0) { + console.log('[CSV] No custom placeholder keys found for envelopes'); + return ''; + } + + const keys = ['UmschlagNr', ...Array.from(allKeys).sort()]; + const header = keys.join(','); + + const rows = envelopes.map((envelope, index) => { + const umschlagNr = String(envelope.index !== undefined ? envelope.index : index).padStart(3, '0'); + const values = [umschlagNr]; + + for (let i = 1; i < keys.length; i++) { + const key = keys[i]; + const value = envelope.placeholders?.[key] || ''; + values.push(escapeCSV(value)); + } + + return values.join(','); + }); + + return [header, ...rows].join('\n'); +} + +/** + * Generiert alle CSV-Dateien für eine Bestellung + * @param {Array} letters - Array von Letter-Objekten (Briefe und Umschläge) + * @returns {Object} - Objekt mit CSV-Inhalten { placeholders, recipients, envelopePlaceholders, freeAddresses } + */ +function generateAllCSVs(letters) { + const result = { + placeholders: null, // Brief-Platzhalter + recipients: null, // Empfängerdaten für Umschläge (recipientData-Modus) + envelopePlaceholders: null, // Umschlag-Platzhalter (customText-Modus) + freeAddresses: null // Freie Adressen für Umschläge (free-Modus) + }; + + if (!Array.isArray(letters) || letters.length === 0) { + return result; + } + + // Brief-Platzhalter CSV + const placeholderCSV = generatePlaceholderCSV(letters); + if (placeholderCSV) { + result.placeholders = placeholderCSV; + } + + // Empfänger CSV (für recipientData-Modus) + const recipientCSV = generateRecipientCSV(letters); + if (recipientCSV) { + result.recipients = recipientCSV; + } + + // Umschlag-Platzhalter CSV (für customText-Modus) + const envelopePlaceholderCSV = generateEnvelopePlaceholderCSV(letters); + if (envelopePlaceholderCSV) { + result.envelopePlaceholders = envelopePlaceholderCSV; + } + + // Freie Adressen CSV (für free-Modus) + const freeAddressCSV = generateFreeAddressCSV(letters); + if (freeAddressCSV) { + result.freeAddresses = freeAddressCSV; + } + + return result; +} + +module.exports = { + replacePlaceholders, + generatePlaceholderCSV, + generateRecipientCSV, + generateEnvelopePlaceholderCSV, + generateFreeAddressCSV, + generateAllCSVs, + escapeCSV +}; diff --git a/Docker/skrift-backend/src/services/scriptalizer-service.js b/Docker/skrift-backend/src/services/scriptalizer-service.js new file mode 100644 index 0000000..a1a1340 --- /dev/null +++ b/Docker/skrift-backend/src/services/scriptalizer-service.js @@ -0,0 +1,230 @@ +const https = require("https"); +const { parseStringPromise } = require("xml2js"); +const config = require("../config"); + +// API Request Counter (resets daily) +const stats = { + requestsToday: 0, + textsToday: 0, + lastResetDate: new Date().toDateString(), +}; + +/** + * Prüft ob der Zähler zurückgesetzt werden muss (neuer Tag) + */ +function checkAndResetDailyStats() { + const today = new Date().toDateString(); + if (stats.lastResetDate !== today) { + console.log(`[Scriptalizer] 📅 New day detected, resetting counters`); + console.log(`[Scriptalizer] 📊 Yesterday's stats: ${stats.requestsToday} API calls, ${stats.textsToday} texts processed`); + stats.requestsToday = 0; + stats.textsToday = 0; + stats.lastResetDate = today; + } +} + +/** + * Gibt die aktuellen Stats zurück + */ +function getStats() { + checkAndResetDailyStats(); + return { ...stats }; +} + +/** + * Ruft die Scriptalizer API auf + * @param {string} inputText - Text zum Scriptalisieren + * @param {string} fontName - Font-Name (z.B. 'PremiumUltra79') + * @param {number} errFrequency - Fehlerfrequenz + * @returns {Promise<{status: string, outputText: string}>} + */ +async function callScriptalizer(inputText, fontName, errFrequency = 10) { + // Prüfe ob neuer Tag + checkAndResetDailyStats(); + + return new Promise((resolve, reject) => { + if (!config.scriptalizer.licenseKey) { + reject(new Error("Scriptalizer License Key fehlt in Konfiguration")); + return; + } + + // Use x-www-form-urlencoded format + const params = new URLSearchParams(); + params.append("LicenseKey", config.scriptalizer.licenseKey); + params.append("FontName", fontName); + params.append("ErrFrequency", String(errFrequency)); + params.append("InputText", inputText); + + const body = params.toString(); + + // Check input size (48KB limit) + if (Buffer.byteLength(body) > config.scriptalizer.maxInputSize) { + reject( + new Error( + `Input size exceeds 48KB limit (${Buffer.byteLength(body)} bytes)` + ) + ); + return; + } + + const options = { + hostname: "www.scriptalizer.co.uk", + port: 443, + path: "/QuantumScriptalize.asmx/Scriptalize", + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded; charset=utf-8", + "Content-Length": Buffer.byteLength(body), + }, + timeout: 30000, // 30 second timeout + }; + + const req = https.request(options, (res) => { + let data = ""; + + res.on("data", (chunk) => { + data += chunk; + }); + + res.on("end", async () => { + try { + const result = await parseStringPromise(data, { + explicitArray: false, + }); + const response = result.ScriptalizerResponse; + + if (!response || response.Status !== "OK") { + reject(new Error(response?.Status || "UNKNOWN_STATUS")); + return; + } + + // Zähler erhöhen bei erfolgreichem Request + stats.requestsToday++; + console.log(`[Scriptalizer] 📊 API Request #${stats.requestsToday} today completed`); + + resolve({ status: "OK", outputText: response.OutputText }); + } catch (err) { + reject( + new Error(`Failed to parse Scriptalizer response: ${err.message}`) + ); + } + }); + }); + + req.on("error", (err) => { + reject(new Error(`Scriptalizer request failed: ${err.message}`)); + }); + + req.on("timeout", () => { + req.destroy(); + reject(new Error("Scriptalizer request timed out")); + }); + + req.write(body); + req.end(); + }); +} + +/** + * Scriptalisiert mehrere Texte in 25er Batches + * @param {string[]} texts - Array von Texten + * @param {string} font - Font-Key (tilda, alva, ellie) + * @param {number} errFrequency - Fehlerfrequenz + * @returns {Promise} - Array von scriptalisierten Texten + */ +async function scriptalizeBatch(texts, font = "tilda", errFrequency = 10) { + if (!Array.isArray(texts) || texts.length === 0) { + return []; + } + + const fontName = + config.scriptalizer.fontMap[font.toLowerCase()] || + config.scriptalizer.fontMap.tilda; + const separator = config.scriptalizer.separator; + const BATCH_SIZE = 18; // 18 Texte pro Scriptalizer API Call (Scriptalizer-API-Limit: 32000 Zeichen pro Call → ca. 1700 Zeichen pro Brief sicher) + + console.log( + `[Scriptalizer] Processing ${texts.length} texts in batches of ${BATCH_SIZE}` + ); + + const allScriptalized = []; + + // Teile in 25er Batches auf + for (let i = 0; i < texts.length; i += BATCH_SIZE) { + const batch = texts.slice(i, i + BATCH_SIZE); + const batchNumber = Math.floor(i / BATCH_SIZE) + 1; + const totalBatches = Math.ceil(texts.length / BATCH_SIZE); + + console.log( + `[Scriptalizer] Processing batch ${batchNumber}/${totalBatches} (${ + batch.length + } texts, indices ${i}-${i + batch.length - 1})` + ); + + // Kombiniere Texte mit Separator + const combinedText = batch.join(separator); + + try { + const result = await callScriptalizer( + combinedText, + fontName, + errFrequency + ); + + // Trenne Ergebnis wieder + const scriptalized = result.outputText.split(separator); + + if (scriptalized.length !== batch.length) { + console.warn( + `[Scriptalizer] Batch ${batchNumber}: returned ${scriptalized.length} parts, expected ${batch.length}` + ); + } + + allScriptalized.push(...scriptalized); + console.log( + `[Scriptalizer] Batch ${batchNumber}/${totalBatches} completed successfully` + ); + } catch (err) { + console.error(`[Scriptalizer] Batch ${batchNumber} failed:`, err.message); + throw err; + } + } + + // Textzähler erhöhen + stats.textsToday += allScriptalized.length; + + console.log( + `[Scriptalizer] All batches completed. Total scriptalized: ${allScriptalized.length}` + ); + console.log( + `[Scriptalizer] 📊 Daily stats: ${stats.requestsToday} API calls, ${stats.textsToday} texts processed` + ); + return allScriptalized; +} + +/** + * Scriptalisiert einen einzelnen Text + * @param {string} text - Text zum Scriptalisieren + * @param {string} font - Font-Key (tilda, alva, ellie) + * @param {number} errFrequency - Fehlerfrequenz + * @returns {Promise} - Scriptalisierter Text + */ +async function scriptalizeSingle(text, font = "tilda", errFrequency = 10) { + const fontName = + config.scriptalizer.fontMap[font.toLowerCase()] || + config.scriptalizer.fontMap.tilda; + + try { + const result = await callScriptalizer(text, fontName, errFrequency); + return result.outputText; + } catch (err) { + console.error("Scriptalizer single call failed:", err.message); + throw err; + } +} + +module.exports = { + scriptalizeBatch, + scriptalizeSingle, + getStats, +}; diff --git a/FRONTEND_BACKEND_ZUSAMMENFASSUNG.md b/FRONTEND_BACKEND_ZUSAMMENFASSUNG.md new file mode 100644 index 0000000..0cc1439 --- /dev/null +++ b/FRONTEND_BACKEND_ZUSAMMENFASSUNG.md @@ -0,0 +1,476 @@ +# Frontend-Backend Integration - Zusammenfassung + +Komplette Übersicht über die Integration des WordPress Frontends mit dem Node.js Backend. + +## Änderungen in diesem Chat + +### ❌ Keine Frontend-Änderungen besprochen + +In diesem Chat lag der Fokus komplett auf dem **Backend**: +- Backend-Entwicklung (Node.js/Express) +- Docker-Deployment +- Scriptalizer API Integration +- SVG-Generierung mit Variationen + +Das Frontend (WordPress Plugin) wurde **jetzt** für das Backend angepasst. + +## Neue Dateien im WordPress Plugin + +### 1. `assets/js/configurator-api.js` +**Backend API Client** + +Funktionen: +- `healthCheck()` - Prüft ob Backend erreichbar ist +- `generatePreviewBatch(letters)` - Generiert Preview von Briefen +- `getPreviewUrl(sessionId, index)` - Holt Preview-URL +- `finalizeOrder(sessionId, orderNumber, metadata)` - Finalisiert Order aus Preview +- `generateOrder(orderNumber, letters, envelopes, metadata)` - Erstellt Order direkt +- `generateOrderNumber()` - Generiert Bestellnummer (SK-YYYY-MM-DD-XXX) + +### 2. `assets/js/configurator-backend-integration.js` +**Integration Logic** + +Funktionen: +- `handleOrderSubmitWithBackend(state)` - Erweiterte Order-Submit mit Backend +- `prepareLettersForBackend(state)` - Bereitet Letter-Daten vor +- `prepareEnvelopesForBackend(state)` - Bereitet Envelope-Daten vor +- `mapFontToBackend(font)` - Mappt Frontend-Font zu Backend +- `mapFormatToBackend(format)` - Mappt Frontend-Format zu Backend + +### 3. `BACKEND_INTEGRATION.md` +**Vollständige Integrations-Dokumentation** + +Inhalt: +- WordPress Admin-Einstellungen Anleitung +- Backend-API Endpunkte Dokumentation +- Workflow-Beschreibungen +- Datenmapping-Tabellen +- Troubleshooting Guide +- Testing-Anleitungen + +### 4. `README.md` +**Plugin-Dokumentation** + +Inhalt: +- Features-Übersicht +- Installations-Anleitung +- Konfigurations-Guide +- Datei-Struktur +- API Integration +- Workflow-Diagramme +- Troubleshooting +- Changelog + +## WordPress Admin-Einstellungen + +### Bereits vorhanden (keine Änderung nötig!) + +Die Backend-Verbindungseinstellungen waren bereits im Plugin vorbereitet: + +**Einstellungen → Skrift Konfigurator → Backend-Verbindung:** + +1. **API URL / Domain** + - Beispiel: `https://backend.deine-domain.de` + - Pflichtfeld für Backend-Integration + +2. **API Token / Authentifizierung** + - Optional (aktuell nicht genutzt) + - Für zukünftige Erweiterungen + +3. **Order Webhook URL** + - Beispiel: `https://n8n.deine-domain.de/webhook/order` + - Wird nach Bestellung aufgerufen + +4. **Redirect URL Geschäftskunden** + - Beispiel: `https://deine-domain.de/danke-business` + - Wohin nach Business-Bestellung + +5. **Redirect URL Privatkunden** + - Beispiel: `https://deine-domain.de/danke-privat` + - Wohin nach Privat-Bestellung + +## Integration in bestehendes Frontend + +### Wie funktioniert es? + +Das neue Backend-System wird **automatisch** genutzt, wenn: +1. Backend-URL in WordPress-Einstellungen gesetzt ist +2. Backend erreichbar ist (Health-Check erfolgreich) + +### Fallback-Logik + +Falls Backend nicht erreichbar: +- Plugin fällt zurück auf alte Webhook-Only Logik +- Bestellung wird trotzdem durchgeführt +- Aber: Keine SVG-Generierung + +### Was ändert sich für den User? + +**Nichts!** Der Konfigurator funktioniert genau gleich: +1. Produkt auswählen +2. Menge eingeben +3. Format wählen +4. Versand & Umschlag +5. Inhalt eingeben +6. Kundendaten +7. Bestellen → **Jetzt mit Backend-Generierung!** + +## Datenmapping + +### Fonts + +| WordPress | Backend | Scriptalizer | +|-----------|---------|--------------| +| tilda | tilda | PremiumUltra79 | +| alva | alva | PremiumUltra23 | +| ellie | ellie | PremiumUltra39 | + +### Formate + +| WordPress | Backend | Papier | +|-----------|---------|--------| +| a4 | A4 | 210×297mm | +| a6p | A6_PORTRAIT | 105×148mm | +| a6l | A6_LANDSCAPE | 148×105mm | + +### Envelopes + +| Brief-Format | Envelope-Format | Größe | +|--------------|-----------------|-------| +| A4 | DIN_LANG | 110×220mm | +| A6 | C6 | 114×162mm | + +## Workflow-Übersicht + +### Business-Kunde (B2B) + +``` +┌─────────────────────────────────────────────────────────┐ +│ 1. Kunde füllt Konfigurator aus │ +│ - Businessbriefe / Business Postkarten / Follow-ups │ +│ - Menge, Format, Versand, Umschlag │ +│ - Text-Inhalt │ +│ - Kundendaten │ +└─────────────────────────┬───────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────┐ +│ 2. Klick auf "Jetzt kostenpflichtig bestellen" │ +└─────────────────────────┬───────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────┐ +│ 3. WordPress Plugin │ +│ - generateOrderNumber() → SK-2026-01-03-001 │ +│ - prepareLettersForBackend(state) │ +│ - prepareEnvelopesForBackend(state) │ +└─────────────────────────┬───────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────┐ +│ 4. Backend API Call │ +│ POST /api/order/generate │ +│ { │ +│ orderNumber: "SK-2026-01-03-001", │ +│ letters: [...], │ +│ envelopes: [...], │ +│ metadata: {...} │ +│ } │ +└─────────────────────────┬───────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────┐ +│ 5. Node.js Backend │ +│ - Scriptalizer API Call (Batch-Processing) │ +│ - SVG-Generierung mit Variationen │ +│ - Dateien speichern in /var/skrift-output/ │ +│ - Placeholders.csv erstellen │ +│ - order-metadata.json erstellen │ +└─────────────────────────┬───────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────┐ +│ 6. Webhook aufrufen (optional) │ +│ POST https://n8n.deine-domain.de/webhook/order │ +│ { │ +│ orderNumber: "SK-2026-01-03-001", │ +│ customer_data: {...}, │ +│ backend_result: { │ +│ path: "/var/skrift-output/SK-2026-01-03-001", │ +│ files: [...] │ +│ } │ +│ } │ +└─────────────────────────┬───────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────┐ +│ 7. Weiterleitung │ +│ → https://domain.de/danke-business?orderNumber=SK-.. │ +└─────────────────────────────────────────────────────────┘ +``` + +### Privat-Kunde (B2C) - Später mit PayPal + +``` +1. Kunde füllt Konfigurator aus +2. Klick auf "Jetzt kostenpflichtig bestellen" +3. → PayPal Checkout +4. PayPal Zahlung erfolgreich +5. PayPal Webhook → WordPress +6. WordPress → Backend API (generateOrder) +7. Backend generiert SVG-Dateien +8. Webhook aufrufen +9. Weiterleitung zu Danke-Seite +``` + +## Generierte Dateien + +Für jede Bestellung erstellt das Backend: + +``` +/var/skrift-output/SK-2026-01-03-001/ +├── letter_000.svg # Brief 1 +├── letter_001.svg # Brief 2 +├── ... +├── letter_099.svg # Brief 100 +├── envelope_000.svg # Umschlag 1 (falls gewünscht) +├── envelope_001.svg # Umschlag 2 +├── ... +├── envelope_099.svg # Umschlag 100 +├── placeholders.csv # Platzhalter-Daten (CSV) +└── order-metadata.json # Bestellungs-Metadaten +``` + +### order-metadata.json + +```json +{ + "orderNumber": "SK-2026-01-03-001", + "generatedAt": "2026-01-03T12:34:56.789Z", + "summary": { + "totalLetters": 100, + "totalEnvelopes": 100, + "fonts": ["tilda"], + "formats": ["A4"] + }, + "metadata": { + "customer": { + "type": "business", + "firstName": "Max", + "lastName": "Mustermann", + "company": "Beispiel GmbH", + "email": "max@example.com" + }, + "product": "businessbriefe", + "quantity": 100 + } +} +``` + +### placeholders.csv + +```csv +PlaceholderName,Value +Anrede,Sehr geehrte Damen und Herren +Firma,Beispiel GmbH +Ansprechpartner,Max Mustermann +``` + +## Wichtige Konfigurationen + +### WordPress Plugin + +**Datei:** `WordPress Plugin/includes/admin-settings.php` + +Bereits vorhanden: +- Backend-Verbindungseinstellungen +- Preiskonfiguration +- Produktverwaltung +- Gutschein-System + +**Keine Änderungen nötig!** + +### Node.js Backend + +**Datei:** `Docker Backend/.env` + +```bash +SCRIPTALIZER_LICENSE_KEY=f9918b40-d11c-11f0-b558-0800200c9a66 +SCRIPTALIZER_ERR_FREQUENCY=0 # WICHTIG: Keine durchgestrichenen Wörter! +BATCH_SIZE=30 +CACHE_LIFETIME_HOURS=2 +RATE_LIMIT_PER_MINUTE=2 +NODE_ENV=production +``` + +**Datei:** `Docker Backend/src/lib/svg-font-engine.js` + +Handschrift-Variationen: +- **15% Wortabstand-Variation** (mit Sinuswelle) +- **±2.5° Wort-Rotation** (für natürliche Schräglage) + +## Testing + +### 1. Backend Health-Check + +```bash +# Auf Server +curl http://localhost:4000/health + +# Von außen +curl https://backend.deine-domain.de/health + +# Erwartete Antwort: +{"status":"ok","timestamp":"2026-01-03T..."} +``` + +### 2. WordPress Browser-Console + +```javascript +// Backend API testen +const api = window.SkriftBackendAPI; + +// Health-Check +const healthy = await api.healthCheck(); +console.log('Backend healthy:', healthy); + +// Test-Order +const result = await api.generateOrder( + api.generateOrderNumber(), + [{ + text: 'Liebe Oma, vielen Dank für das schöne Geschenk!', + font: 'tilda', + format: 'A4', + placeholders: {} + }], + [{ + type: 'recipient', + recipientAddress: { + name: 'Frau Schmidt', + street: 'Hauptstraße 123', + zip: '12345', + city: 'Berlin' + }, + font: 'tilda', + format: 'DIN_LANG' + }], + { + customer: { + type: 'business', + firstName: 'Test', + lastName: 'User', + email: 'test@example.com' + } + } +); + +console.log('Order result:', result); +``` + +### 3. Vollständiger Test-Workflow + +1. WordPress Konfigurator öffnen +2. Produkt wählen (z.B. Businessbriefe) +3. Menge: 5 +4. Format: A4 +5. Versand: Direktversand +6. Umschlag: Ja, mit Empfängeradresse +7. Text eingeben +8. Kundendaten eingeben +9. "Jetzt kostenpflichtig bestellen" klicken +10. Prüfen: + - Browser-Console: Keine Fehler + - Backend-Logs: `docker compose logs -f` + - Dateien erstellt: `ls /var/skrift-output/SK-*` + - SVGs korrekt: Keine durchgestrichenen Wörter + - Webhook aufgerufen (falls konfiguriert) + +## Deployment-Reihenfolge + +### 1. Backend deployen + +```bash +# Siehe: Docker Backend/DEPLOYMENT_READY/START_HIER.txt + +# Upload auf Server +scp -r DEPLOYMENT_READY/* root@SERVER:/opt/skrift-backend/ + +# Auf Server +ssh root@SERVER +cd /opt/skrift-backend +cp .env.example .env +mkdir -p /var/skrift-output +docker compose up -d --build +``` + +### 2. Nginx Proxy Manager konfigurieren + +- Domain: `backend.deine-domain.de` +- Forward to: `skrift-backend:4000` +- SSL: Let's Encrypt + +### 3. WordPress Plugin hochladen + +```bash +# Per FTP/SSH +scp -r "WordPress Plugin" root@SERVER:/var/www/html/wp-content/plugins/skrift-konfigurator/ +``` + +### 4. WordPress Plugin aktivieren + +- WordPress Admin → Plugins +- "Skrift Konfigurator" aktivieren + +### 5. Einstellungen konfigurieren + +- Einstellungen → Skrift Konfigurator +- Backend-Verbindung: + - API URL: `https://backend.deine-domain.de` + - Webhook URL: `https://n8n.deine-domain.de/webhook/order` (optional) + - Redirect URLs setzen +- Speichern + +### 6. Seite mit Konfigurator erstellen + +- Neue Seite: "Konfigurator" +- Shortcode: `[skrift_konfigurator]` +- Veröffentlichen + +### 7. Testen! + +## Troubleshooting + +Siehe: +- `WordPress Plugin/BACKEND_INTEGRATION.md` +- `Docker Backend/DEPLOYMENT_READY/SERVER_SETUP.txt` +- `Docker Backend/DEPLOYMENT_READY/CHECKLISTE.txt` + +## Zusammenfassung + +### ✅ Was ist fertig? + +- ✅ Backend komplett entwickelt (Node.js + Docker) +- ✅ Frontend-Backend Integration implementiert +- ✅ API Client für WordPress +- ✅ Dokumentation vollständig +- ✅ Deployment-Package bereit +- ✅ Handschrift-Variationen (15% Abstand, ±2.5° Rotation) +- ✅ Keine durchgestrichenen Wörter (SCRIPTALIZER_ERR_FREQUENCY=0) + +### 🚧 Was fehlt noch? + +- [ ] Backend auf Server deployen +- [ ] WordPress Einstellungen konfigurieren +- [ ] Vollständigen Test-Durchlauf +- [ ] PayPal-Integration für Privatkunden +- [ ] Email-Benachrichtigungen +- [ ] N8N Workflow für Plotter-Übertragung + +### 🎯 Nächste Schritte + +1. **Backend deployen** (siehe `DEPLOYMENT_READY/START_HIER.txt`) +2. **WordPress-Einstellungen** konfigurieren +3. **Test-Bestellung** aufgeben +4. **Prüfen** ob SVG-Dateien korrekt generiert werden +5. **N8N Workflow** einrichten (optional) +6. **Go Live!** 🚀 diff --git a/Produktion/AutoRecover/auto_recover.py b/Produktion/AutoRecover/auto_recover.py new file mode 100644 index 0000000..43a3fa6 --- /dev/null +++ b/Produktion/AutoRecover/auto_recover.py @@ -0,0 +1,67 @@ +""" +AutoRecover - Drucker Fehler Handler +Erkennt das "Error"-Fenster (Java AWT) und klickt automatisch "Recover" (max. 3x). +""" + +import win32gui +import win32con +import ctypes +import time + +MAX_VERSUCHE = 3 +PRUEF_INTERVAL = 2 # Sekunden zwischen den Checks + +def click_recover_button(hwnd): + """ + Holt die Fensterposition und klickt auf den Recover-Button. + Der Button ist zentriert am unteren Rand des Fensters. + """ + # Fenster in den Vordergrund holen + win32gui.SetForegroundWindow(hwnd) + time.sleep(0.3) + + # Fensterposition und -größe ermitteln + left, top, right, bottom = win32gui.GetWindowRect(hwnd) + width = right - left + height = bottom - top + + # Recover-Button liegt ca. bei: horizontal zentriert, ~85% der Höhe + click_x = left + width // 2 + click_y = top + int(height * 0.85) + + # Mausklick simulieren + ctypes.windll.user32.SetCursorPos(click_x, click_y) + time.sleep(0.1) + ctypes.windll.user32.mouse_event(0x0002, 0, 0, 0, 0) # linke Taste runter + ctypes.windll.user32.mouse_event(0x0004, 0, 0, 0, 0) # linke Taste hoch + + print(f" → Klick auf Position ({click_x}, {click_y})") + +def main(): + versuche = 0 + print("AutoRecover läuft... (Strg+C zum Beenden)") + print(f"Maximal {MAX_VERSUCHE} Versuche.\n") + + while versuche < MAX_VERSUCHE: + hwnd = win32gui.FindWindow(None, "Error") + + if hwnd and win32gui.IsWindowVisible(hwnd): + versuche += 1 + print(f"[Versuch {versuche}/{MAX_VERSUCHE}] Fehlerfenster gefunden!") + + time.sleep(0.5) + click_recover_button(hwnd) + + if versuche >= MAX_VERSUCHE: + print(f"\nMaximale Versuche ({MAX_VERSUCHE}) erreicht. Programm beendet.") + break + + time.sleep(3) + else: + time.sleep(PRUEF_INTERVAL) + +if __name__ == "__main__": + try: + main() + except KeyboardInterrupt: + print("\nProgramm manuell beendet.") diff --git a/Produktion/AutoRecover/build_exe.bat b/Produktion/AutoRecover/build_exe.bat new file mode 100644 index 0000000..b5dfce9 --- /dev/null +++ b/Produktion/AutoRecover/build_exe.bat @@ -0,0 +1,11 @@ +@echo off +echo PyInstaller wird installiert falls noetig... +pip install pyinstaller pywin32 --quiet + +echo. +echo Erstelle AutoRecover.exe ... +python -m PyInstaller --onefile --noconsole --name AutoRecover auto_recover.py + +echo. +echo Fertig! Die EXE liegt in: dist\AutoRecover.exe +pause diff --git a/Produktion/AutoRecover/debug_window.py b/Produktion/AutoRecover/debug_window.py new file mode 100644 index 0000000..6d36d53 --- /dev/null +++ b/Produktion/AutoRecover/debug_window.py @@ -0,0 +1,27 @@ +"""Debug: Zeigt alle Elemente im Error-Fenster""" +import win32gui + +def dump_window(hwnd, indent=0): + try: + class_name = win32gui.GetClassName(hwnd) + text = win32gui.GetWindowText(hwnd) + print(" " * indent + f'[{class_name}] "{text}" (hwnd={hwnd})') + except: + pass + + try: + def callback(child_hwnd, _): + dump_window(child_hwnd, indent + 4) + win32gui.EnumChildWindows(hwnd, callback, None) + except: + pass + +hwnd = win32gui.FindWindow(None, "Error") +if hwnd: + print(f"Fenster gefunden: hwnd={hwnd}\n") + dump_window(hwnd) +else: + print("Kein Fenster mit Titel 'Error' gefunden!") + print("Bitte Fehlerfenster öffnen und nochmal ausführen.") + +input("\nEnter zum Beenden...") diff --git a/Produktion/AutoRecover/find_window_info.py b/Produktion/AutoRecover/find_window_info.py new file mode 100644 index 0000000..0beca2a --- /dev/null +++ b/Produktion/AutoRecover/find_window_info.py @@ -0,0 +1,46 @@ +""" +Schritt 1: Fenster-Erkennung +Lass den Druckerfehler aufpoppen, dann starte dieses Skript. +Es zeigt alle sichtbaren Fenster und deren Buttons an. +""" + +import win32gui +import win32con +import win32api + +def get_child_controls(hwnd): + controls = [] + def callback(child_hwnd, _): + class_name = win32gui.GetClassName(child_hwnd) + text = win32gui.GetWindowText(child_hwnd) + if class_name == "Button" and text: + controls.append(f' Button: "{text}"') + elif text: + controls.append(f' {class_name}: "{text}"') + win32gui.EnumChildWindows(hwnd, callback, None) + return controls + +def list_windows(): + windows = [] + def callback(hwnd, _): + if win32gui.IsWindowVisible(hwnd): + title = win32gui.GetWindowText(hwnd) + if title: + controls = get_child_controls(hwnd) + windows.append((title, controls)) + win32gui.EnumWindows(callback, None) + return windows + +print("=" * 60) +print("Gefundene Fenster mit Buttons:") +print("=" * 60) + +for title, controls in list_windows(): + if controls: + print(f'\nFenster: "{title}"') + for c in controls: + print(c) + +print("\n" + "=" * 60) +print("Kopiere den Fenstertitel und Button-Namen für AutoRecover.") +input("\nEnter drücken zum Beenden...") diff --git a/Produktion/skrift-produktion/Einrichten.bat b/Produktion/skrift-produktion/Einrichten.bat new file mode 100644 index 0000000..70220eb --- /dev/null +++ b/Produktion/skrift-produktion/Einrichten.bat @@ -0,0 +1,67 @@ +@echo off +title Skrift FTP-Checker – Einrichtung +color 0A +cd /d "%~dp0" + +echo. +echo ============================================ +echo Skrift FTP-Checker – Einrichtung +echo ============================================ +echo. + +:: Python pruefen +python --version >nul 2>&1 +if errorlevel 1 ( + color 0C + echo FEHLER: Python wurde nicht gefunden! + echo. + echo Bitte Python von https://python.org herunterladen, + echo installieren und dabei "Add Python to PATH" aktivieren. + echo. + pause + exit /b 1 +) + +python --version +echo Python gefunden. +echo. + +:: Virtuelle Umgebung erstellen falls nicht vorhanden +if exist ".venv\Scripts\python.exe" ( + echo Virtuelle Umgebung bereits vorhanden. +) else ( + echo Erstelle virtuelle Umgebung... + python -m venv .venv + if errorlevel 1 ( + color 0C + echo FEHLER: Konnte .venv nicht erstellen! + pause + exit /b 1 + ) + echo Fertig. +) +echo. + +:: Abhaengigkeiten installieren +echo Installiere / aktualisiere Abhaengigkeiten... +.venv\Scripts\pip.exe install -r requirements.txt -q --disable-pip-version-check +if errorlevel 1 ( + color 0C + echo. + echo FEHLER: Installation fehlgeschlagen! + echo Bitte Internetverbindung pruefen und erneut versuchen. + echo. + pause + exit /b 1 +) +echo Fertig. +echo. + +color 0A +echo ============================================ +echo Einrichtung abgeschlossen! +echo ============================================ +echo. +echo Doppelklick auf "Starten.vbs" zum Starten. +echo. +timeout /t 3 >nul diff --git a/Produktion/skrift-produktion/Starten (Debug).bat b/Produktion/skrift-produktion/Starten (Debug).bat new file mode 100644 index 0000000..12ed3b5 --- /dev/null +++ b/Produktion/skrift-produktion/Starten (Debug).bat @@ -0,0 +1,23 @@ +@echo off +title Skrift FTP-Checker – Debug +cd /d "%~dp0" + +:: Laufende Instanz auf Port 8765 beenden +for /f "tokens=5" %%a in ('netstat -ano 2^>nul ^| findstr ":8765 " ^| findstr "LISTENING"') do ( + taskkill /PID %%a /F >nul 2>&1 +) + +if exist ".venv\Scripts\python.exe" ( + echo Starte mit .venv Python... + .venv\Scripts\python.exe main.py +) else ( + echo HINWEIS: .venv nicht gefunden. Bitte zuerst Einrichten.bat ausfuehren. + echo Versuche System-Python... + python main.py +) + +if errorlevel 1 ( + echo. + echo Programm mit Fehler beendet. Siehe Meldung oben. + pause +) diff --git a/Produktion/skrift-produktion/Starten.bat b/Produktion/skrift-produktion/Starten.bat new file mode 100644 index 0000000..fa661dd --- /dev/null +++ b/Produktion/skrift-produktion/Starten.bat @@ -0,0 +1,40 @@ +@echo off +title Skrift FTP-Checker +cd /d "%~dp0" + +:: ── venv pruefen / erstellen ───────────────────────────────────────── +if exist ".venv\Scripts\python.exe" goto :deps + +echo [Setup] Virtuelle Umgebung wird erstellt... +python -m venv .venv +if errorlevel 1 ( + echo. + echo FEHLER: Python nicht gefunden! + echo Bitte Python von https://python.org installieren + echo und dabei "Add Python to PATH" aktivieren. + echo. + pause + exit /b 1 +) +echo Erledigt. + +:deps +:: ── Abhaengigkeiten installieren (immer) ───────────────────────────── +echo [Setup] Pakete werden geprueft / installiert... +.venv\Scripts\pip.exe install -r requirements.txt -q --disable-pip-version-check +if errorlevel 1 ( + echo. + echo FEHLER: Pakete konnten nicht installiert werden. + pause + exit /b 1 +) + +:run +:: ── Alte Instanz auf Port 8765 beenden ─────────────────────────────── +for /f "tokens=5" %%a in ('netstat -ano 2^>nul ^| findstr ":8765 " ^| findstr "LISTENING"') do ( + taskkill /PID %%a /F >nul 2>&1 +) + +:: ── App in eigenem Fenster starten (laeuft weiter wenn dieses Fenster zu) ── +echo Skrift FTP-Checker wird gestartet... +start "Skrift FTP-Checker" .venv\Scripts\python.exe main.py diff --git a/Produktion/skrift-produktion/app/__init__.py b/Produktion/skrift-produktion/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/Produktion/skrift-produktion/app/api/__init__.py b/Produktion/skrift-produktion/app/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/Produktion/skrift-produktion/app/api/routes.py b/Produktion/skrift-produktion/app/api/routes.py new file mode 100644 index 0000000..a6d11cb --- /dev/null +++ b/Produktion/skrift-produktion/app/api/routes.py @@ -0,0 +1,1238 @@ +import asyncio +import io +import json +import logging +import os +import threading +import zipfile +from dataclasses import asdict +from typing import AsyncGenerator + +import requests + +from fastapi import APIRouter, HTTPException, UploadFile, File, Form +from typing import List, Optional +from fastapi.responses import FileResponse, StreamingResponse +from pydantic import BaseModel + +from app.constants import OrderStatus +from app.core.ftp_client import FTPClient, FTPConnectionError + +router = APIRouter() + +# Injected by main.py +_state = {} + + +def init(config, order_manager, machine_client, ftp_worker, machine_worker, event_queue): + _state["config"] = config + _state["om"] = order_manager + _state["machine"] = machine_client + _state["ftp_worker"] = ftp_worker + _state["machine_worker"] = machine_worker + _state["event_queue"] = event_queue # asyncio.Queue for SSE + _state["cancel_event"] = None + _state["current_order"] = None + _state["send_letters"] = True + _state["send_envelopes"] = True + _state["pending_jobs"] = [] # Jobs, die auf Bestätigung warten + _state["job_history"] = [] # Bereits bestätigte Jobs (letzte 20) + _state["format_confirm_event"] = None + _state["format_change_from"] = "" + _state["format_change_to"] = "" + _state["last_used_format"] = None # {"width": x, "height": y} – zuletzt an Maschine gesendetes Format + _state["queue_seq"] = 0 # monoton steigend – bestimmt die Abarbeitungsreihenfolge der Queue + _state["current_job"] = None # Info über den gerade laufenden Auftrag (Template + Seiten) + + +# ── SSE ───────────────────────────────────────────────────────────── + +@router.get("/api/events") +async def sse_events(): + async def generator() -> AsyncGenerator[str, None]: + q: asyncio.Queue = _state["event_queue"] + try: + while True: + event = await q.get() + yield f"data: {json.dumps(event)}\n\n" + except asyncio.CancelledError: + pass + return StreamingResponse(generator(), media_type="text/event-stream", + headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}) + + +# ── Orders ─────────────────────────────────────────────────────────── + +@router.get("/api/orders") +def get_orders(): + orders = _state["om"].get_all() + result = [] + for o in orders: + d = asdict(o) + d["status"] = o.status.value + result.append(d) + return result + + +class SendParams(BaseModel): + letter_template_key: str = "" + letter_width: float = 0 + letter_height: float = 0 + letter_xpos: float = 0 + letter_ypos: float = 0 + letter_scale: float = 1.0 + letter_rotation: float = 0 + envelope_template_key: str = "" + envelope_width: float = 0 + envelope_height: float = 0 + envelope_xpos: float = 0 + envelope_ypos: float = 0 + envelope_scale: float = 1.0 + envelope_rotation: float = 0 + # 0-basierte Briefnummern (analog Dateinamen letter_000, letter_001 …). + # None/null/leer = alle. Erlaubt beliebige Auswahl, auch nicht zusammenhängend. + # Optional, damit das Frontend auch explizit `null` senden darf. + letter_indices: Optional[List[int]] = None + envelope_indices: Optional[List[int]] = None + send_letters: bool = True + send_envelopes: bool = True + machine_id: str = "" + + +@router.post("/api/orders/{order_number}/send") +def send_order(order_number: str, params: SendParams): + om = _state["om"] + order = om.get_by_number(order_number) + if not order: + raise HTTPException(404, "Auftrag nicht gefunden") + if order.status in (OrderStatus.SENDING,): + raise HTTPException(409, "Auftrag wird bereits gesendet") + + letter_p = { + "width": params.letter_width, "height": params.letter_height, + "xpos": params.letter_xpos, "ypos": params.letter_ypos, + "scale": params.letter_scale, "rotation": params.letter_rotation, + } + env_p = { + "width": params.envelope_width, "height": params.envelope_height, + "xpos": params.envelope_xpos, "ypos": params.envelope_ypos, + "scale": params.envelope_scale, "rotation": params.envelope_rotation, + } + + t = threading.Thread( + target=_run_send_job, + args=(order, letter_p, env_p), + kwargs={ + "letter_indices": params.letter_indices, + "envelope_indices": params.envelope_indices, + "send_letters": params.send_letters, "send_envelopes": params.send_envelopes, + "machine_id": params.machine_id, + }, + daemon=True, + ) + t.start() + return {"status": "started"} + + +def _parse_num_spec(spec, max_index: int): + """ + '0-4' / '0,3,5-7' / '5' → sortierte 0-basierte Indexliste im Bereich 0..max_index. + Leer/None → None (= alle). Ungültige Token werden ignoriert. + """ + if spec is None or not str(spec).strip(): + return None + out = set() + for tok in str(spec).split(","): + tok = tok.strip() + if not tok: + continue + if "-" in tok: + a, _, b = tok.partition("-") + try: + a, b = int(a), int(b) + except ValueError: + continue + if a > b: + a, b = b, a + for i in range(a, b + 1): + if 0 <= i <= max_index: + out.add(i) + elif tok.lstrip("-").isdigit(): + i = int(tok) + if 0 <= i <= max_index: + out.add(i) + return sorted(out) + + +def _nums_summary(indices) -> str: + """Kompakte Darstellung 0-basierter Nummern, z.B. '0–99' oder '0, 3, 5–7'.""" + idx = sorted(set(indices)) + if not idx: + return "—" + parts = [] + start = prev = idx[0] + for i in idx[1:]: + if i == prev + 1: + prev = i + else: + parts.append(f"{start}" if start == prev else f"{start}–{prev}") + start = prev = i + parts.append(f"{start}" if start == prev else f"{start}–{prev}") + return ", ".join(parts) + + +def _next_queue_seq() -> int: + """Nächste (monoton steigende) Reihenfolge-Nummer für die Warteschlange.""" + _state["queue_seq"] = _state.get("queue_seq", 0) + 1 + return _state["queue_seq"] + + +def _order_dims_signature(order, letter_p: dict, env_p: dict, + send_letters: bool = True, send_envelopes: bool = True) -> frozenset: + """ + Maße, die dieser Auftrag beim Drucken belegt – je gesendetem Typ ein + (Breite, Höhe)-Paar. Zwei Aufträge sind für die Warteschlange nur dann + kompatibel, wenn diese Signaturen identisch sind (gleiches Material). + Nur die tatsächlich gesendeten Typen zählen (z.B. reiner Brief-Sammeldruck). + """ + dims = [] + if send_letters and order.letter_count and order.letters: + dims.append((round(letter_p.get("width", 0), 2), round(letter_p.get("height", 0), 2))) + if send_envelopes and order.envelope_count and order.envelopes: + dims.append((round(env_p.get("width", 0), 2), round(env_p.get("height", 0), 2))) + return frozenset(dims) + + +@router.post("/api/orders/{order_number}/queue") +def queue_order(order_number: str, params: SendParams): + om = _state["om"] + order = om.get_by_number(order_number) + if not order: + raise HTTPException(404, "Auftrag nicht gefunden") + + new_letter_p = {"width": params.letter_width, "height": params.letter_height} + new_env_p = {"width": params.envelope_width, "height": params.envelope_height} + new_sig = _order_dims_signature( + order, new_letter_p, new_env_p, params.send_letters, params.send_envelopes + ) + + # Warteschlange akzeptiert nur Aufträge mit exakt gleichen Maßen. + queued = om.get_queued() + if queued and new_sig: + ref = queued[0] + ref_sig = _order_dims_signature( + ref, ref.letter_params or {}, ref.envelope_params or {}, + getattr(ref, "queue_send_letters", True), + getattr(ref, "queue_send_envelopes", True), + ) + if ref_sig and new_sig != ref_sig: + def _fmt(sig): + return " + ".join(f'{w}"×{h}"' for w, h in sorted(sig)) or "—" + raise HTTPException( + 409, + f"Formatwechsel nicht möglich: Die Warteschlange läuft mit " + f"{_fmt(ref_sig)}, dieser Auftrag hat {_fmt(new_sig)}. " + f"Es können nur Aufträge mit gleichen Maßen aneinandergehängt werden.", + ) + + om.set_order_params( + order_number, + letter_params={ + "width": params.letter_width, "height": params.letter_height, + "xpos": params.letter_xpos, "ypos": params.letter_ypos, + "scale": params.letter_scale, "rotation": params.letter_rotation, + }, + envelope_params={ + "width": params.envelope_width, "height": params.envelope_height, + "xpos": params.envelope_xpos, "ypos": params.envelope_ypos, + "scale": params.envelope_scale, "rotation": params.envelope_rotation, + }, + queue_send_letters=params.send_letters, + queue_send_envelopes=params.send_envelopes, + queue_seq=_next_queue_seq(), + split_size=0, + ) + om.update_status(order_number, OrderStatus.QUEUED) + _push_event({"type": "order_update", "order_number": order_number, "status": OrderStatus.QUEUED.value}) + return {"status": "queued"} + + +@router.post("/api/queue/kick") +def kick_queue(): + """Startet sofort eine Warteschlangen-Prüfung (statt bis zum nächsten Worker-Tick zu warten).""" + from app.workers.poll_worker import process_queue_tick + threading.Thread(target=process_queue_tick, daemon=True).start() + return {"status": "ok"} + + +@router.post("/api/orders/{order_number}/unqueue") +def unqueue_order(order_number: str): + om = _state["om"] + order = om.get_by_number(order_number) + if not order: + raise HTTPException(404, "Auftrag nicht gefunden") + om.update_status(order_number, OrderStatus.NEW) + _push_event({"type": "order_update", "order_number": order_number, "status": OrderStatus.NEW.value}) + return {"status": "unqueued"} + + +@router.post("/api/orders/{order_number}/archive") +def archive_order(order_number: str): + om = _state["om"] + order = om.get_by_number(order_number) + if not order: + raise HTTPException(404, "Auftrag nicht gefunden") + om.archive(order_number) + _push_event({"type": "order_update", "order_number": order_number, "status": OrderStatus.ARCHIVED.value}) + return {"status": "archived"} + + +@router.get("/api/orders/archived") +def get_archived_orders(): + return _state["om"].get_archived() + + +@router.post("/api/orders/{order_number}/unarchive") +def unarchive_order(order_number: str): + om = _state["om"] + ok = om.unarchive(order_number) + if not ok: + raise HTTPException(404, "Auftrag nicht im Archiv gefunden") + push_log(f"[{order_number}] Entarchiviert – erscheint wieder unter Aufträge.", "info") + _push_event({"type": "order_update", "order_number": order_number, "status": "new"}) + return {"status": "unarchived"} + + +@router.get("/api/orders/{order_number}/extra-files") +def get_order_extra_files(order_number: str): + """List non-SVG extra files in the order's FTP folder.""" + om = _state["om"] + order = om.get_by_number(order_number) + if not order: + raise HTTPException(404, "Auftrag nicht gefunden") + try: + ftp = FTPClient(_state["config"]) + ftp.connect() + all_files = ftp.list_folder_files(order.folder_name, recursive=True) + ftp.disconnect() + except Exception as e: + raise HTTPException(502, f"FTP-Fehler: {e}") + + # Bereits als Brief/Kuvert bekannte SVGs ausblenden (Vergleich per Basename, + # da rekursive Namen Unterordner-Pfade enthalten). Manifest ebenfalls ausblenden. + known = {os.path.basename(f) for f in (order.letters or []) + (order.envelopes or [])} + known.add("order-metadata.json") + extra = [f for f in all_files if os.path.basename(f["name"]) not in known] + return extra + + +@router.get("/api/orders/{order_number}/files/{filename:path}") +def download_order_file(order_number: str, filename: str, inline: int = 0): + """Download an SVG file from an order. Downloads from FTP if not cached locally. + + ``inline=1`` liefert die Datei zur Anzeige (Vorschau) statt als Download. + """ + om = _state["om"] + order = om.get_by_number(order_number) + if not order: + raise HTTPException(404, "Auftrag nicht gefunden") + + # Normalise: strip any leading path prefix so we only look at the base filename + local_name = os.path.basename(filename) + local_path = os.path.join(order.local_dir, local_name) + + if not os.path.exists(local_path): + # Download on demand from FTP + try: + ftp = FTPClient(_state["config"]) + ftp.connect() + os.makedirs(order.local_dir, exist_ok=True) + ftp.download_svg(order.folder_name, filename, order.local_dir) + ftp.disconnect() + except Exception as e: + raise HTTPException(502, f"Download fehlgeschlagen: {e}") + + if not os.path.exists(local_path): + raise HTTPException(404, "Datei nicht gefunden") + + ext = os.path.splitext(local_name)[1].lower() + media_map = { + ".svg": "image/svg+xml", + ".pdf": "application/pdf", + ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ".xls": "application/vnd.ms-excel", + ".csv": "text/csv", + ".png": "image/png", + ".jpg": "image/jpeg", ".jpeg": "image/jpeg", + } + media_type = media_map.get(ext, "application/octet-stream") + disposition = "inline" if inline else "attachment" + return FileResponse( + local_path, + media_type=media_type, + headers={"Content-Disposition": f'{disposition}; filename="{local_name}"'}, + ) + + +# ── Machine ────────────────────────────────────────────────────────── + +@router.get("/api/machine/status") +def machine_status(): + try: + return _state["machine"].get_status() + except Exception as e: + return {"error": str(e)} + + +@router.get("/api/machine/current") +def machine_current(): + return { + "order_number": _state.get("current_order"), + "send_letters": _state.get("send_letters", True), + "send_envelopes": _state.get("send_envelopes", True), + "current_job": _state.get("current_job"), # {order_number, letter{...}, envelope{...}} + "pending_jobs": _state.get("pending_jobs", []), + "job_history": _state.get("job_history", []), + } + + +@router.post("/api/machine/cancel") +def machine_cancel(): + cancel_event = _state.get("cancel_event") + if cancel_event: + cancel_event.set() + try: + _state["machine"].stop() + except Exception as e: + logging.warning(f"machine.stop() fehlgeschlagen: {e}") + return {"status": "cancelling"} + return {"status": "idle"} + + +@router.post("/api/machine/auto-queue") +def set_auto_queue(body: dict): + _state["auto_queue"] = bool(body.get("enabled", False)) + return {"enabled": _state.get("auto_queue", False)} + + +@router.post("/api/manual-print") +async def manual_print( + files: List[UploadFile] = File(...), + width: float = Form(0.0), + height: float = Form(0.0), + xpos: float = Form(0.0), + ypos: float = Form(0.0), + scale: float = Form(1.0), + rotation: float = Form(0.0), + machine_id: str = Form(""), +): + """Druckt eine oder mehrere manuell hochgeladene SVG-Dateien mit einem Template.""" + machine = _resolve_machine(machine_id) + + # Alle Dateien ins Temp-Verzeichnis speichern + temp_dir = os.path.join(os.path.dirname(__file__), "..", "..", "temp_manual") + os.makedirs(temp_dir, exist_ok=True) + local_paths = [] + for f in files: + local_path = os.path.join(temp_dir, f.filename or "manual.svg") + content = await f.read() + with open(local_path, "wb") as fh: + fh.write(content) + local_paths.append(local_path) + + names = ", ".join(f.filename for f in files) + params = {"width": width, "height": height, "xpos": xpos, "ypos": ypos, + "scale": scale, "rotation": rotation} + push_log( + f"[Manuell] {len(local_paths)} Datei(en): {names} — " + f"B={width}\" H={height}\" X={xpos}\" Y={ypos}\" Scale={scale} Rot={rotation}°" + ) + + _start_print_job(machine, local_paths, params, f"Manuell ({names})") + return {"status": "started", "count": len(local_paths)} + + +@router.post("/api/machine/confirm-format-change") +def confirm_format_change(): + ev = _state.get("format_confirm_event") + if ev: + ev.set() + return {"status": "confirmed"} + + +@router.post("/api/machine/cancel-job") +def cancel_job(body: dict): + """Verwirft einen ausstehenden Druckauftrag ohne Print-Log einzutragen.""" + job_id = body.get("job_id") + pending = _state.get("pending_jobs", []) + job = next((j for j in pending if j["id"] == job_id), None) + if not job: + return {"status": "not_found"} + _state["pending_jobs"] = [j for j in pending if j["id"] != job_id] + num = job["order_number"] + om = _state["om"] + order = om.get_by_number(num) + if order and order.status == OrderStatus.SENDING: + om.update_status(num, OrderStatus.NEW) + push_log(f"[{num}] Druckvorgang abgebrochen – nicht als gedruckt markiert.", "warning") + push_order_update(num, OrderStatus.NEW.value) + return {"status": "cancelled"} + + +@router.post("/api/machine/ack-job") +def ack_job(body: dict): + from datetime import datetime as _dt + job_id = body.get("job_id") + pending = _state.get("pending_jobs", []) + + job = next((j for j in pending if j["id"] == job_id), None) + if not job: + return {"status": "not_found"} + + # Aus pending entfernen + _state["pending_jobs"] = [j for j in pending if j["id"] != job_id] + + # Manueller Druck: kein Auftragsstatus-Update nötig → direkt in History + if job.get("manual"): + job["confirmed_at"] = _dt.utcnow().isoformat() + "Z" + history = _state.get("job_history", []) + history.insert(0, job) + _state["job_history"] = history[:20] + push_log(f"[Manuell] Druckbestätigung erhalten.", "success") + return {"status": "acknowledged"} + + om = _state["om"] + num = job["order_number"] + + # Print-Log jetzt eintragen + if job.get("letter_log"): + lg = job["letter_log"] + om.add_print_log(num, "letter", lg["from"], lg["to"]) + if job.get("envelope_log"): + lg = job["envelope_log"] + om.add_print_log(num, "envelope", lg["from"], lg["to"]) + + # Bedeckung prüfen und Status setzen + order = om.get_by_number(num) + if order: + l_done = _is_fully_covered(order.letter_print_log, len(order.letters)) + e_done = _is_fully_covered(order.envelope_print_log, len(order.envelopes)) + if l_done and e_done: + om.mark_processed(num) + push_log(f"[{num}] Druckvorgang bestätigt – vollständig gedruckt.", "success") + push_order_update(num, OrderStatus.SENT.value) + else: + covered_l = sum(e["to"] - e["from"] + 1 for e in order.letter_print_log) + covered_e = sum(e["to"] - e["from"] + 1 for e in order.envelope_print_log) + push_log( + f"[{num}] Druckvorgang bestätigt – " + f"{covered_l}/{len(order.letters)} Briefe, " + f"{covered_e}/{len(order.envelopes)} Kuverts.", + "info", + ) + om.update_status(num, OrderStatus.NEW) + push_order_update(num, OrderStatus.NEW.value) + + # In History aufnehmen (max. 20 Einträge) + job["confirmed_at"] = _dt.utcnow().isoformat() + "Z" + history = _state.get("job_history", []) + history.insert(0, job) + _state["job_history"] = history[:20] + + push_order_update(num, order.status.value if order else "sent") + return {"status": "acknowledged"} + + +@router.post("/api/orders/{order_number}/comment") +def set_order_comment(order_number: str, body: dict): + _state["om"].set_comment(order_number, body.get("comment", "")) + return {"status": "saved"} + + +@router.get("/api/machine/format-change-pending") +def format_change_pending(): + return { + "pending": _state.get("format_confirm_event") is not None, + "from_dims": _state.get("format_change_from", ""), + "to_dims": _state.get("format_change_to", ""), + } + + +# ── FTP ────────────────────────────────────────────────────────────── + +@router.get("/api/ftp/status") +def ftp_status(): + worker = _state.get("ftp_worker") + return {"connected": worker._connected if worker else False} + + +@router.post("/api/poll") +def force_poll(): + t = threading.Thread(target=_state["ftp_worker"]._poll, daemon=True) + t.start() + return {"status": "polling"} + + +@router.get("/api/debug") +def debug_info(): + om = _state["om"] + with om._lock: + return { + "orders_in_memory": [ + {"number": o.order_number, "status": o.status.value} + for o in om._orders.values() + ], + "processed_count": len(om._processed), + "processed": sorted(om._processed), + "archived_count": len(om._archived_orders), + "ftp_connected": _state.get("ftp_worker")._connected if _state.get("ftp_worker") else None, + } + + +@router.post("/api/reset-processed") +def reset_processed(): + """Löscht die processed_orders.json — alle Aufträge werden wieder angezeigt.""" + om = _state["om"] + with om._lock: + om._processed = set() + om._orders = {} + om._save_processed() + t = threading.Thread(target=_state["ftp_worker"]._poll, daemon=True) + t.start() + return {"status": "reset"} + + +# ── Config ─────────────────────────────────────────────────────────── + +@router.get("/api/config") +def get_config(): + cfg = _state["config"] + return { + "connection": cfg.get_section("connection"), + "machine": cfg.get_section("machine"), + "format_mapping": cfg.get_section("format_mapping"), + } + + +@router.post("/api/config") +def save_config(body: dict): + cfg = _state["config"] + if "connection" in body: + cfg.set_section("connection", body["connection"]) + if "machine" in body: + cfg.set_section("machine", body["machine"]) + if "format_mapping" in body: + cfg.set_section("format_mapping", body["format_mapping"]) + _state["ftp_worker"].reconnect() + return {"status": "saved"} + + +# ── Signatur-Service (Backend-URL konfigurierbar) ──────────────────── + +DEFAULT_SIGNATURE_BACKEND_URL = "http://localhost:8000" + + +@router.get("/api/signature/backend-url") +def get_signature_backend_url(): + """Liefert die konfigurierte URL des Signatur-Plotter-Service.""" + url = _state["config"].get("app.signature_backend_url") or DEFAULT_SIGNATURE_BACKEND_URL + return {"url": url} + + +@router.post("/api/signature/backend-url") +def set_signature_backend_url(body: dict): + """Speichert die URL des Signatur-Plotter-Service (im app-Abschnitt der Config).""" + url = (body.get("url") or "").strip() or DEFAULT_SIGNATURE_BACKEND_URL + # config.set nutzt Dot-Notation und erhält die übrigen app-Schlüssel. + _state["config"].set("app.signature_backend_url", url) + return {"status": "saved", "url": url} + + +def _reorder_a4_signatures_ltr(svg_path: str): + """ + Ordnet die obersten -Signaturgruppen einer A4-Signatur-SVG nach x-Position + (links→rechts). Der Plotter schreibt links→rechts, daher soll die linke Signatur + immer vor der rechten geschrieben werden. Fehlerrobust: bei Problemen unverändert. + """ + import xml.etree.ElementTree as ET + import re as _re + try: + ET.register_namespace("", "http://www.w3.org/2000/svg") + ET.register_namespace("xlink", "http://www.w3.org/1999/xlink") + tree = ET.parse(svg_path) + root = tree.getroot() + groups = [c for c in list(root) if c.tag.split("}")[-1] == "g"] + if len(groups) < 2: + return + def _tx(g): + m = _re.search(r"translate\(\s*([-\d.]+)", g.get("transform") or "") + return float(m.group(1)) if m else 0.0 + ordered = sorted(groups, key=_tx) + if ordered == groups: + return # bereits links→rechts + for g in groups: + root.remove(g) + for g in ordered: + root.append(g) + tree.write(svg_path, encoding="unicode") + except Exception: + pass # SVG im Zweifel unverändert lassen + + +@router.post("/api/signature/print") +async def signature_print( + pdf: UploadFile = File(...), + svgs: List[UploadFile] = File(default=[]), + job: str = Form(...), + width: float = Form(0.0), + height: float = Form(0.0), + xpos: float = Form(0.0), + ypos: float = Form(0.0), + scale: float = Form(1.0), + rotation: float = Form(0.0), + machine_id: str = Form(""), + pages: str = Form(""), +): + """ + Rendert die Signaturen über den Signatur-Service (/render) und schickt die + erzeugten A4-SVGs direkt mit dem gewählten Template an die Maschine. + + ``pages`` (0-basiert, z.B. "0-4" oder "0,3,5-7") wählt nur bestimmte Seiten; + leer = alle. + """ + backend = _state["config"].get("app.signature_backend_url") or DEFAULT_SIGNATURE_BACKEND_URL + + # 1. Eingaben an den Signatur-Service durchreichen → ZIP mit A4-SVGs. + files = [("pdf", (pdf.filename or "doc.pdf", await pdf.read(), "application/pdf"))] + for s in svgs: + files.append(("svgs", (s.filename, await s.read(), "image/svg+xml"))) + try: + resp = requests.post( + backend.rstrip("/") + "/render", files=files, data={"job": job}, timeout=120 + ) + except requests.RequestException as e: + raise HTTPException(502, f"Signatur-Service nicht erreichbar ({backend}): {e}") + + if resp.status_code != 200: + # Fehlerliste des Service unverändert durchreichen. + try: + detail = resp.json().get("detail") + except Exception: + detail = resp.text + raise HTTPException(resp.status_code, detail=detail) + + # 2. ZIP entpacken → einzelne A4-SVGs lokal ablegen. + try: + zf = zipfile.ZipFile(io.BytesIO(resp.content)) + except zipfile.BadZipFile: + raise HTTPException(502, "Signatur-Service lieferte kein gültiges ZIP.") + temp_dir = os.path.join(os.path.dirname(__file__), "..", "..", "temp_signature") + os.makedirs(temp_dir, exist_ok=True) + local_paths = [] + for name in zf.namelist(): + path = os.path.join(temp_dir, os.path.basename(name)) + with open(path, "wb") as fh: + fh.write(zf.read(name)) + local_paths.append(path) + if not local_paths: + raise HTTPException(422, "Keine Signaturen erzeugt.") + + # Natürlich nach Seitennummer sortieren (seite_1, seite_2, …, seite_10) + import re as _re + def _page_num(p): + m = _re.search(r"(\d+)", os.path.basename(p)) + return int(m.group(1)) if m else 0 + local_paths.sort(key=_page_num) + + # Seitenauswahl (0-basiert) anwenden + total_pages = len(local_paths) + sel = _parse_num_spec(pages, total_pages - 1) + if sel is not None: + if not sel: + raise HTTPException(422, f"Keine gültige Seite im Bereich 0–{total_pages - 1}.") + local_paths = [local_paths[i] for i in sel] + pages_label = _nums_summary(sel) + else: + pages_label = f"0–{total_pages - 1}" + + # Signaturen je Seite links→rechts ordnen (Plotter schreibt in diese Richtung) + for _p in local_paths: + _reorder_a4_signatures_ltr(_p) + + # 3. A4-Signaturen mit Template-Parametern an die Maschine senden. + machine = _resolve_machine(machine_id) + params = {"width": width, "height": height, "xpos": xpos, "ypos": ypos, + "scale": scale, "rotation": rotation} + push_log( + f"[Signatur] {len(local_paths)} A4-Signatur(en) drucken (Seite {pages_label}) — " + f"B={width}\" H={height}\" X={xpos}\" Y={ypos}\" Scale={scale} Rot={rotation}°" + ) + _start_print_job(machine, local_paths, params, f"Signatur (Seite {pages_label})") + return {"status": "started", "count": len(local_paths)} + + +# ── Import / Export ────────────────────────────────────────────────── + +@router.get("/api/export/settings") +def export_settings(): + cfg = _state["config"] + payload = { + "connection": cfg.get_section("connection"), + "machine": cfg.get_section("machine"), + "templates": cfg.get_templates(), + "format_mapping": cfg.get_section("format_mapping"), + } + content = json.dumps(payload, indent=2, ensure_ascii=False) + return StreamingResponse( + iter([content]), + media_type="application/json", + headers={"Content-Disposition": "attachment; filename=skrift-settings.json"}, + ) + + +@router.get("/api/export/archive") +def export_archive(): + items = _state["om"].get_archived() + content = json.dumps({"archived": items}, indent=2, ensure_ascii=False) + return StreamingResponse( + iter([content]), + media_type="application/json", + headers={"Content-Disposition": "attachment; filename=skrift-archiv.json"}, + ) + + +@router.get("/api/export/history") +def export_history(): + om = _state["om"] + with om._lock: + processed = sorted(om._processed) + content = json.dumps({"processed": processed}, indent=2, ensure_ascii=False) + return StreamingResponse( + iter([content]), + media_type="application/json", + headers={"Content-Disposition": "attachment; filename=skrift-history.json"}, + ) + + +@router.post("/api/import/settings") +def import_settings(body: dict): + cfg = _state["config"] + if "connection" in body: + cfg.set_section("connection", body["connection"]) + if "machine" in body: + cfg.set_section("machine", body["machine"]) + if "format_mapping" in body: + cfg.set_section("format_mapping", body["format_mapping"]) + if "templates" in body: + for key, tpl in body["templates"].items(): + cfg.save_template(key, tpl) + _state["ftp_worker"].reconnect() + return {"status": "imported"} + + +@router.post("/api/import/archive") +def import_archive(body: dict): + om = _state["om"] + imported = 0 + with om._lock: + for entry in body.get("archived", []): + num = entry.get("order_number") + if num and num not in om._archived_orders: + om._archived_orders[num] = entry + imported += 1 + om._save_archive() + return {"status": "imported", "count": imported} + + +@router.post("/api/import/history") +def import_history(body: dict): + om = _state["om"] + added = 0 + with om._lock: + for num in body.get("processed", []): + if num not in om._processed: + om._processed.add(num) + added += 1 + om._save_processed() + return {"status": "imported", "count": added} + + +# ── Templates ──────────────────────────────────────────────────────── + +@router.get("/api/templates") +def get_templates(): + return _state["config"].get_templates() + + +@router.post("/api/templates/{key}") +def save_template(key: str, body: dict): + _state["config"].save_template(key, body) + return {"status": "saved"} + + +@router.delete("/api/templates/{key}") +def delete_template(key: str): + _state["config"].delete_template(key) + return {"status": "deleted"} + + +# ── Machines config ────────────────────────────────────────────── + +@router.get("/api/machines") +def get_machines(): + cfg = _state["config"] + return cfg.get_section("machines") or [] + + +@router.post("/api/machines") +def save_machines(body: dict): + cfg = _state["config"] + cfg.set_section("machines", body.get("machines", [])) + return {"status": "saved"} + + +# ── Helpers ────────────────────────────────────────────────────────── + +def _push_event(event: dict): + """Thread-safe push to the SSE queue.""" + q: asyncio.Queue = _state.get("event_queue") + loop = _state.get("loop") + if q and loop: + asyncio.run_coroutine_threadsafe(q.put(event), loop) + + +def push_log(message: str, level: str = "info"): + _push_event({"type": "log", "message": message, "level": level}) + + +def push_machine_status(status): + _push_event({"type": "machine_status", "status": status}) + + +def push_order_update(order_number: str, status: str, error: str = None): + _push_event({"type": "order_update", "order_number": order_number, + "status": status, "error": error}) + + +def push_new_orders(): + _push_event({"type": "new_orders"}) + + +def push_ftp_status(connected: bool): + _push_event({"type": "ftp_status", "connected": connected}) + + +def _resolve_machine(machine_id: str): + """Liefert den passenden MachineAPIClient (Standard oder per ID gewählt).""" + machine = _state["machine"] + if machine_id: + machines_cfg = _state["config"].get_section("machines") + mc = next((m for m in machines_cfg if m.get("id") == machine_id), None) + if mc: + from app.core.machine_client import MachineAPIClient + machine = MachineAPIClient(_state["config"], machine_override=mc) + return machine + + +def _start_print_job(machine, local_paths, params, label): + """ + Sendet SVGs an die Maschine (inkl. Formatwechsel-Prüfung) und legt einen + pending Job an. Gemeinsam genutzt von Manuell-Druck und Signatur-Direktdruck. + """ + cancel_event = threading.Event() + + def _do(): + import uuid as _uuid + from datetime import datetime as _dt + try: + if not _prompt_format_change_if_needed(params, label, cancel_event): + push_log(f"[{label}] Abgebrochen – Formatwechsel nicht bestätigt.", "warning") + return + machine.send_svgs(svg_paths=local_paths, clear=True, start=True, **params) + _state["last_used_format"] = {"width": params["width"], "height": params["height"]} + push_log(f"[{label}] {len(local_paths)} Datei(en) erfolgreich gesendet.", "success") + + job_entry = { + "id": str(_uuid.uuid4()), + "order_number": label, + "send_letters": True, + "send_envelopes": False, + "letter_log": None, + "envelope_log": None, + "started_at": _dt.utcnow().isoformat() + "Z", + "manual": True, + "file_count": len(local_paths), + } + _state["pending_jobs"].append(job_entry) + _push_event({ + "type": "job_complete", + "order_number": label, + "send_letters": True, + "send_envelopes": False, + "job_id": job_entry["id"], + "manual": True, + }) + except Exception as e: + push_log(f"[{label}] Fehler: {e}", "error") + + threading.Thread(target=_do, daemon=True).start() + + +def _is_fully_covered(log: list, total: int) -> bool: + """True when all indices 1..total are covered by the print log.""" + if total == 0: + return True + covered: set[int] = set() + for entry in log: + covered.update(range(entry["from"], entry["to"] + 1)) + return len(covered) >= total + + +def _formats_differ(p1: dict, p2: dict) -> bool: + return (abs(p1.get("width", 0) - p2.get("width", 0)) > 0.01 or + abs(p1.get("height", 0) - p2.get("height", 0)) > 0.01) + + +def _prompt_format_change_if_needed(new_params: dict, order_num: str, cancel_event) -> bool: + """ + Prüft ob sich das Format gegenüber dem zuletzt genutzten geändert hat. + Wenn ja: User-Prompt via SSE + warten auf Bestätigung. + Gibt False zurück wenn abgebrochen, True wenn bestätigt / kein Wechsel nötig. + """ + last = _state.get("last_used_format") + if last is None: + # Erster Auftrag überhaupt – kein Vergleich möglich, kein Prompt + return True + if not _formats_differ(last, new_params): + return True + + # Format hat sich geändert → Prompt + from_label = f'{last["width"]}" × {last["height"]}"' + to_label = f'{new_params["width"]}" × {new_params["height"]}"' + + push_log( + f"[{order_num}] Formatwechsel: {from_label} → {to_label}. " + "Bitte Druckmaterial tauschen.", "warning" + ) + confirm_ev = threading.Event() + _state["format_confirm_event"] = confirm_ev + _state["format_change_from"] = from_label + _state["format_change_to"] = to_label + _push_event({ + "type": "format_change", + "order_number": order_num, + "from_dims": from_label, + "to_dims": to_label, + }) + + # Warten bis bestätigt oder abgebrochen + while not confirm_ev.wait(timeout=5): + if cancel_event and cancel_event.is_set(): + _state["format_confirm_event"] = None + return False + + _state["format_confirm_event"] = None + return True + + +def _run_send_job(order, letter_params, env_params, + letter_indices=None, envelope_indices=None, + send_letters=True, send_envelopes=True, machine_id=""): + om = _state["om"] + machine = _state["machine"] + config = _state["config"] + + # Select machine by ID if specified + if machine_id: + machines_cfg = config.get_section("machines") + machine_cfg = next((m for m in machines_cfg if m.get("id") == machine_id), None) + if machine_cfg: + from app.core.machine_client import MachineAPIClient + machine = MachineAPIClient(config, machine_override=machine_cfg) + + # Set up cancel event and track current order + cancel_event = threading.Event() + _state["cancel_event"] = cancel_event + _state["current_order"] = order.order_number + _state["send_letters"] = send_letters + _state["send_envelopes"] = send_envelopes + + om.update_status(order.order_number, OrderStatus.SENDING) + push_order_update(order.order_number, OrderStatus.SENDING.value) + + try: + # Download SVGs + ftp = FTPClient(config) + ftp.connect() + push_log(f"[{order.order_number}] SVGs werden heruntergeladen…") + os.makedirs(order.local_dir, exist_ok=True) + for filename in order.letters + order.envelopes: + ftp.download_svg(order.folder_name, filename, order.local_dir) + ftp.disconnect() + + # Check idle + if not machine.is_idle(): + raise RuntimeError("Maschine ist nicht bereit (nicht Idle).") + + # os.path.basename weil Kuverts als 'umschlaege/xxx.svg' gespeichert, aber lokal flach liegen + all_letter_paths = [os.path.join(order.local_dir, os.path.basename(f)) for f in order.letters] + all_env_paths = [os.path.join(order.local_dir, os.path.basename(f)) for f in order.envelopes] + + def _resolve_indices(indices, total): + # 0-basierte Auswahl (Briefnummern). None/leer = alle. + if not indices: + return list(range(total)) + return sorted({int(i) for i in indices if 0 <= int(i) < total}) + + # Gewählte 0-basierte Nummern → konkrete Dateien + l_idx = _resolve_indices(letter_indices, len(all_letter_paths)) if send_letters else [] + e_idx = _resolve_indices(envelope_indices, len(all_env_paths)) if send_envelopes else [] + letter_paths = [all_letter_paths[i] for i in l_idx] + env_paths = [all_env_paths[i] for i in e_idx] + + # Info über den laufenden Auftrag für die Aufträge-Seite (Template + Seiten) + def _tpl_dims(p): + return f'{p.get("width", 0)}"×{p.get("height", 0)}"' + _state["current_job"] = { + "order_number": order.order_number, + "letter": ({"nums": _nums_summary(l_idx), "count": len(l_idx), "dims": _tpl_dims(letter_params)} + if (send_letters and l_idx) else None), + "envelope": ({"nums": _nums_summary(e_idx), "count": len(e_idx), "dims": _tpl_dims(env_params)} + if (send_envelopes and e_idx) else None), + } + + def _cancelled(): + if cancel_event.is_set(): + push_log(f"[{order.order_number}] Auftrag abgebrochen.", "warning") + om.update_status(order.order_number, OrderStatus.NEW) + push_order_update(order.order_number, OrderStatus.NEW.value) + return True + return False + + if send_letters and letter_paths: + # Format-Wechsel-Prüfung (auch gegenüber vorherigem Job) + if not _prompt_format_change_if_needed(letter_params, order.order_number, cancel_event): + om.update_status(order.order_number, OrderStatus.NEW) + push_order_update(order.order_number, OrderStatus.NEW.value) + return + if _cancelled(): return + + push_log( + f"[{order.order_number}] Briefe senden (Nr. {_nums_summary(l_idx)}, " + f"{len(letter_paths)} Stk.) — " + f"B={letter_params['width']}\" H={letter_params['height']}\" " + f"X={letter_params['xpos']}\" Y={letter_params['ypos']}\" " + f"Scale={letter_params['scale']} Rot={letter_params['rotation']}°" + ) + machine.send_svgs( + svg_paths=letter_paths, + width=letter_params["width"], height=letter_params["height"], + xpos=letter_params["xpos"], ypos=letter_params["ypos"], + rotation=letter_params["rotation"], scale=letter_params["scale"], + clear=True, start=True, + ) + _state["last_used_format"] = {"width": letter_params["width"], "height": letter_params["height"]} + if _cancelled(): return + + if send_letters and send_envelopes and letter_paths and env_paths: + _wait_for_idle(order.order_number, cancel_event=cancel_event) + if _cancelled(): return + + if send_envelopes and env_paths: + # Format-Wechsel-Prüfung vor Kuverts (auch gegenüber vorherigem/aktuellem Job) + if not _prompt_format_change_if_needed(env_params, order.order_number, cancel_event): + om.update_status(order.order_number, OrderStatus.NEW) + push_order_update(order.order_number, OrderStatus.NEW.value) + return + if _cancelled(): return + + push_log( + f"[{order.order_number}] Kuverts senden (Nr. {_nums_summary(e_idx)}, " + f"{len(env_paths)} Stk.) — " + f"B={env_params['width']}\" H={env_params['height']}\" " + f"X={env_params['xpos']}\" Y={env_params['ypos']}\" " + f"Scale={env_params['scale']} Rot={env_params['rotation']}°" + ) + machine.send_svgs( + svg_paths=env_paths, + width=env_params["width"], height=env_params["height"], + xpos=env_params["xpos"], ypos=env_params["ypos"], + rotation=env_params["rotation"], scale=env_params["scale"], + clear=True, start=True, + ) + _state["last_used_format"] = {"width": env_params["width"], "height": env_params["height"]} + if _cancelled(): return + + # Automatischer Abschluss – keine manuelle Druckbestätigung nötig. + # Print-Log direkt schreiben und Auftragsstatus setzen, damit die + # Warteschlange sofort weiterlaufen kann, sobald die Maschine frei ist. + push_log(f"[{order.order_number}] Dateien übertragen.", "success") + if send_letters and letter_paths: + om.add_print_indices(order.order_number, "letter", l_idx) + if send_envelopes and env_paths: + om.add_print_indices(order.order_number, "envelope", e_idx) + + updated = om.get_by_number(order.order_number) + if updated: + l_done = _is_fully_covered(updated.letter_print_log, len(updated.letters)) + e_done = _is_fully_covered(updated.envelope_print_log, len(updated.envelopes)) + else: + l_done = e_done = True + + # Wurde der Auftrag zwischenzeitlich für den ANDEREN Typ neu eingereiht + # (z.B. Kuverts anhängen während Briefe drucken), darf der Abschluss den + # QUEUED-Status nicht überschreiben – sonst geht die Nachfolge verloren. + requeued = bool(updated) and updated.status == OrderStatus.QUEUED + if l_done and e_done: + om.mark_processed(order.order_number) + push_log(f"[{order.order_number}] Vollständig gedruckt.", "success") + push_order_update(order.order_number, OrderStatus.SENT.value) + elif requeued: + push_log(f"[{order.order_number}] Teil gedruckt – Rest steht in der Warteschlange.", "info") + push_order_update(order.order_number, OrderStatus.QUEUED.value) + else: + om.update_status(order.order_number, OrderStatus.NEW) + covered_l = sum(e["to"] - e["from"] + 1 for e in updated.letter_print_log) if updated else 0 + covered_e = sum(e["to"] - e["from"] + 1 for e in updated.envelope_print_log) if updated else 0 + push_log( + f"[{order.order_number}] Teil gedruckt – " + f"{covered_l}/{len(updated.letters) if updated else 0} Briefe, " + f"{covered_e}/{len(updated.envelopes) if updated else 0} Kuverts.", + "info", + ) + push_order_update(order.order_number, OrderStatus.NEW.value) + + _push_event({"type": "job_complete", "order_number": order.order_number}) + + except Exception as e: + msg = str(e) + om.update_status(order.order_number, OrderStatus.ERROR, msg) + push_log(f"[{order.order_number}] Fehler: {msg}", "error") + # Wenn Maschinenparameter im Fehlertext: auch separat anzeigen + if "params:" in msg: + push_log(f" → {msg.split('params:')[-1].strip()}", "error") + push_order_update(order.order_number, OrderStatus.ERROR.value, msg) + + finally: + _state["current_order"] = None + _state["current_job"] = None + _state["cancel_event"] = None + _state["send_letters"] = True + _state["send_envelopes"] = True + _state["format_confirm_event"] = None + + +def _wait_for_idle(order_num, timeout=300, cancel_event=None): + import time + machine = _state["machine"] + config = _state["config"] + elapsed = 0 + interval = config.get("machine.poll_interval_seconds", 5) + while elapsed < timeout: + if cancel_event and cancel_event.is_set(): + return + if machine.is_idle(): + return + time.sleep(interval) + elapsed += interval + raise RuntimeError(f"Timeout: Maschine wurde nicht Idle nach {timeout}s") diff --git a/Produktion/skrift-produktion/app/constants.py b/Produktion/skrift-produktion/app/constants.py new file mode 100644 index 0000000..085af02 --- /dev/null +++ b/Produktion/skrift-produktion/app/constants.py @@ -0,0 +1,28 @@ +from enum import Enum + + +class OrderStatus(str, Enum): + NEW = "new" + QUEUED = "queued" + SENDING = "sending" + SENT = "sent" + ERROR = "error" + ARCHIVED = "archived" + + +class MachineStatus(str, Enum): + IDLE = "Idle" + PRINTING = "Printing" + ERROR = "Error" + PAUSED = "Paused" + UNKNOWN = "Unknown" + + +MACHINE_ERROR_CODES = { + 0: "Erfolgreich", + -1: "Parameterfehler", + -2: "Keine Maschine verfügbar", + -3: "Service beschäftigt", + 1: "Fehler beim Generieren der Schreibdatei", + 2: "Bitte erneut in der Software einloggen", +} diff --git a/Produktion/skrift-produktion/app/core/__init__.py b/Produktion/skrift-produktion/app/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/Produktion/skrift-produktion/app/core/config_manager.py b/Produktion/skrift-produktion/app/core/config_manager.py new file mode 100644 index 0000000..68a4d3c --- /dev/null +++ b/Produktion/skrift-produktion/app/core/config_manager.py @@ -0,0 +1,176 @@ +import json +import os +import copy +from typing import Any + +DEFAULT_CONFIG = { + "format_mapping": { + "letter": { + "A4": "a4_single", + "A6 Hochformat": "a6_v1", + "A6 Querformat": "a6_v2", + }, + "envelope": { + "DIN Lang": "kuvert_a_v1", + "C6": "kuvert_b_v1", + }, + }, + "connection": { + "protocol": "sftp", + "host": "", + "port": 22, + "username": "", + "password": "", + "private_key_path": "", + "remote_path": "/orders/", + "poll_interval_seconds": 30, + }, + "machine": { + "base_url": "http://192.168.2.32:90", + "username": "", + "password": "", + "mid": "", + "poll_interval_seconds": 5, + }, + "templates": { + "a4_single": { + "name": "A4 Brief – Einzeln", + "width": 8.27, + "height": 11.69, + "xpos": 0.0, + "ypos": -0.3, + "scale": 1.0, + "rotation": 0, + }, + "a6_v1": { + "name": "A6 Brief – Variante 1", + "width": 4.1, + "height": 5.8, + "xpos": -0.3, + "ypos": -0.3, + "scale": 1.0, + "rotation": 0, + }, + "a6_v2": { + "name": "A6 Brief – Variante 2", + "width": 4.1, + "height": 5.8, + "xpos": 4.0, + "ypos": -0.3, + "scale": 1.0, + "rotation": 90, + }, + "kuvert_a_v1": { + "name": "Kuvert A – Variante 1", + "width": 4.49, + "height": 6.38, + "xpos": 4.2, + "ypos": -0.2, + "scale": 1.0, + "rotation": 90, + }, + "kuvert_a_v2": { + "name": "Kuvert A – Variante 2", + "width": 4.33, + "height": 8.27, + "xpos": 4.0, + "ypos": -0.2, + "scale": 1.0, + "rotation": 90, + }, + "kuvert_b_v1": { + "name": "Kuvert B – Variante 1", + "width": 3.9, + "height": 8.27, + "xpos": 4.49, + "ypos": -0.2, + "scale": 1.0, + "rotation": 90, + }, + }, + "app": { + "theme": "dark", + "local_temp_dir": "", + }, + "machines": [], +} + + +class ConfigManager: + def __init__(self, config_path: str): + self._path = config_path + self._data: dict = {} + self._load() + + def _load(self): + if os.path.exists(self._path): + with open(self._path, "r", encoding="utf-8") as f: + loaded = json.load(f) + self._data = self._deep_merge(copy.deepcopy(DEFAULT_CONFIG), loaded) + else: + self._data = copy.deepcopy(DEFAULT_CONFIG) + self._save() + + def _save(self): + os.makedirs(os.path.dirname(self._path), exist_ok=True) + with open(self._path, "w", encoding="utf-8") as f: + json.dump(self._data, f, indent=2, ensure_ascii=False) + + def _deep_merge(self, base: dict, override: dict) -> dict: + result = base.copy() + for key, value in override.items(): + if key in result and isinstance(result[key], dict) and isinstance(value, dict): + result[key] = self._deep_merge(result[key], value) + else: + result[key] = value + return result + + def get(self, key_path: str, default: Any = None) -> Any: + """Access nested keys with dot notation: 'connection.host'""" + parts = key_path.split(".") + current = self._data + for part in parts: + if isinstance(current, dict) and part in current: + current = current[part] + else: + return default + return current + + def set(self, key_path: str, value: Any): + """Set nested key with dot notation and persist.""" + parts = key_path.split(".") + current = self._data + for part in parts[:-1]: + current = current.setdefault(part, {}) + current[parts[-1]] = value + self._save() + + def get_section(self, section: str) -> dict: + return copy.deepcopy(self._data.get(section, {})) + + def set_section(self, section: str, data: dict): + self._data[section] = data + self._save() + + def get_templates(self) -> dict: + return copy.deepcopy(self._data.get("templates", {})) + + def save_template(self, key: str, template: dict): + if "templates" not in self._data: + self._data["templates"] = {} + self._data["templates"][key] = template + self._save() + + def delete_template(self, key: str): + self._data.get("templates", {}).pop(key, None) + self._save() + + def get_local_temp_dir(self) -> str: + path = self.get("app.local_temp_dir") or os.path.join( + os.environ.get("TEMP", os.path.expanduser("~")), "ftp_checker" + ) + os.makedirs(path, exist_ok=True) + return path + + def reload(self): + self._load() diff --git a/Produktion/skrift-produktion/app/core/ftp_client.py b/Produktion/skrift-produktion/app/core/ftp_client.py new file mode 100644 index 0000000..b54e090 --- /dev/null +++ b/Produktion/skrift-produktion/app/core/ftp_client.py @@ -0,0 +1,219 @@ +import errno +import ftplib +import io +import json +import os +import stat +from typing import Optional + + +class FTPConnectionError(Exception): + pass + + +class ManifestNotFound(Exception): + """Der Ordner enthält (noch) keine order-metadata.json – kein echter Fehler.""" + + +def _is_missing_file(exc: Exception) -> bool: + """Erkennt 'Datei existiert nicht' über SFTP (ENOENT) und FTP (550).""" + if isinstance(exc, FileNotFoundError): + return True + if isinstance(exc, OSError) and getattr(exc, "errno", None) == errno.ENOENT: + return True + if isinstance(exc, ftplib.error_perm) and str(exc).startswith("550"): + return True + return False + + +class FTPClient: + """ + Protocol-agnostic FTP/SFTP client. + Uses paramiko for SFTP and ftplib for FTP. + """ + + def __init__(self, config): + self._config = config + self._sftp = None + self._ssh = None + self._ftp: Optional[ftplib.FTP] = None + self._protocol: str = "sftp" + + def connect(self): + self._protocol = self._config.get("connection.protocol", "sftp").lower() + host = self._config.get("connection.host", "") + port = int(self._config.get("connection.port", 22)) + username = self._config.get("connection.username", "") + password = self._config.get("connection.password", "") + key_path = self._config.get("connection.private_key_path", "") + + try: + if self._protocol == "sftp": + import paramiko + self._ssh = paramiko.SSHClient() + self._ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + self._ssh.connect( + hostname=host, + port=port, + username=username, + password=password or None, + key_filename=key_path or None, + timeout=10, + ) + self._sftp = self._ssh.open_sftp() + else: + ftp_port = int(self._config.get("connection.port", 21)) + self._ftp = ftplib.FTP() + self._ftp.connect(host, ftp_port, timeout=10) + self._ftp.login(username, password) + except Exception as e: + raise FTPConnectionError(f"Verbindung fehlgeschlagen: {e}") from e + + def disconnect(self): + try: + if self._sftp: + self._sftp.close() + if self._ssh: + self._ssh.close() + if self._ftp: + self._ftp.quit() + except Exception: + pass + finally: + self._sftp = None + self._ssh = None + self._ftp = None + + def is_connected(self) -> bool: + try: + if self._protocol == "sftp": + if self._sftp is None: + return False + self._sftp.listdir(".") + return True + else: + if self._ftp is None: + return False + self._ftp.voidcmd("NOOP") + return True + except Exception: + return False + + def list_order_folders(self) -> list[str]: + remote_path = self._config.get("connection.remote_path", "/orders/") + try: + if self._protocol == "sftp": + entries = self._sftp.listdir_attr(remote_path) + return [ + e.filename + for e in entries + if stat.S_ISDIR(e.st_mode) + ] + else: + original = self._ftp.pwd() + self._ftp.cwd(remote_path) + folders = [] + lines = [] + self._ftp.retrlines("LIST", lines.append) + for line in lines: + parts = line.split() + if parts and parts[0].startswith("d"): + name = parts[-1] + if name not in (".", ".."): + folders.append(name) + self._ftp.cwd(original) + return folders + except Exception as e: + raise FTPConnectionError(f"Ordner auflisten fehlgeschlagen: {e}") from e + + def list_folder_files(self, folder: str, recursive: bool = False) -> list[dict]: + """ + Listet Dateien im Auftragsordner auf. Mit ``recursive=True`` werden auch + Dateien in Unterordnern (z.B. 'umschlaege/') aufgenommen; ``name`` enthält + dann den relativen Pfad (z.B. 'umschlaege/kuvert_000.svg'). + """ + remote_path = self._config.get("connection.remote_path", "/orders/") + folder_path = f"{remote_path.rstrip('/')}/{folder}" + try: + if self._protocol == "sftp": + return self._list_sftp(folder_path, "", recursive) + else: + return self._list_ftp(folder_path, "", recursive) + except Exception as e: + raise FTPConnectionError(f"Dateiliste fehlgeschlagen ({folder}): {e}") from e + + def _list_sftp(self, base_path: str, prefix: str, recursive: bool) -> list[dict]: + out = [] + for e in self._sftp.listdir_attr(base_path): + rel = f"{prefix}{e.filename}" + if stat.S_ISDIR(e.st_mode): + if recursive: + out.extend(self._list_sftp(f"{base_path}/{e.filename}", rel + "/", True)) + else: + out.append({"name": rel, "size": e.st_size or 0}) + return out + + def _list_ftp(self, base_path: str, prefix: str, recursive: bool) -> list[dict]: + original = self._ftp.pwd() + self._ftp.cwd(base_path) + lines = [] + self._ftp.retrlines("LIST", lines.append) + self._ftp.cwd(original) + out = [] + for line in lines: + parts = line.split() + if not parts: + continue + name = parts[-1] + if name in (".", ".."): + continue + if parts[0].startswith("d"): + if recursive: + out.extend(self._list_ftp(f"{base_path}/{name}", f"{prefix}{name}/", True)) + else: + try: + size = int(parts[4]) + except (IndexError, ValueError): + size = 0 + out.append({"name": f"{prefix}{name}", "size": size}) + return out + + def read_order_manifest(self, folder: str) -> dict: + remote_path = self._config.get("connection.remote_path", "/orders/") + manifest_path = f"{remote_path.rstrip('/')}/{folder}/order-metadata.json" + try: + if self._protocol == "sftp": + with self._sftp.open(manifest_path, "r") as f: + return json.loads(f.read()) + else: + buf = io.BytesIO() + self._ftp.retrbinary(f"RETR {manifest_path}", buf.write) + return json.loads(buf.getvalue().decode("utf-8")) + except json.JSONDecodeError as e: + raise ValueError(f"Ungültiges JSON in {manifest_path}: {e}") from e + except Exception as e: + # Fehlende Datei ist der Normalfall (Ordner noch im Upload o.Ä.) – + # gesondert melden, damit der Poller nicht jeden Zyklus warnt. + if _is_missing_file(e): + raise ManifestNotFound(folder) from e + raise FTPConnectionError(f"Manifest lesen fehlgeschlagen: {e}") from e + + def download_svg(self, folder: str, filename: str, local_dir: str) -> str: + """ + Lädt eine SVG-Datei herunter. filename kann einen Unterordnerpfad + enthalten (z.B. 'umschlaege/kuvert_000.svg') — lokal wird nur der + Dateiname (ohne Unterordner) gespeichert. + """ + remote_path = self._config.get("connection.remote_path", "/orders/") + remote_file = f"{remote_path.rstrip('/')}/{folder}/{filename}" + os.makedirs(local_dir, exist_ok=True) + local_path = os.path.join(local_dir, os.path.basename(filename)) + try: + if self._protocol == "sftp": + self._sftp.get(remote_file, local_path) + else: + with open(local_path, "wb") as f: + self._ftp.retrbinary(f"RETR {remote_file}", f.write) + return local_path + except Exception as e: + raise FTPConnectionError(f"Download fehlgeschlagen ({filename}): {e}") from e diff --git a/Produktion/skrift-produktion/app/core/machine_client.py b/Produktion/skrift-produktion/app/core/machine_client.py new file mode 100644 index 0000000..1f08693 --- /dev/null +++ b/Produktion/skrift-produktion/app/core/machine_client.py @@ -0,0 +1,182 @@ +import threading +from typing import Optional + +import requests + +from app.constants import MACHINE_ERROR_CODES, MachineStatus + + +class MachineAPIError(Exception): + def __init__(self, code: int, message: str = ""): + self.code = code + self.message = message or MACHINE_ERROR_CODES.get(code, f"Unbekannter Fehler (Code {code})") + super().__init__(self.message) + + +class MachineAPIClient: + def __init__(self, config, machine_override: dict = None): + self._config = config + self._override = machine_override or {} + self._session = requests.Session() + self._lock = threading.Lock() + + @property + def _base_url(self) -> str: + if self._override: + return self._override.get("base_url", "").rstrip("/") + return self._config.get("machine.base_url", "").rstrip("/") + + def login(self) -> bool: + if self._override: + username = self._override.get("username", "") + password = self._override.get("password", "") + else: + username = self._config.get("machine.username", "") + password = self._config.get("machine.password", "") + if not username: + return True # No login required + with self._lock: + resp = self._session.get( + f"{self._base_url}/login", + params={"uin": username, "passwd": password}, + timeout=10, + ) + data = resp.json() + return data.get("error", -1) == 0 + + def get_status(self) -> list[dict]: + """Returns list of machine status dicts.""" + if self._override: + mid = self._override.get("mid", "") + else: + mid = self._config.get("machine.mid", "") + params = {} + if mid: + params["mids"] = mid + with self._lock: + resp = self._session.get( + f"{self._base_url}/machine_status", + params=params, + timeout=10, + ) + data = resp.json() + if isinstance(data, list): + return data + return [] + + def get_first_status(self) -> Optional[dict]: + statuses = self.get_status() + return statuses[0] if statuses else None + + def is_idle(self) -> bool: + status = self.get_first_status() + if status is None: + return False + return status.get("status") == MachineStatus.IDLE + + def recover_write(self, mid: str = "") -> bool: + """Setzt einen bereits in der Maschine liegenden Schreib-Task nach einem + Fehler fort ('Feed again' / recover_write). + + WICHTIG: Es wird KEINE Datei neu hochgeladen – der vorhandene Task wird + nur neu zugeführt/fortgesetzt. Gibt True zurück, wenn die Maschine den + Recovery bestätigt ({"code":0}). + """ + if self._override: + effective_mid = mid or self._override.get("mid", "") + else: + effective_mid = mid or self._config.get("machine.mid", "") + params = {} + if effective_mid: + params["mid"] = effective_mid + with self._lock: + resp = self._session.get( + f"{self._base_url}/recover_write", + params=params, + timeout=10, + ) + try: + data = resp.json() + except Exception: + raise MachineAPIError(-1, f"Ungültige recover_write-Antwort (HTTP {resp.status_code}): {resp.text[:200]}") + return data.get("code", -1) == 0 + + def send_svgs( + self, + svg_paths: list[str], + width: float, + height: float, + xpos: float = 0, + ypos: float = 0, + rotation: float = 0, + scale: float = 1.0, + mid: str = "", + clear: bool = True, + start: bool = True, + repeat: int = 1, + ) -> dict: + """ + Upload one or more SVG files in a single multipart POST. + All files share the same width/height/xpos/ypos/rotation/scale. + """ + def _fmt(v: float) -> str: + """Format float without trailing .0 so the machine API gets '90' not '90.0'.""" + return str(int(v)) if float(v) == int(v) else str(v) + + data = { + "width": f"{_fmt(width)}in", + "height": f"{_fmt(height)}in", + "xpos": f"{_fmt(xpos)}in", + "ypos": f"{_fmt(ypos)}in", + "rotation": _fmt(rotation), + "scale": _fmt(scale), + "clear": "1" if clear else "0", + "start": "1" if start else "0", + "repeat": str(repeat), + } + if self._override: + effective_mid = mid or self._override.get("mid", "") + else: + effective_mid = mid or self._config.get("machine.mid", "") + if effective_mid: + data["mid"] = effective_mid + + import os, logging + _log = logging.getLogger(__name__) + last = len(svg_paths) - 1 + result = {} + for i, path in enumerate(svg_paths): + file_data = { + **data, + "clear": "1" if i == 0 else "0", + "start": "1" if i == last else "0", + } + _log.info( + "write_svg [%d/%d] %s | params: %s", + i + 1, last + 1, os.path.basename(path), + {k: v for k, v in file_data.items() if k != "file"}, + ) + with open(path, "rb") as h: + files = [("file", (os.path.basename(path), h, "image/svg+xml"))] + with self._lock: + resp = self._session.post( + f"{self._base_url}/write_svg", + data=file_data, + files=files, + timeout=60, + ) + try: + result = resp.json() + except Exception: + raise MachineAPIError(-1, f"Ungültige API-Antwort (HTTP {resp.status_code}): {resp.text[:200]}") + _log.info("write_svg response [%d/%d]: %s", i + 1, last + 1, result) + if result.get("error", -1) != 0: + err_code = result["error"] + raise MachineAPIError( + err_code, + f"Maschine meldet Fehler {err_code} bei Datei '{os.path.basename(path)}' " + f"(params: width={data['width']} height={data['height']} " + f"xpos={data['xpos']} ypos={data['ypos']} " + f"rotation={data['rotation']} scale={data['scale']})" + ) + return result diff --git a/Produktion/skrift-produktion/app/core/order_manager.py b/Produktion/skrift-produktion/app/core/order_manager.py new file mode 100644 index 0000000..fa06280 --- /dev/null +++ b/Produktion/skrift-produktion/app/core/order_manager.py @@ -0,0 +1,390 @@ +import json +import os +import threading +from dataclasses import dataclass, field +from datetime import datetime +from typing import Optional + +from app.constants import OrderStatus + + +@dataclass +class Order: + order_number: str + session_id: str + finalized_at: str + letter_count: int + envelope_count: int + letters: list + envelopes: list + letter_format: str + envelope_format: str + folder_name: str + local_dir: str + envelope_beschriftung: str = "" + status: OrderStatus = OrderStatus.NEW + error_message: Optional[str] = None + letter_params: Optional[dict] = None + envelope_params: Optional[dict] = None + split_size: int = 0 # 0 = no split + queue_send_letters: bool = True # in der Warteschlange: Briefe senden? + queue_send_envelopes: bool = True # in der Warteschlange: Kuverts senden? + queue_seq: float = 0.0 # Abarbeitungsreihenfolge der Warteschlange (kleiner = früher) + letter_print_log: list = field(default_factory=list) # [{"from":1,"to":10,"at":"ISO"}] + envelope_print_log: list = field(default_factory=list) + comment: str = "" + has_manifest: bool = True # False = Ordner ohne order-metadata.json (nur Dateien) + + +class OrderManager: + def __init__(self, processed_path: str, temp_base_dir: str, archive_path: str = None): + self._processed_path = processed_path + self._temp_base_dir = temp_base_dir + self._archive_path = archive_path or os.path.join( + os.path.dirname(processed_path), "archive.json" + ) + self._order_data_path = os.path.join( + os.path.dirname(processed_path), "order_data.json" + ) + self._orders: dict[str, Order] = {} + self._processed: set[str] = set() + self._archived_orders: dict[str, dict] = {} + self._order_data: dict[str, dict] = {} # persistent per-order data + self._lock = threading.Lock() + self._load_processed() + self._load_archive() + self._load_order_data() + + def _load_processed(self): + if os.path.exists(self._processed_path): + try: + with open(self._processed_path, "r", encoding="utf-8") as f: + data = json.load(f) + self._processed = set(data.get("processed", [])) + except Exception: + self._processed = set() + + def _save_processed(self): + os.makedirs(os.path.dirname(self._processed_path), exist_ok=True) + with open(self._processed_path, "w", encoding="utf-8") as f: + json.dump( + { + "processed": sorted(self._processed), + "last_updated": datetime.utcnow().isoformat() + "Z", + }, + f, + indent=2, + ) + + def _load_order_data(self): + if os.path.exists(self._order_data_path): + try: + with open(self._order_data_path, "r", encoding="utf-8") as f: + self._order_data = json.load(f) + except Exception: + self._order_data = {} + + def _save_order_data(self): + # Merge current in-memory orders into persistent store + for num, order in self._orders.items(): + self._order_data[num] = { + "comment": order.comment, + "letter_print_log": order.letter_print_log, + "envelope_print_log": order.envelope_print_log, + } + try: + os.makedirs(os.path.dirname(self._order_data_path), exist_ok=True) + with open(self._order_data_path, "w", encoding="utf-8") as f: + json.dump(self._order_data, f, indent=2, ensure_ascii=False) + except Exception: + pass + + def _apply_order_data(self, order: "Order"): + """Merge persisted per-order data (comment, print log) into an Order object.""" + stored = self._order_data.get(order.order_number, {}) + if stored.get("comment"): + order.comment = stored["comment"] + if stored.get("letter_print_log"): + order.letter_print_log = list(stored["letter_print_log"]) + if stored.get("envelope_print_log"): + order.envelope_print_log = list(stored["envelope_print_log"]) + + def _load_archive(self): + if os.path.exists(self._archive_path): + try: + with open(self._archive_path, "r", encoding="utf-8") as f: + data = json.load(f) + self._archived_orders = { + entry["order_number"]: entry + for entry in data.get("archived", []) + if "order_number" in entry + } + except Exception: + self._archived_orders = {} + + def _save_archive(self): + os.makedirs(os.path.dirname(self._archive_path), exist_ok=True) + with open(self._archive_path, "w", encoding="utf-8") as f: + json.dump( + { + "archived": list(self._archived_orders.values()), + "last_updated": datetime.utcnow().isoformat() + "Z", + }, + f, + indent=2, + ensure_ascii=False, + ) + + def archive(self, order_number: str): + """Move an order to the archive. Saves minimal metadata to archive file.""" + with self._lock: + order = self._orders.get(order_number) + if not order: + return + metadata = { + "order_number": order.order_number, + "folder_name": order.folder_name, + "finalized_at": order.finalized_at, + "letter_count": order.letter_count, + "envelope_count": order.envelope_count, + "letter_format": order.letter_format, + "envelope_format": order.envelope_format, + "envelope_beschriftung": order.envelope_beschriftung, + "archived_at": datetime.utcnow().isoformat() + "Z", + } + self._archived_orders[order_number] = metadata + del self._orders[order_number] + self._save_archive() + + def unarchive(self, order_number: str) -> bool: + """Restore an archived order back to active status (NEW).""" + with self._lock: + metadata = self._archived_orders.get(order_number) + if not metadata: + return False + # Remove from archive + processed + del self._archived_orders[order_number] + self._processed.discard(order_number) + # Re-create a minimal Order object so it shows immediately + temp_dir = os.path.join(self._temp_base_dir, order_number) + order = Order( + order_number=metadata["order_number"], + session_id="", + finalized_at=metadata.get("finalized_at", ""), + letter_count=metadata.get("letter_count", 0), + envelope_count=metadata.get("envelope_count", 0), + letters=[], + envelopes=[], + letter_format=metadata.get("letter_format", ""), + envelope_format=metadata.get("envelope_format", ""), + folder_name=metadata.get("folder_name", ""), + local_dir=temp_dir, + envelope_beschriftung=metadata.get("envelope_beschriftung", ""), + status=OrderStatus.NEW, + ) + self._apply_order_data(order) + self._orders[order_number] = order + self._save_archive() + self._save_processed() + return True + + def get_archived(self) -> list[dict]: + """Return archived order metadata, sorted by finalized_at descending.""" + with self._lock: + items = list(self._archived_orders.values()) + items.sort(key=lambda x: x.get("finalized_at", ""), reverse=True) + return items + + def _apply_manifest_fields(self, order: "Order", folder: str, manifest: dict): + """Populate/refresh an order's content fields from its manifest.""" + order.session_id = manifest.get("sessionId") or "" + order.finalized_at = manifest.get("finalizedAt") or "" + order.letter_count = manifest.get("letterCount") or 0 + order.envelope_count = manifest.get("envelopeCount") or 0 + order.letters = manifest.get("letters") or [] + order.envelopes = manifest.get("envelopes") or [] + order.letter_format = manifest.get("letterFormat") or "" + order.envelope_format = manifest.get("envelopeFormat") or "" + order.envelope_beschriftung = manifest.get("envelopeBeschriftung") or "" + order.folder_name = folder + order.has_manifest = True + + def update_from_remote( + self, + folders: list[str], + manifests: dict[str, dict], + no_manifest_folders: set[str] = None, + ) -> list[Order]: + """ + Merge newly fetched folder data. Returns list of newly discovered orders. + + Ordner mit Manifest werden wie gewohnt angelegt. Ordner in + ``no_manifest_folders`` (bestätigt ohne order-metadata.json) erscheinen + mit dem Ordnernamen als Auftragsnummer und ohne SVG-Liste. Taucht später + ein Manifest auf, wird der bestehende Auftrag aufgewertet. + """ + no_manifest_folders = no_manifest_folders or set() + new_orders = [] + with self._lock: + for folder in folders: + manifest = manifests.get(folder) + if manifest: + order_number = manifest.get("orderNumber") or folder + existing = self._orders.get(order_number) + if existing: + # Bereits bekannt – nur einen Manifest-losen Stub aufwerten. + if not existing.has_manifest: + self._apply_manifest_fields(existing, folder, manifest) + continue + if order_number in self._archived_orders: + continue # Already archived + local_dir = os.path.join(self._temp_base_dir, order_number) + already_sent = order_number in self._processed + order = Order( + order_number=order_number, + session_id="", + finalized_at="", + letter_count=0, + envelope_count=0, + letters=[], + envelopes=[], + letter_format="", + envelope_format="", + folder_name=folder, + local_dir=local_dir, + status=OrderStatus.SENT if already_sent else OrderStatus.NEW, + ) + self._apply_manifest_fields(order, folder, manifest) + self._apply_order_data(order) + self._orders[order_number] = order + if not already_sent: + new_orders.append(order) + elif folder in no_manifest_folders: + # Ordner ohne Manifest: Ordnername = Auftragsnummer. + order_number = folder + if order_number in self._orders: + continue + if order_number in self._archived_orders: + continue + local_dir = os.path.join(self._temp_base_dir, order_number) + already_sent = order_number in self._processed + order = Order( + order_number=order_number, + session_id="", + finalized_at="", + letter_count=0, + envelope_count=0, + letters=[], + envelopes=[], + letter_format="", + envelope_format="", + folder_name=folder, + local_dir=local_dir, + status=OrderStatus.SENT if already_sent else OrderStatus.NEW, + has_manifest=False, + ) + self._apply_order_data(order) + self._orders[order_number] = order + if not already_sent: + new_orders.append(order) + return new_orders + + def get_all(self) -> list[Order]: + with self._lock: + return sorted( + self._orders.values(), + key=lambda o: o.finalized_at, + reverse=True, + ) + + def get_by_number(self, order_number: str) -> Optional[Order]: + with self._lock: + return self._orders.get(order_number) + + def update_status(self, order_number: str, status: OrderStatus, error: str = None): + with self._lock: + order = self._orders.get(order_number) + if order: + order.status = status + order.error_message = error + + def mark_processed(self, order_number: str): + with self._lock: + order = self._orders.get(order_number) + if order: + order.status = OrderStatus.SENT + order.error_message = None + self._processed.add(order_number) + self._save_processed() + + def is_processed(self, order_number: str) -> bool: + with self._lock: + return order_number in self._processed + + def get_queued(self) -> list[Order]: + with self._lock: + return sorted( + [o for o in self._orders.values() if o.status == OrderStatus.QUEUED], + key=lambda o: (o.queue_seq, o.finalized_at), + ) + + def add_print_log(self, order_number: str, kind: str, from_idx: int, to_idx: int): + """Record a printed range (0-based, inclusive) for 'letter' or 'envelope'.""" + with self._lock: + order = self._orders.get(order_number) + if not order: + return + entry = {"from": from_idx, "to": to_idx, "at": datetime.utcnow().isoformat() + "Z"} + if kind == "letter": + order.letter_print_log.append(entry) + elif kind == "envelope": + order.envelope_print_log.append(entry) + self._save_order_data() + + def add_print_indices(self, order_number: str, kind: str, indices: list): + """ + Record printed 0-based indices (Briefnummern) for 'letter' or 'envelope'. + Aufeinanderfolgende Nummern werden zu {from,to}-Bereichen komprimiert. + """ + idx = sorted({int(i) for i in indices}) + if not idx: + return + ranges = [] + start = prev = idx[0] + for i in idx[1:]: + if i == prev + 1: + prev = i + else: + ranges.append((start, prev)) + start = prev = i + ranges.append((start, prev)) + now = datetime.utcnow().isoformat() + "Z" + with self._lock: + order = self._orders.get(order_number) + if not order: + return + log = order.letter_print_log if kind == "letter" else order.envelope_print_log + for a, b in ranges: + log.append({"from": a, "to": b, "at": now}) + self._save_order_data() + + def set_comment(self, order_number: str, comment: str): + """Save a comment for an order.""" + with self._lock: + order = self._orders.get(order_number) + if order: + order.comment = comment + if order_number not in self._order_data: + self._order_data[order_number] = {} + self._order_data[order_number]["comment"] = comment + self._save_order_data() + + def set_order_params(self, order_number: str, **kwargs): + """Update per-order UI parameters (template key, overrides, split size).""" + with self._lock: + order = self._orders.get(order_number) + if not order: + return + for k, v in kwargs.items(): + if hasattr(order, k): + setattr(order, k, v) diff --git a/Produktion/skrift-produktion/app/static/index.html b/Produktion/skrift-produktion/app/static/index.html new file mode 100644 index 0000000..b06d3d1 --- /dev/null +++ b/Produktion/skrift-produktion/app/static/index.html @@ -0,0 +1,2870 @@ + + + + + +Skrift FTP-Checker + + + + + +
+ + +
+ +
+ + + +
+ + +
+
+
+
+
Maschine
+
—
+
+
+
+
+
+
FTP-Server
+
—
+
+
+
+ + + + +
+ +
+ +
+
+
+
📭
+
Keine Aufträge
+
Warte auf neue Schriftstücke vom Server…
+
+
+
+ + +
+
+
+
📁
+
Archiv ist leer
+
Archivierte Aufträge erscheinen hier.
+
+
+
+ + +
+ + + +
+
📂
+
SVGs hier ablegen
+
Mehrere Dateien auf einmal möglich
+ +
+
+ +
+ + +
+ +
+
+
+ + +
+ +
+
+ +
+ + + +
+
📄
+
PDF hier ablegen
+
Genau eine Datei – alle Seiten enthalten
+ +
+ + + + +
+
✍️
+
SVG-Unterschriften hier ablegen
+
Bis zu 3 Dateien – je eine Unterschrift
+ +
+ + +
+ + + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + + +
+ + + + + + +
+
+ + +
+
+ Aktivitätslog + +
+
+
+
+
+ + +
+ +
+ + +
+ +
+ + +
+
+
+ — + 0 / 0 + 🔍 Hover zum Zoomen + ⬇ + +
+
+ + SVG-Vorschau +
+ +
+
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + + + diff --git a/Produktion/skrift-produktion/app/workers/__init__.py b/Produktion/skrift-produktion/app/workers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/Produktion/skrift-produktion/app/workers/machine_poll_worker.py b/Produktion/skrift-produktion/app/workers/machine_poll_worker.py new file mode 100644 index 0000000..79e0d95 --- /dev/null +++ b/Produktion/skrift-produktion/app/workers/machine_poll_worker.py @@ -0,0 +1,122 @@ +import threading +import time +from typing import Callable, Optional + +from app.constants import MachineStatus +from app.core.machine_client import MachineAPIClient + + +class MachinePollWorker(threading.Thread): + def __init__( + self, + config, + machine_client: MachineAPIClient, + on_status_update: Callable, + on_log: Callable, + on_idle_trigger: Callable, + ): + super().__init__(daemon=True) + self._config = config + self._machine = machine_client + self._on_status_update = on_status_update # called with dict or None + self._on_log = on_log + self._on_idle_trigger = on_idle_trigger # called when machine becomes Idle + self._stop_event = threading.Event() + self._last_status: Optional[str] = None + + # --- Auto-Recovery bei Fehler während des Drucks --- + # Versucht max. N-mal recover_write ("Feed again"), OHNE den Auftrag + # neu zu senden. Wird nur ausgelöst, wenn die Maschine aus dem Zustand + # "Printing" heraus auf "Error" wechselt (= Fehler während des Drucks). + self._max_retries = config.get("machine.error_max_retries", 3) + self._retry_delay = config.get("machine.error_retry_delay_seconds", 5) + self._error_active = False # läuft gerade eine Recovery-Episode? + self._error_retries = 0 # bereits unternommene recover_write-Versuche + self._last_retry_ts = 0.0 # Zeitstempel des letzten Versuchs (monotonic) + + def stop(self): + self._stop_event.set() + + def run(self): + interval = self._config.get("machine.poll_interval_seconds", 5) + while not self._stop_event.wait(timeout=interval): + interval = self._config.get("machine.poll_interval_seconds", 5) + self._poll() + + def _poll(self): + try: + status_list = self._machine.get_status() + if status_list: + status = status_list[0] + self._on_status_update(status) + current = status.get("status", MachineStatus.UNKNOWN) + if self._last_status != MachineStatus.IDLE and current == MachineStatus.IDLE: + self._on_idle_trigger() + self._handle_error_recovery(self._last_status, current, status) + self._last_status = current + else: + self._on_status_update(None) + self._last_status = None + except Exception as e: + self._on_log(f"Maschinen-Status Fehler: {e}", "error") + self._on_status_update(None) + + def _handle_error_recovery(self, prev: Optional[str], current: str, status: dict): + """Automatischer recover_write-Retry bei Fehler während des Drucks. + + Sendet NICHTS neu – ruft nur recover_write auf, um den bereits in der + Maschine liegenden Task fortzusetzen. Max. self._max_retries Versuche + mit je self._retry_delay Sekunden Abstand. + """ + if current == MachineStatus.ERROR: + # Episode nur starten, wenn der Fehler aus laufendem Druck kam + if not self._error_active and prev == MachineStatus.PRINTING: + self._error_active = True + self._error_retries = 0 + self._last_retry_ts = 0.0 + self._on_log( + "Maschine meldet Fehler während des Drucks – starte automatische " + f"Wiederaufnahme (max. {self._max_retries} Versuche, kein Neu-Senden).", + "warning", + ) + + if self._error_active and self._error_retries < self._max_retries: + now = time.monotonic() + if now - self._last_retry_ts >= self._retry_delay: + self._error_retries += 1 + self._last_retry_ts = now + mid = status.get("mid", "") + self._on_log( + f"Wiederaufnahme-Versuch {self._error_retries}/{self._max_retries} " + f"(recover_write)…", "info", + ) + try: + ok = self._machine.recover_write(mid) + if ok: + self._on_log( + f"recover_write bestätigt (Versuch {self._error_retries}/{self._max_retries}).", + "info", + ) + else: + self._on_log( + f"recover_write nicht bestätigt (Versuch {self._error_retries}/{self._max_retries}).", + "warning", + ) + except Exception as e: + self._on_log(f"recover_write Fehler: {e}", "error") + + elif self._error_active and self._error_retries >= self._max_retries: + # Alle Versuche ausgeschöpft, Maschine immer noch im Fehler + self._on_log( + f"Automatische Wiederaufnahme nach {self._max_retries} Versuchen " + "fehlgeschlagen – bitte Maschine manuell prüfen.", "error", + ) + self._error_active = False # nicht weiter versuchen (re-armt erst bei erneutem Druck) + else: + # Maschine hat den Fehlerzustand verlassen + if self._error_active and current in (MachineStatus.PRINTING, MachineStatus.IDLE): + if self._error_retries > 0: + self._on_log("Maschine hat sich erholt – Druck wird fortgesetzt.", "success") + self._error_active = False + self._error_retries = 0 + self._last_retry_ts = 0.0 diff --git a/Produktion/skrift-produktion/app/workers/poll_worker.py b/Produktion/skrift-produktion/app/workers/poll_worker.py new file mode 100644 index 0000000..c6d0301 --- /dev/null +++ b/Produktion/skrift-produktion/app/workers/poll_worker.py @@ -0,0 +1,183 @@ +import logging +import threading +from typing import Callable + +from app.core.ftp_client import FTPClient, FTPConnectionError, ManifestNotFound +from app.core.order_manager import OrderManager + +log = logging.getLogger(__name__) + + +class FTPPollWorker(threading.Thread): + def __init__( + self, + config, + order_manager: OrderManager, + on_new_orders: Callable, + on_log: Callable, + on_connection_change: Callable, + ): + super().__init__(daemon=True) + self._config = config + self._order_manager = order_manager + self._on_new_orders = on_new_orders # called with list[Order] + self._on_log = on_log # called with (message, level) + self._on_connection_change = on_connection_change # called with bool + self._stop_event = threading.Event() + self._ftp = FTPClient(config) + self._connected = False + # Anti-Spam: pro Ordner die zuletzt gemeldete Hinweis-Signatur merken, + # damit dieselbe Meldung nicht bei jedem Poll erneut im Log landet. + self._folder_notices: dict[str, str] = {} + + def stop(self): + self._stop_event.set() + + def run(self): + self._poll() # Immediate first poll + interval = self._config.get("connection.poll_interval_seconds", 30) + while not self._stop_event.wait(timeout=interval): + interval = self._config.get("connection.poll_interval_seconds", 30) + self._poll() + + def _poll(self): + try: + if not self._ftp.is_connected(): + self._ftp.disconnect() + self._ftp.connect() + if not self._connected: + self._connected = True + self._on_connection_change(True) + self._on_log("FTP-Verbindung hergestellt.", "info") + + folders = self._ftp.list_order_folders() + + # Hinweise für verschwundene Ordner vergessen (damit sie bei + # erneutem Auftauchen wieder einmalig gemeldet werden). + present = set(folders) + for gone in [f for f in self._folder_notices if f not in present]: + self._folder_notices.pop(gone, None) + + manifests = {} + no_manifest_folders: set[str] = set() + for folder in folders: + try: + manifest = self._ftp.read_order_manifest(folder) + # Kuverts liegen im Unterordner 'umschlaege/' — Pfad voraussetzen + manifest["envelopes"] = [ + f"umschlaege/{f}" for f in manifest.get("envelopes", []) + ] + manifests[folder] = manifest + self._folder_notices.pop(folder, None) # Ordner ok → Hinweis löschen + except ManifestNotFound: + # Ordner ohne order-metadata.json: trotzdem als Auftrag + # anzeigen (Ordnername = Auftragsnummer, alle Dateien). + no_manifest_folders.add(folder) + if self._folder_notices.get(folder) != "missing": + self._folder_notices[folder] = "missing" + self._on_log( + f"Ordner {folder}: kein order-metadata.json – " + "wird ohne SVG-Zuordnung angezeigt.", "info" + ) + except (ValueError, FTPConnectionError) as e: + # Echte Fehler (kaputtes JSON, Verbindungsproblem) weiterhin + # als Warnung – aber pro Ordner nur einmal je Meldung. + # Nicht als "manifestlos" anzeigen, da das Manifest existiert. + if self._folder_notices.get(folder) != str(e): + self._folder_notices[folder] = str(e) + self._on_log(f"Manifest übersprungen ({folder}): {e}", "warning") + + new_orders = self._order_manager.update_from_remote( + folders, manifests, no_manifest_folders + ) + if new_orders: + self._on_log(f"{len(new_orders)} neuer Auftrag/Aufträge gefunden.", "info") + self._on_new_orders(new_orders) + + except FTPConnectionError as e: + self._connected = False + self._on_connection_change(False) + self._on_log(f"FTP-Fehler: {e}", "error") + except Exception as e: + self._on_log(f"Unbekannter Poll-Fehler: {e}", "error") + + def reconnect(self): + """Force reconnect on next poll (e.g., after settings change).""" + self._ftp.disconnect() + self._connected = False + + +def process_queue_tick(): + """ + Startet den nächsten Warteschlangen-Auftrag, sobald die Maschine frei ist. + + Läuft ohne manuelle Druckbestätigung: der Auftrag wird nach der Übertragung + automatisch abgeschlossen (siehe _run_send_job), sodass der nächste Auftrag + startet, sobald die Maschine wieder Idle meldet. + + Wiederverwendbar: vom QueueWorker-Loop und vom /api/queue/kick-Endpoint. + """ + # Lazy import to avoid circular dependency + from app.api.routes import _state, _run_send_job + + # Skip wenn schon ein Job läuft + if _state.get("current_order"): + return + # Skip wenn Formatwechsel-Bestätigung aussteht (physischer Materialwechsel) + if _state.get("format_confirm_event"): + return + + om = _state.get("om") + machine = _state.get("machine") + if not om or not machine: + return + + # Prüfen ob Maschine bereit ist + if not machine.is_idle(): + return + + # Nächsten Auftrag aus der Warteschlange holen + queued = om.get_queued() + if not queued: + return + + next_order = queued[0] + + # Gespeicherte Druckparameter verwenden + zero = {"width": 0, "height": 0, "xpos": 0, "ypos": 0, "scale": 1, "rotation": 0} + letter_params = next_order.letter_params or dict(zero) + envelope_params = next_order.envelope_params or dict(zero) + + # Typ-Auswahl der Warteschlange respektieren (Briefe- oder Kuvert-Sammeldruck) + send_letters = getattr(next_order, "queue_send_letters", True) and bool(next_order.letters) + send_envelopes = getattr(next_order, "queue_send_envelopes", True) and bool(next_order.envelopes) + + log.info(f"Queue-Worker: Starte Auftrag {next_order.order_number}") + + t = threading.Thread( + target=_run_send_job, + args=(next_order, letter_params, envelope_params), + kwargs={"send_letters": send_letters, "send_envelopes": send_envelopes}, + daemon=True, + ) + t.start() + + +class QueueWorker(threading.Thread): + """Verarbeitet automatisch warteschlange-Aufträge wenn die Maschine bereit ist.""" + + INTERVAL = 6 # Sekunden zwischen Prüfungen + + def __init__(self): + super().__init__(daemon=True) + self._stop_event = threading.Event() + + def stop(self): + self._stop_event.set() + + def run(self): + while not self._stop_event.wait(timeout=self.INTERVAL): + try: + process_queue_tick() + except Exception as e: + log.warning(f"QueueWorker Fehler: {e}") diff --git a/Produktion/skrift-produktion/config.default.json b/Produktion/skrift-produktion/config.default.json new file mode 100644 index 0000000..61f4f55 --- /dev/null +++ b/Produktion/skrift-produktion/config.default.json @@ -0,0 +1,117 @@ +{ + "connection": { + "protocol": "sftp", + "host": "192.168.1.100", + "port": 22, + "username": "skrift", + "password": "", + "private_key_path": "", + "remote_path": "/orders/", + "poll_interval_seconds": 30 + }, + "machine": { + "base_url": "http://192.168.2.32:90", + "username": "", + "password": "", + "mid": "", + "poll_interval_seconds": 5 + }, + "format_mapping": { + "letter": { + "A4": "a4_v1", + "A6 Hochformat": "a6p_v1", + "A6 Querformat": "a6l_v1" + }, + "envelope": { + "DIN Lang": "dinlang_v1", + "C6": "c6_v1" + } + }, + "templates": { + "a4_v1": { + "name": "A4 – Variante 1", + "width": 8.27, + "height": 11.69, + "xpos": 0.0, + "ypos": -0.3, + "scale": 1.0, + "rotation": 0 + }, + "a6p_v1": { + "name": "A6 Hochformat – Variante 1", + "width": 4.1, + "height": 5.8, + "xpos": -0.3, + "ypos": -0.3, + "scale": 1.0, + "rotation": 0 + }, + "a6p_v2": { + "name": "A6 Hochformat – Variante 2", + "width": 4.1, + "height": 5.8, + "xpos": 4.0, + "ypos": -0.3, + "scale": 1.0, + "rotation": 90 + }, + "a6l_v1": { + "name": "A6 Querformat – Variante 1", + "width": 5.8, + "height": 4.1, + "xpos": -0.3, + "ypos": -0.3, + "scale": 1.0, + "rotation": 0 + }, + "a6l_v2": { + "name": "A6 Querformat – Variante 2", + "width": 5.8, + "height": 4.1, + "xpos": 4.0, + "ypos": -0.3, + "scale": 1.0, + "rotation": 90 + }, + "dinlang_v1": { + "name": "DIN Lang Kuvert – Variante 1", + "width": 4.33, + "height": 8.27, + "xpos": 4.0, + "ypos": -0.2, + "scale": 1.0, + "rotation": 90 + }, + "dinlang_v2": { + "name": "DIN Lang Kuvert – Variante 2", + "width": 3.9, + "height": 8.27, + "xpos": 4.49, + "ypos": -0.2, + "scale": 1.0, + "rotation": 90 + }, + "c6_v1": { + "name": "C6 Kuvert – Variante 1", + "width": 4.49, + "height": 6.38, + "xpos": 4.2, + "ypos": -0.2, + "scale": 1.0, + "rotation": 90 + }, + "c6_v2": { + "name": "C6 Kuvert – Variante 2", + "width": 4.33, + "height": 6.38, + "xpos": 4.0, + "ypos": -0.2, + "scale": 1.0, + "rotation": 90 + } + }, + "app": { + "theme": "dark", + "local_temp_dir": "" + } +} diff --git a/Produktion/skrift-produktion/main.py b/Produktion/skrift-produktion/main.py new file mode 100644 index 0000000..3977af0 --- /dev/null +++ b/Produktion/skrift-produktion/main.py @@ -0,0 +1,125 @@ +import asyncio +import os +import sys +import threading +import webbrowser + +import uvicorn +from fastapi import FastAPI +from fastapi.responses import FileResponse +from fastapi.staticfiles import StaticFiles + +sys.path.insert(0, os.path.dirname(__file__)) + +from app.core.config_manager import ConfigManager +from app.core.machine_client import MachineAPIClient +from app.core.order_manager import OrderManager +from app.workers.poll_worker import FTPPollWorker, QueueWorker +from app.workers.machine_poll_worker import MachinePollWorker +from app.api import routes +from app.api.routes import push_log, push_machine_status, push_new_orders, push_ftp_status + +CONFIG_PATH = os.path.join(os.path.dirname(__file__), "config.json") +PROCESSED_PATH = os.path.join(os.path.dirname(__file__), "processed_orders.json") +ARCHIVE_PATH = os.path.join(os.path.dirname(__file__), "archive.json") +STATIC_DIR = os.path.join(os.path.dirname(__file__), "app", "static") +PORT = 8765 + + +def create_app(): + config = ConfigManager(CONFIG_PATH) + order_manager = OrderManager( + processed_path=PROCESSED_PATH, + temp_base_dir=config.get_local_temp_dir(), + archive_path=ARCHIVE_PATH, + ) + machine_client = MachineAPIClient(config) + + app = FastAPI(title="Skrift FTP-Checker") + + # SSE event queue (created inside the async loop) + event_queue: asyncio.Queue = None + loop_holder = {} + + @app.on_event("startup") + async def startup(): + nonlocal event_queue + event_queue = asyncio.Queue() + loop = asyncio.get_event_loop() + loop_holder["loop"] = loop + + ftp_worker = FTPPollWorker( + config=config, + order_manager=order_manager, + on_new_orders=lambda orders: (push_new_orders(), None), + on_log=push_log, + on_connection_change=push_ftp_status, + ) + machine_worker = MachinePollWorker( + config=config, + machine_client=machine_client, + on_status_update=push_machine_status, + on_log=push_log, + on_idle_trigger=_on_machine_idle, + ) + + routes.init( + config=config, + order_manager=order_manager, + machine_client=machine_client, + ftp_worker=ftp_worker, + machine_worker=machine_worker, + event_queue=event_queue, + ) + routes._state["loop"] = loop + routes._state["auto_queue"] = False + routes._state["cancel_event"] = None + routes._state["current_order"] = None + + queue_worker = QueueWorker() + + ftp_worker.start() + machine_worker.start() + queue_worker.start() + + # Login attempt + threading.Thread(target=_do_login, args=(machine_client,), daemon=True).start() + + # Open browser after short delay + threading.Timer(1.2, lambda: webbrowser.open(f"http://localhost:{PORT}")).start() + + def _on_machine_idle(): + pass # Queue-Verarbeitung übernimmt QueueWorker + + def _do_login(machine): + try: + ok = machine.login() + push_log("Maschinen-Login erfolgreich." if ok else "Maschinen-Login fehlgeschlagen.", "success" if ok else "warning") + except Exception as e: + push_log(f"Maschinen-Login Fehler: {e}", "error") + + # Routes + app.include_router(routes.router) + app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static") + + @app.get("/") + def index(): + # no-store, damit der Browser nach einem Update immer die neue + # index.html (inkl. aktualisiertem JS) lädt und nicht die alte cacht. + return FileResponse( + os.path.join(STATIC_DIR, "index.html"), + headers={"Cache-Control": "no-store, must-revalidate"}, + ) + + return app + + +if __name__ == "__main__": + import logging + logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(name)s] %(levelname)s: %(message)s", + handlers=[logging.StreamHandler()], + ) + app = create_app() + uvicorn.run(app, host="127.0.0.1", port=PORT, log_level="warning") diff --git a/Produktion/skrift-produktion/requirements.txt b/Produktion/skrift-produktion/requirements.txt new file mode 100644 index 0000000..09570ac --- /dev/null +++ b/Produktion/skrift-produktion/requirements.txt @@ -0,0 +1,5 @@ +fastapi>=0.111.0 +uvicorn>=0.29.0 +paramiko>=3.4.0 +requests>=2.31.0 +python-multipart>=0.0.9 diff --git a/Webseite/skrift-configurator/BACKEND_INTEGRATION.md b/Webseite/skrift-configurator/BACKEND_INTEGRATION.md new file mode 100644 index 0000000..f03863e --- /dev/null +++ b/Webseite/skrift-configurator/BACKEND_INTEGRATION.md @@ -0,0 +1,388 @@ +# Backend Integration - WordPress Plugin + +Anleitung zur Integration des Node.js Backends mit dem WordPress Konfigurator-Plugin. + +## Überblick + +Das WordPress Plugin kommuniziert mit dem Node.js Backend über eine REST API. Das Backend generiert die handgeschriebenen SVG-Dateien und speichert die finalen Bestellungen. + +## WordPress Admin-Einstellungen + +Nach der Installation des Plugins in WordPress: + +1. Gehe zu **Einstellungen → Skrift Konfigurator** +2. Scrolle nach unten zum Abschnitt **"Backend-Verbindung"** + +### Erforderliche Einstellungen + +#### 1. API URL / Domain +**Beispiel:** `https://backend.deine-domain.de` + +Die vollständige URL zu deinem Backend-Server (ohne trailing slash). + +- ✅ `https://backend.example.com` +- ✅ `http://localhost:4000` (nur für lokale Tests) +- ❌ `https://backend.example.com/` (kein Slash am Ende!) + +#### 2. API Token / Authentifizierung +**Optional** - Aktuell nicht implementiert, für zukünftige Erweiterungen vorbereitet. + +Lasse dieses Feld erstmal leer. + +#### 3. Order Webhook URL +**Beispiel:** `https://n8n.deine-domain.de/webhook/order` + +URL die aufgerufen wird, nachdem eine Bestellung abgeschickt wurde. + +**Wird aufgerufen:** +- **Business-Kunden:** Sofort nach Klick auf "Jetzt kostenpflichtig bestellen" +- **Privat-Kunden:** Nach erfolgreicher PayPal-Zahlung (später implementiert) + +**Webhook-Payload:** +```json +{ + "orderNumber": "SK-2026-01-03-001", + "customer_type": "business", + "product": "businessbriefe", + "quantity": 100, + "format": "A4", + "shipping_mode": "direct", + "envelope": "yes", + "customer_data": { + "firstName": "Max", + "lastName": "Mustermann", + "company": "Beispiel GmbH", + "email": "max@example.com", + ... + }, + "quote": { + "total": 250.00, + ... + }, + "backend_result": { + "path": "/var/skrift-output/SK-2026-01-03-001", + "files": [...], + "summary": {...} + }, + "timestamp": "2026-01-03T..." +} +``` + +**Verwendung:** +- N8N Workflow triggern +- CRM-System benachrichtigen +- Interne Benachrichtigungen versenden + +#### 4. Redirect URL Geschäftskunden +**Beispiel:** `https://deine-domain.de/danke-business` + +Wohin Business-Kunden nach dem Klick auf "Jetzt kostenpflichtig bestellen" weitergeleitet werden. + +**Query-Parameter:** +- `orderNumber` - Die generierte Bestellnummer + +**Beispiel-Redirect:** +``` +https://deine-domain.de/danke-business?orderNumber=SK-2026-01-03-001 +``` + +#### 5. Redirect URL Privatkunden +**Beispiel:** `https://deine-domain.de/danke-privat` + +Wohin Privat-Kunden nach erfolgreicher PayPal-Zahlung weitergeleitet werden. + +**Query-Parameter:** +- `orderNumber` - Die generierte Bestellnummer + +## Backend-API Endpunkte + +Das Plugin nutzt folgende Backend-Endpunkte: + +### 1. Health Check +``` +GET /health +``` + +**Response:** +```json +{ + "status": "ok", + "timestamp": "2026-01-03T..." +} +``` + +### 2. Preview Batch +``` +POST /api/preview/batch +``` + +**Request:** +```json +{ + "sessionId": "session-1234567890-abc", + "letters": [ + { + "text": "Liebe Oma, ...", + "font": "tilda", + "format": "A4", + "placeholders": {}, + "envelope": null + } + ] +} +``` + +**Response:** +```json +{ + "sessionId": "session-1234567890-abc", + "previews": [ + { + "index": 0, + "url": "/api/preview/session-1234567890-abc/0" + } + ], + "batchInfo": { + "totalLetters": 1, + "batchSize": 30 + } +} +``` + +### 3. Generate Order +``` +POST /api/order/generate +``` + +**Request:** +```json +{ + "orderNumber": "SK-2026-01-03-001", + "letters": [...], + "envelopes": [...], + "metadata": { + "customer": {...}, + "orderDate": "2026-01-03T..." + } +} +``` + +**Response:** +```json +{ + "orderNumber": "SK-2026-01-03-001", + "path": "/var/skrift-output/SK-2026-01-03-001", + "files": [ + "letter_000.svg", + "envelope_000.svg", + "order-metadata.json", + "placeholders.csv" + ], + "summary": { + "totalLetters": 100, + "totalEnvelopes": 100, + "fonts": ["tilda"], + "formats": ["A4"] + } +} +``` + +## Workflow + +### Für Business-Kunden + +``` +1. Kunde füllt Konfigurator aus +2. Kunde klickt "Jetzt kostenpflichtig bestellen" +3. WordPress Plugin → Backend API (generateOrder) +4. Backend generiert SVG-Dateien +5. Backend speichert in /var/skrift-output/SK-... +6. WordPress Plugin → Webhook aufrufen +7. WordPress Plugin → Redirect zu Business-Danke-Seite +``` + +### Für Privat-Kunden (später) + +``` +1. Kunde füllt Konfigurator aus +2. Kunde klickt "Jetzt kostenpflichtig bestellen" +3. WordPress Plugin → PayPal Checkout +4. PayPal → Zahlung erfolgreich +5. PayPal Webhook → WordPress +6. WordPress Plugin → Backend API (generateOrder) +7. Backend generiert SVG-Dateien +8. WordPress Plugin → Webhook aufrufen +9. WordPress Plugin → Redirect zu Privat-Danke-Seite +``` + +## Datenmapping + +### Fonts + +| Frontend | Backend | +|----------|---------| +| tilda | tilda (PremiumUltra79) | +| alva | alva (PremiumUltra23) | +| ellie | ellie (PremiumUltra39) | + +### Formate + +| Frontend | Backend | +|----------|---------| +| a4 | A4 | +| a6p | A6_PORTRAIT | +| a6l | A6_LANDSCAPE | + +### Envelope-Formate + +| Brief-Format | Envelope-Format | +|--------------|-----------------| +| A4 | DIN_LANG | +| A6 | C6 | + +## JavaScript Integration + +### API Client laden + +Das Plugin lädt automatisch: +- `configurator-api.js` - Backend API Client +- `configurator-backend-integration.js` - Integration Logic + +### Globale Instanz + +```javascript +// Verfügbar in allen Konfigurator-Scripts +const api = window.SkriftBackendAPI; + +// Health-Check +const isHealthy = await api.healthCheck(); + +// Preview generieren +const result = await api.generatePreviewBatch(letters); + +// Order generieren +const order = await api.generateOrder(orderNumber, letters, envelopes, metadata); +``` + +## Testing + +### 1. Backend Health-Check + +```javascript +// In Browser-Console auf Konfigurator-Seite: +const api = window.SkriftBackendAPI; +const healthy = await api.healthCheck(); +console.log('Backend healthy:', healthy); +``` + +### 2. Test-Order generieren + +```javascript +const api = window.SkriftBackendAPI; + +const result = await api.generateOrder( + api.generateOrderNumber(), + [ + { + text: 'Test Brief', + font: 'tilda', + format: 'A4', + placeholders: {} + } + ], + [], + { + customer: { + type: 'business', + firstName: 'Test', + lastName: 'User', + email: 'test@example.com' + } + } +); + +console.log('Order result:', result); +``` + +## Troubleshooting + +### Fehler: "Backend ist nicht erreichbar" + +**Lösung:** +1. Prüfe Backend-URL in WordPress-Einstellungen +2. Teste Health-Check: `curl https://backend.deine-domain.de/health` +3. Prüfe Nginx Proxy Manager Konfiguration +4. Prüfe Backend-Container: `docker compose logs -f` + +### Fehler: "CORS Error" + +**Lösung:** +Nginx Proxy Manager muss CORS-Header setzen: + +```nginx +# In Custom Nginx Configuration (Advanced Tab) +add_header Access-Control-Allow-Origin "https://deine-wordpress-domain.de" always; +add_header Access-Control-Allow-Methods "GET, POST, OPTIONS" always; +add_header Access-Control-Allow-Headers "Content-Type, Authorization" always; + +if ($request_method = 'OPTIONS') { + return 204; +} +``` + +### Fehler: "Order generation failed" + +**Lösung:** +1. Prüfe Backend-Logs: `docker compose logs -f` +2. Prüfe `/var/skrift-output` Verzeichnis existiert +3. Prüfe Fonts sind vorhanden in `/app/fonts` +4. Prüfe `.env` hat `SCRIPTALIZER_ERR_FREQUENCY=0` + +### Webhook wird nicht aufgerufen + +**Lösung:** +1. Prüfe Webhook-URL in WordPress-Einstellungen +2. Teste Webhook manuell mit curl +3. Prüfe N8N/Webhook-Service Logs +4. Webhook-Fehler werden ignoriert (soft fail) - Order wird trotzdem erstellt + +## Weitere Entwicklung + +### Preview-System (TODO) + +Aktuell wird direkt die finale Order generiert. Zukünftig: +1. Kunde füllt Schritt 1-4 aus +2. Preview generieren mit `/api/preview/batch` +3. Kunde sieht Vorschau der Briefe +4. Kunde bestätigt → Order finalisieren mit `/api/order/finalize` + +### PayPal-Integration (TODO) + +Für Privatkunden: +1. PayPal SDK laden +2. Order-ID an PayPal übergeben +3. Nach Zahlung Webhook empfangen +4. Backend-Order generieren +5. Weiterleitung + +## Support + +Bei Problemen: +1. Browser-Console öffnen (F12) +2. Logs prüfen (nach `[API]` oder `[Backend Integration]` filtern) +3. Backend-Logs prüfen: `docker compose logs -f` +4. WordPress Debug-Log prüfen: `wp-content/debug.log` + +## Checkliste nach Installation + +- [ ] Backend deployed und erreichbar +- [ ] Health-Check erfolgreich: `https://backend.domain.de/health` +- [ ] WordPress Admin-Einstellungen konfiguriert + - [ ] API URL gesetzt + - [ ] Order Webhook URL gesetzt (optional) + - [ ] Redirect URLs gesetzt +- [ ] Test-Bestellung generiert +- [ ] Dateien in `/var/skrift-output` erstellt +- [ ] Webhook wurde aufgerufen (falls konfiguriert) +- [ ] Keine durchgestrichenen Wörter in SVGs +- [ ] Handschrift-Variationen sichtbar (Wortabstände, Rotation) diff --git a/Webseite/skrift-configurator/README.md b/Webseite/skrift-configurator/README.md new file mode 100644 index 0000000..d3b0c84 --- /dev/null +++ b/Webseite/skrift-configurator/README.md @@ -0,0 +1,318 @@ +# Skrift Konfigurator - WordPress Plugin + +Interaktiver Konfigurator für handgeschriebene Briefe, Postkarten und Einladungen. + +## Features + +- ✅ Multi-Step Konfigurator mit 6 Schritten +- ✅ B2B und B2C Workflows +- ✅ Dynamische Preisberechnung +- ✅ Gutschein-System +- ✅ Backend-Integration für SVG-Generierung +- ✅ Preview-System (in Entwicklung) +- ✅ Responsive Design +- ✅ Vollständig anpassbare Preise und Produkte + +## Installation + +1. Plugin-Ordner nach `wp-content/plugins/skrift-konfigurator/` kopieren +2. In WordPress: **Plugins → Installierte Plugins** +3. "Skrift Konfigurator" aktivieren +4. Zu **Einstellungen → Skrift Konfigurator** gehen +5. Einstellungen konfigurieren + +## Konfiguration + +### 1. Produkte + +Verwalte Namen, Beschreibungen und Basispreise für alle 5 Produkte: +- Businessbriefe +- Business Postkarten +- Follow-ups +- Einladungen +- Private Briefe + +### 2. Preise + +Konfiguriere Aufpreise für: +- Formate (A4 Upgrade) +- Versand (Direkt vs. Bulk) +- Umschläge (mit Adresse vs. Custom Text) +- Zusatzleistungen (Motiv-Upload, Design-Service, etc.) +- Schriftarten (Tilda, Alva, Ellie) + +### 3. Dynamische Preisformeln + +Erstelle Mengenrabatt-Formeln für Business und Privatkunden: + +**Beispiel Business:** +``` +if (q < 50) return 2.50; +if (q < 100) return 2.30; +if (q < 200) return 2.10; +return 1.90; +``` + +**Beispiel Privat:** +``` +if (q < 10) return 3.00; +if (q < 25) return 2.80; +return 2.50; +``` + +### 4. Backend-Verbindung + +**WICHTIG:** Für die SVG-Generierung! + +- **API URL:** `https://backend.deine-domain.de` +- **Order Webhook URL:** `https://n8n.deine-domain.de/webhook/order` (optional) +- **Redirect URLs:** Wohin nach Bestellung weitergeleitet wird + +Siehe [BACKEND_INTEGRATION.md](./BACKEND_INTEGRATION.md) für Details. + +### 5. Gutscheine + +Erstelle Gutschein-Codes für Rabatte oder Testbestellungen. + +**Arten:** +- **Prozentual:** 10%, 20%, 50% +- **Festbetrag:** 5€, 10€, 20€ +- **Gratis:** 100% Rabatt + +**Einstellungen:** +- Einmalverwendung oder Mehrfachnutzung +- Aktiv/Inaktiv Toggle + +## Verwendung + +### Shortcode einfügen + +``` +[skrift_konfigurator] +``` + +Füge diesen Shortcode auf jeder Seite oder jedem Beitrag ein. + +### Mit URL-Parametern + +Direktlink zu einem Produkt: + +``` +https://deine-domain.de/konfigurator/?businessbriefe +https://deine-domain.de/konfigurator/?private-briefe +https://deine-domain.de/konfigurator/?einladungen +``` + +## Datei-Struktur + +``` +skrift-konfigurator/ +├── assets/ +│ ├── css/ +│ │ └── configurator.css # Styling +│ └── js/ +│ ├── configurator-app.js # Main App +│ ├── configurator-state.js # State Management +│ ├── configurator-ui.js # UI Rendering +│ ├── configurator-pricing.js # Price Calculation +│ ├── configurator-api.js # Backend API Client +│ └── configurator-backend-integration.js # Backend Integration +├── includes/ +│ ├── admin-settings.php # Admin Settings Page +│ └── admin-vouchers.php # Voucher Management +├── skrift-konfigurator.php # Main Plugin File +├── BACKEND_INTEGRATION.md # Backend Integration Guide +└── README.md # This file +``` + +## API Integration + +Das Plugin kommuniziert mit dem Node.js Backend über REST API. + +### Endpoints + +| Endpoint | Method | Beschreibung | +|----------|--------|--------------| +| `/health` | GET | Health-Check | +| `/api/preview/batch` | POST | Preview generieren | +| `/api/order/generate` | POST | Order erstellen | +| `/api/order/finalize` | POST | Order aus Preview finalisieren | + +Siehe [BACKEND_INTEGRATION.md](./BACKEND_INTEGRATION.md) für vollständige API-Dokumentation. + +## Workflow + +### Business-Kunde + +``` +1. Produkt auswählen (Businessbriefe, Business Postkarten, Follow-ups) +2. Menge eingeben +3. Format wählen +4. Versand & Umschlag konfigurieren +5. Inhalt eingeben (Text) +6. Kundendaten eingeben +7. Prüfen & Bestellen + → Backend generiert SVG-Dateien + → Webhook wird aufgerufen + → Weiterleitung zu Danke-Seite +``` + +### Privat-Kunde + +``` +1. Produkt auswählen (Private Briefe, Einladungen) +2. Menge eingeben +3. Format wählen +4. Versand & Umschlag konfigurieren +5. Inhalt eingeben (Text + optional Motiv) +6. Kundendaten eingeben +7. Prüfen & Bestellen + → PayPal-Checkout (später) + → Backend generiert SVG-Dateien + → Webhook wird aufgerufen + → Weiterleitung zu Danke-Seite +``` + +## Entwicklung + +### Requirements + +- PHP 7.4+ +- WordPress 5.8+ +- Modern Browser mit ES6+ Support + +### JavaScript + +Das Plugin nutzt ES6 Modules und läuft ohne Build-Step. + +**State Management:** +- Reducer-Pattern (ähnlich Redux) +- Immutable State Updates +- Uni-directional Data Flow + +**UI Rendering:** +- Virtual DOM mit `h()` Helper +- Deklaratives Rendering +- Event-Delegation + +### Debugging + +Browser-Console öffnen (F12): + +```javascript +// State prüfen +console.log(window.currentState); + +// Backend API testen +const api = window.SkriftBackendAPI; +await api.healthCheck(); + +// Test-Order erstellen +await api.generateOrder( + api.generateOrderNumber(), + [{ text: 'Test', font: 'tilda', format: 'A4', placeholders: {} }], + [], + { customer: { type: 'business', firstName: 'Test' } } +); +``` + +## Deployment + +### 1. Plugin auf Server hochladen + +Via FTP, SSH oder WordPress Dashboard: +``` +wp-content/plugins/skrift-konfigurator/ +``` + +### 2. Backend deployen + +Siehe `Docker Backend/DEPLOYMENT.md` + +### 3. WordPress konfigurieren + +- Einstellungen → Skrift Konfigurator +- Backend-URL setzen +- Preise anpassen +- Gutscheine erstellen (optional) + +### 4. Seite erstellen + +- Neue Seite: "Konfigurator" +- Shortcode einfügen: `[skrift_konfigurator]` +- Veröffentlichen + +### 5. Testen + +- Produkt durchklicken +- Test-Bestellung aufgeben +- Prüfen ob Backend-Order erstellt wurde +- Prüfen ob Dateien in `/var/skrift-output/` erstellt wurden + +## Troubleshooting + +### Konfigurator wird nicht angezeigt + +**Lösung:** +- Shortcode korrekt? `[skrift_konfigurator]` +- JavaScript-Fehler in Console? (F12) +- Plugin aktiviert? + +### Backend-Verbindung fehlgeschlagen + +**Lösung:** +- Backend URL korrekt in Einstellungen? +- Backend erreichbar? `curl https://backend.domain.de/health` +- CORS konfiguriert? (Nginx Proxy Manager) + +### Preise werden falsch berechnet + +**Lösung:** +- Preise in Einstellungen prüfen +- Dynamische Formeln prüfen (Syntax) +- Console-Logs prüfen: `window.currentState.quote` + +### Gutschein funktioniert nicht + +**Lösung:** +- Gutschein ist aktiv? +- Gutschein noch nicht verwendet? (bei Einmalverwendung) +- Code korrekt geschrieben? (Case-sensitive!) + +## Changelog + +### Version 0.3.0 +- ✅ Backend-Integration implementiert +- ✅ API Client für Preview & Order +- ✅ Webhook-Support +- ✅ Redirect-URLs konfigurierbar + +### Version 0.2.0 +- ✅ Gutschein-System +- ✅ Dynamische Preisformeln +- ✅ Admin-Einstellungen erweitert + +### Version 0.1.0 +- ✅ Basis-Konfigurator +- ✅ 6 Schritte +- ✅ B2B und B2C Workflows + +## TODO + +- [ ] Preview-System vollständig integrieren +- [ ] PayPal-Integration für Privatkunden +- [ ] Email-Benachrichtigungen +- [ ] PDF-Export der Bestellung +- [ ] Admin-Dashboard für Bestellungen + +## Support + +Bei Fragen oder Problemen: +1. [BACKEND_INTEGRATION.md](./BACKEND_INTEGRATION.md) lesen +2. Browser-Console prüfen (F12) +3. Backend-Logs prüfen: `docker compose logs -f` +4. WordPress Debug-Log: `wp-content/debug.log` + +## Lizenz + +Proprietär - Alle Rechte vorbehalten diff --git a/Webseite/skrift-configurator/assets/css/configurator.css b/Webseite/skrift-configurator/assets/css/configurator.css new file mode 100644 index 0000000..c5950df --- /dev/null +++ b/Webseite/skrift-configurator/assets/css/configurator.css @@ -0,0 +1,1463 @@ +/* Skrift Konfigurator Styles - Überarbeitet + Optimiert für besseren Frageprozess und UX */ + +.sk-configurator { + /* Farben */ + --sk-bg: #f7f7f8; + --sk-card: #ffffff; + --sk-text: #0f172a; + --sk-muted: rgba(15, 23, 42, 0.68); + --sk-border: rgba(15, 23, 42, 0.1); + --sk-border-strong: rgba(15, 23, 42, 0.16); + --sk-accent: #1e3a6a; + --sk-success: #059669; + --sk-warning: #f59e0b; + --sk-error: #dc2626; + + /* Radius & Spacing */ + --sk-radius: 8px; + --sk-gap: 18px; + + /* Typography */ + --sk-font-primary: var( + --e-global-typography-primary-font-family, + system-ui, + -apple-system, + Segoe UI, + Roboto, + Arial, + sans-serif + ); + --sk-font-text: var( + --e-global-typography-text-font-family, + system-ui, + -apple-system, + Segoe UI, + Roboto, + Arial, + sans-serif + ); + + font-family: var(--sk-font-text); + color: var(--sk-text); + background: var(--sk-bg); + + max-width: 1400px; + margin: 0 auto; + padding: var(--sk-gap); + + /* Scroll-Offset für festes Menü */ + scroll-margin-top: 120px; + scroll-padding-top: 120px; +} + +/* Layout Grid */ +.sk-configurator__layout { + display: grid; + grid-template-columns: minmax(0, 65fr) minmax(300px, 35fr); + gap: var(--sk-gap); + align-items: start; +} + +@media (max-width: 980px) { + .sk-configurator__layout { + grid-template-columns: 1fr; + } + + .sk-main { + max-width: 100%; + } +} + +/* Main Content Area */ +.sk-main { + display: flex; + flex-direction: column; + gap: var(--sk-gap); + min-width: 0; +} + +/* Sidebar Preview */ +.sk-side { + display: flex; + flex-direction: column; + gap: var(--sk-gap); + position: sticky; + top: 150px; +} + +/* Top Bar mit Preis */ +.sk-topbar { + background: var(--sk-card); + border: 1px solid var(--sk-border); + border-radius: var(--sk-radius); + padding: 16px; + display: flex; + justify-content: space-between; + align-items: center; + flex-wrap: wrap; + gap: 12px; +} + +.sk-price { + display: flex; + flex-direction: column; + gap: 4px; +} + +.sk-price-label { + font-size: 12px; + color: var(--sk-muted); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.sk-price-value { + font-size: 24px; + font-weight: 750; + color: var(--sk-text); +} + +.sk-price-note { + font-size: 12px; + color: var(--sk-muted); + margin-top: 2px; +} + +/* Product Info */ +.sk-product-info { + display: flex; + align-items: center; + gap: 12px; +} + +.sk-product-icon { + width: 40px; + height: 40px; + border-radius: 8px; + background: var(--sk-accent); + display: flex; + align-items: center; + justify-content: center; + color: white; + font-weight: 600; +} + +.sk-product-label { + font-size: 14px; + font-weight: 600; + color: var(--sk-text); +} + +/* Progress Stepper */ +.sk-stepper { + display: flex; + gap: 4px; + flex-wrap: wrap; + align-items: center; +} + +.sk-chip { + appearance: none; + border: 1px solid var(--sk-border); + background: white; + border-radius: 999px; + padding: 8px 16px; + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; + display: flex; + align-items: center; + gap: 8px; +} + +.sk-chip:hover:not(:disabled) { + border-color: var(--sk-border-strong); + transform: translateY(-1px); +} + +.sk-chip.is-active { + border-color: var(--sk-accent); + background: var(--sk-accent); + color: white; + font-weight: 600; +} + +.sk-chip.is-complete { + border-color: var(--sk-success); + background: rgba(5, 150, 105, 0.1); + color: var(--sk-success); +} + +.sk-chip:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +/* Checkmark Icon für erledigte Steps */ +.sk-chip-check { + display: inline-flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + border-radius: 50%; + background: var(--sk-success); + color: white; + font-size: 11px; + font-weight: 700; + flex-shrink: 0; +} + +/* Pfeil zwischen Steps - dünner Chevron-Pfeil */ +.sk-stepper-arrow { + color: var(--sk-muted); + font-size: 18px; + font-weight: 400; + user-select: none; + flex-shrink: 0; + opacity: 0.7; + margin: 0 2px; + line-height: 1; +} + +/* Cards */ +.sk-card { + background: var(--sk-card); + border: 1px solid var(--sk-border); + border-radius: var(--sk-radius); + overflow: hidden; + margin-bottom: 16px; + max-width: 100%; +} + +.sk-card-head { + padding: 16px; + border-bottom: 1px solid rgba(15, 23, 42, 0.06); +} + +.sk-card-title { + font-size: 16px; + font-weight: 700; + color: var(--sk-text); + margin: 0; +} + +.sk-card-subtitle { + font-size: 14px; + color: var(--sk-muted); + margin-top: 4px; +} + +.sk-card-body { + padding: 16px; + max-width: 100%; + overflow: hidden; +} + +.sk-card-foot { + padding: 16px; + border-top: 1px solid rgba(15, 23, 42, 0.06); + background: rgba(15, 23, 42, 0.02); +} + +/* Selection Cards (Product Selection) */ +.sk-selection-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 12px; +} + +.sk-selection-card { + border: 2px solid var(--sk-border); + border-radius: var(--sk-radius); + padding: 16px; + cursor: pointer; + transition: all 0.2s ease; + background: white; + position: relative; +} + +.sk-selection-card:hover { + border-color: var(--sk-border-strong); + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); +} + +.sk-selection-card.is-selected { + border-color: var(--sk-accent); + background: rgba(30, 58, 106, 0.04); +} + +.sk-selection-card.is-selected::after { + content: "✓"; + position: absolute; + top: 12px; + right: 12px; + width: 24px; + height: 24px; + border-radius: 50%; + background: var(--sk-accent); + color: white; + display: flex; + align-items: center; + justify-content: center; + font-weight: 700; + font-size: 14px; +} + +.sk-selection-card-image { + width: 100%; + height: 160px; + background: rgba(15, 23, 42, 0.04); + border-radius: 6px; + margin-bottom: 12px; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; +} + +.sk-selection-card-image img { + max-width: 100%; + max-height: 100%; + object-fit: cover; +} + +.sk-selection-card-title { + font-size: 16px; + font-weight: 600; + margin-bottom: 6px; +} + +.sk-selection-card-price { + font-size: 14px; + color: var(--sk-accent); + font-weight: 600; +} + +.sk-selection-card-desc { + font-size: 13px; + color: var(--sk-muted); + margin-top: 8px; + line-height: 1.5; +} + +/* Form Fields */ +.sk-field { + margin-bottom: 18px; + max-width: 100%; + overflow: hidden; +} + +.sk-field-label { + display: block; + font-size: 14px; + font-weight: 600; + color: var(--sk-text); + margin-bottom: 8px; +} + +.sk-field-label.is-required::after { + content: "*"; + color: var(--sk-error); + margin-left: 4px; +} + +.sk-help { + font-size: 12px; + color: var(--sk-muted); + margin-top: 6px; + line-height: 1.5; +} + +.sk-input, +.sk-textarea, +.sk-select { + width: 100%; + max-width: 100%; + box-sizing: border-box; + padding: 10px 12px; + border-radius: var(--sk-radius); + border: 1px solid var(--sk-border); + background: white; + font-size: 14px; + font-family: var(--sk-font-text); + transition: all 0.2s ease; +} + +.sk-input:focus, +.sk-textarea:focus, +.sk-select:focus { + outline: none; + border-color: var(--sk-accent); + box-shadow: 0 0 0 3px rgba(30, 58, 106, 0.1); +} + +.sk-input.is-error, +.sk-textarea.is-error, +.sk-select.is-error, +.sk-input-error { + border-color: var(--sk-error); +} + +.sk-error-message { + color: var(--sk-error); + font-size: 14px; + margin-top: 8px; +} + +.sk-textarea { + resize: vertical; + min-height: 120px; + line-height: 1.5; +} + +/* Radio & Checkbox Options */ +.sk-options { + display: flex; + flex-direction: column; + gap: 10px; +} + +.sk-option { + display: flex; + align-items: flex-start; + gap: 12px; + padding: 14px; + border: 1px solid var(--sk-border); + border-radius: var(--sk-radius); + cursor: pointer; + transition: all 0.2s ease; + background: white; + /* Verhindert Scroll-Verhalten bei Klick auf Label */ + scroll-margin: 0; + scroll-padding: 0; +} + +/* Verhindert dass Browser bei Checkbox/Radio Klick scrollt */ +.sk-option input[type="radio"], +.sk-option input[type="checkbox"] { + scroll-margin: 0; +} + +.sk-option:hover { + border-color: var(--sk-border-strong); + background: rgba(15, 23, 42, 0.02); +} + +.sk-option.is-selected { + border-color: var(--sk-accent); + background: rgba(30, 58, 106, 0.04); +} + +.sk-option input[type="radio"], +.sk-option input[type="checkbox"] { + margin-top: 2px; + flex-shrink: 0; +} + +.sk-option-content { + flex: 1; +} + +.sk-option-label { + font-size: 14px; + font-weight: 500; + color: var(--sk-text); + line-height: 1.5; +} + +.sk-option.is-selected .sk-option-label { + font-weight: 600; +} + +.sk-option-desc { + font-size: 13px; + color: var(--sk-muted); + margin-top: 4px; + line-height: 1.4; +} + +.sk-option-price { + font-size: 13px; + color: var(--sk-accent); + font-weight: 600; + margin-top: 4px; +} + +/* Tables */ +.sk-table-wrapper { + overflow: auto; + margin: 12px 0; + max-height: 500px; + max-width: 100%; + border: 1px solid var(--sk-border); + border-radius: var(--sk-radius); + position: relative; + will-change: scroll-position; + -webkit-overflow-scrolling: touch; +} + +.sk-table { + width: 100%; + min-width: 600px; + border-collapse: separate; + border-spacing: 0; + background: white; +} + +.sk-table th, +.sk-table td { + padding: 10px 12px; + text-align: left; + border-bottom: 1px solid rgba(15, 23, 42, 0.06); + white-space: nowrap; +} + +.sk-table th { + background: white; + font-size: 12px; + font-weight: 700; + color: var(--sk-text); + text-transform: uppercase; + letter-spacing: 0.5px; + position: sticky; + top: 0; + z-index: 10; + border-bottom: 2px solid rgba(15, 23, 42, 0.1); +} + +.sk-table td { + font-size: 13px; + vertical-align: middle; +} + +.sk-table tr:last-child td { + border-bottom: none; +} + +.sk-table-row-number { + width: 32px; + min-width: 32px; + max-width: 32px; + text-align: center; + font-weight: 600; + font-size: 13px; + color: var(--sk-muted); + background: white; + position: sticky; + left: 0; + z-index: 5; + padding: 8px 2px; +} + +td.sk-table-row-number { + width: 32px !important; + min-width: 32px !important; +} + +/* Summary-Tabellen (Key-Value Paare) - erste Spalte feste Breite */ +.sk-table td:first-child { + width: 180px; + min-width: 180px; + font-weight: 500; + color: var(--sk-muted); +} + +.sk-table th.sk-table-row-number { + background: white; + z-index: 15; +} + +.sk-table input { + min-width: 120px; + width: 100%; +} + +/* Buttons */ +.sk-btn { + appearance: none; + border: 1px solid var(--sk-border); + background: white; + border-radius: var(--sk-radius); + padding: 10px 16px; + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s ease; + font-family: var(--sk-font-text); +} + +.sk-btn:hover:not(:disabled) { + border-color: var(--sk-border-strong); + transform: translateY(-1px); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); +} + +.sk-btn:disabled { + opacity: 0.4; + cursor: not-allowed; + transform: none; +} + +.sk-btn-primary { + background: var(--sk-accent); + border-color: var(--sk-accent); + color: white; + font-weight: 700; +} + +.sk-btn-primary:hover:not(:disabled) { + background: #1a3258; + border-color: #1a3258; +} + +.sk-btn-secondary { + background: white; + border-color: var(--sk-border-strong); +} + +.sk-btn-large { + padding: 14px 24px; + font-size: 16px; +} + +/* Navigation */ +.sk-nav { + display: flex; + gap: 12px; + margin-top: 20px; + padding-top: 20px; + border-top: 1px solid var(--sk-border); +} + +.sk-nav button { + flex: 1; +} + +/* Preview Card */ +.sk-preview-card { + background: var(--sk-card); + border: 1px solid var(--sk-border); + border-radius: var(--sk-radius); + padding: 16px; +} + +.sk-preview-title { + font-size: 14px; + font-weight: 700; + margin-bottom: 4px; +} + +.sk-preview-sub { + font-size: 12px; + color: var(--sk-muted); +} + +.sk-preview-box { + margin-top: 16px; + border: 2px dashed var(--sk-border); + border-radius: var(--sk-radius); + min-height: 300px; + display: flex; + align-items: center; + justify-content: center; + background: rgba(15, 23, 42, 0.02); +} + +.sk-preview-placeholder { + text-align: center; + color: var(--sk-muted); + font-size: 13px; +} + +/* Alerts & Messages */ +.sk-alert { + padding: 12px 16px; + border-radius: var(--sk-radius); + margin: 12px 0; + font-size: 13px; + line-height: 1.5; +} + +.sk-alert-info { + background: rgba(59, 130, 246, 0.1); + border: 1px solid rgba(59, 130, 246, 0.3); + color: #1e40af; +} + +.sk-alert-warning { + background: rgba(245, 158, 11, 0.1); + border: 1px solid rgba(245, 158, 11, 0.3); + color: #92400e; +} + +.sk-alert-error { + background: rgba(220, 38, 38, 0.1); + border: 1px solid rgba(220, 38, 38, 0.3); + color: #991b1b; +} + +.sk-alert-success { + background: rgba(5, 150, 105, 0.1); + border: 1px solid rgba(5, 150, 105, 0.3); + color: #065f46; +} + +/* Utility Classes */ +.sk-stack { + display: flex; + flex-direction: column; + gap: 16px; +} + +.sk-inline { + display: flex; + gap: 12px; + align-items: center; + flex-wrap: wrap; +} + +.sk-sep { + height: 1px; + background: var(--sk-border); + margin: 16px 0; +} + +.sk-text-muted { + color: var(--sk-muted); +} + +.sk-text-small { + font-size: 12px; +} + +.sk-hidden { + display: none; +} + +/* Read-only Tabellenzellen */ +.sk-table-readonly-header { + background: rgba(156, 163, 175, 0.15) !important; + color: var(--sk-muted) !important; +} + +.sk-table-readonly-cell { + background: transparent; +} + +.sk-table-readonly-cell input { + background: rgba(156, 163, 175, 0.12); + color: var(--sk-muted); + cursor: not-allowed; +} + +/* Modal */ +.sk-modal { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.6); + display: flex; + align-items: center; + justify-content: center; + z-index: 10000; + padding: 20px; + overflow-y: auto; + font-family: var( + --sk-font-text, + system-ui, + -apple-system, + Segoe UI, + Roboto, + Arial, + sans-serif + ); +} + +.sk-modal-content { + background: white; + border-radius: 8px; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3); + max-width: 95vw; + max-height: 90vh; + display: flex; + flex-direction: column; + width: 100%; + margin: auto; +} + +.sk-modal-header { + padding: 20px 24px; + border-bottom: 1px solid rgba(15, 23, 42, 0.1); + display: flex; + justify-content: space-between; + align-items: center; + flex-shrink: 0; +} + +.sk-modal-header h2 { + margin: 0; + font-size: 20px; + font-weight: 700; + color: #0f172a; + font-family: var( + --sk-font-primary, + system-ui, + -apple-system, + Segoe UI, + Roboto, + Arial, + sans-serif + ); +} + +.sk-modal-close { + appearance: none; + background: none; + border: none; + font-size: 32px; + line-height: 1; + cursor: pointer; + color: rgba(15, 23, 42, 0.68); + padding: 0; + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 4px; + transition: all 0.2s; +} + +.sk-modal-close:hover { + background: rgba(0, 0, 0, 0.05); + color: #0f172a; +} + +.sk-modal-body { + padding: 24px; + overflow-y: auto; + flex: 1; + color: #0f172a; +} + +.sk-table-wrapper-modal { + overflow: auto; + margin: 12px 0; + max-width: 100%; + border: 1px solid rgba(15, 23, 42, 0.1); + border-radius: 8px; + position: relative; + max-height: calc(90vh - 300px); + will-change: scroll-position; + -webkit-overflow-scrolling: touch; +} + +.sk-table-wrapper-modal .sk-table { + min-width: 800px; +} + +/* Mobile Preview Container - standardmäßig versteckt */ +.sk-preview-mobile { + display: none; +} + +/* Mobile Contact Card - standardmäßig versteckt */ +.sk-contact-card-mobile { + display: none; +} + +/* Responsive - Tablet */ +@media (max-width: 980px) { + .sk-configurator { + padding: 12px; + } + + /* Auf Tablet/Mobile: Sidebar als eigener Block */ + .sk-configurator__layout { + display: flex; + flex-direction: column; + } + + /* Desktop-Sidebar auf Mobile ausblenden */ + .sk-side { + display: none; + } + + /* Mobile Preview anzeigen (nach Fragen, vor Weiter-Button) */ + .sk-preview-mobile { + display: block; + margin-bottom: var(--sk-gap); + } + + /* Mobile Contact Card anzeigen (nach Weiter-Button) */ + .sk-contact-card-mobile { + display: block; + margin-top: var(--sk-gap); + } + + .sk-main { + order: 1; + } + + .sk-stepper { + gap: 8px; + justify-content: center; + } + + .sk-chip { + padding: 6px 12px; + font-size: 12px; + } + + .sk-stepper-arrow { + font-size: 12px; + margin: 0 4px; + } +} + +/* Responsive - Mobile */ +@media (max-width: 640px) { + .sk-configurator { + padding: 8px; + } + + .sk-selection-grid { + grid-template-columns: 1fr; + } + + .sk-nav { + flex-direction: column; + } + + /* Stepper auf Mobile: Horizontal scrollbar mit Padding links */ + .sk-stepper { + display: flex; + flex-direction: row; + flex-wrap: nowrap; + overflow-x: auto; + gap: 4px; + padding: 4px 8px 8px 8px; + -webkit-overflow-scrolling: touch; + scrollbar-width: thin; + margin: 0 -8px; /* Negative margin to extend to edges */ + width: calc(100% + 16px); + } + + .sk-stepper::-webkit-scrollbar { + height: 4px; + } + + .sk-stepper::-webkit-scrollbar-thumb { + background: var(--sk-border-strong); + border-radius: 2px; + } + + .sk-chip { + flex-shrink: 0; + padding: 5px 8px; + font-size: 10px; + white-space: nowrap; + } + + .sk-stepper-arrow { + flex-shrink: 0; + font-size: 9px; + margin: 0 1px; + } + + /* Mobile Layout: Vorschau zwischen Form und Nav */ + .sk-sidebar { + display: none; /* Desktop Sidebar verstecken */ + } + + .sk-preview-mobile { + display: block !important; + order: 2; /* Nach Form (order: 1), vor Nav */ + margin-bottom: 16px; + } + + /* "Noch Fragen" Card nach dem Button */ + .sk-contact-card-mobile { + order: 4; /* Nach Nav (order: 3) */ + margin-top: 16px; + } + + .sk-card-body { + padding: 12px; + } + + .sk-card-head { + padding: 12px; + } + + .sk-topbar { + padding: 12px; + flex-direction: column; + align-items: flex-start; + } + + .sk-price-value { + font-size: 20px; + } + + .sk-option { + padding: 10px; + } + + .sk-modal-content { + max-width: 100%; + max-height: 100%; + border-radius: 0; + } + + .sk-modal { + padding: 0; + } + + .sk-modal-header { + padding: 16px; + } + + .sk-modal-body { + padding: 16px; + } + + .sk-preview-box { + min-height: 200px; + } + + /* Preview Navigation auf Mobile */ + .sk-preview-navigation { + justify-content: center !important; + } + + .sk-preview-navigation button { + padding: 10px 16px !important; + font-size: 18px !important; + } +} + +/* Sehr kleine Screens */ +@media (max-width: 380px) { + .sk-chip { + padding: 5px 8px; + font-size: 10px; + } + + .sk-stepper-arrow { + display: none; + } + + .sk-card-title { + font-size: 14px; + } + + .sk-option-label { + font-size: 13px; + } + + .sk-option-desc { + font-size: 12px; + } +} + +/* Table Mobile Overflow Fix */ +.sk-card-body { + overflow-x: auto; + -webkit-overflow-scrolling: touch; +} + +.sk-table { + min-width: 100%; +} + +@media (max-width: 640px) { + .sk-table { + font-size: 13px; + } + + .sk-table th, + .sk-table td { + padding: 8px 10px; + white-space: nowrap; + } +} + +/* noPrice Mode - Versteckt alle Preisanzeigen */ +.sk-configurator.sk-no-price .sk-price, +.sk-configurator.sk-no-price .sk-topbar .sk-price, +.sk-configurator.sk-no-price .sk-option-price, +.sk-configurator.sk-no-price .sk-selection-card-price, +.sk-configurator.sk-no-price .sk-price-card { + display: none !important; +} + +/* Mobile Product Cards - Kompakte Darstellung (Bild links, Text rechts) */ +@media (max-width: 640px) { + .sk-selection-grid { + gap: 10px; + } + + .sk-selection-card { + display: flex; + flex-direction: row; + gap: 12px; + padding: 12px; + align-items: flex-start; + } + + .sk-selection-card-image { + width: 80px; + min-width: 80px; + height: 80px; + margin-bottom: 0; + border-radius: 6px; + flex-shrink: 0; + } + + .sk-selection-card-image img { + width: 100%; + height: 100%; + object-fit: cover; + } + + .sk-selection-card-content { + flex: 1; + min-width: 0; + } + + .sk-selection-card-title { + font-size: 14px; + margin-bottom: 4px; + } + + .sk-selection-card-price { + font-size: 13px; + } + + .sk-selection-card-desc { + font-size: 12px; + margin-top: 4px; + line-height: 1.4; + /* Beschreibung auf 2 Zeilen begrenzen */ + display: -webkit-box; + -webkit-line-clamp: 2; + line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + } + + /* Checkmark für ausgewählte Karte */ + .sk-selection-card.is-selected::after { + top: 8px; + right: 8px; + width: 20px; + height: 20px; + font-size: 12px; + } +} + +/* ========== Validierungs-Overlay (Ladeanimation) ========== */ +.sk-validation-overlay { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(255, 255, 255, 0.92); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + z-index: 10000; + gap: 20px; +} + +.sk-validation-spinner { + width: 48px; + height: 48px; + border: 4px solid var(--sk-border); + border-top-color: var(--sk-accent); + border-radius: 50%; + animation: sk-spin 1s linear infinite; +} + +@keyframes sk-spin { + to { transform: rotate(360deg); } +} + +.sk-validation-text { + font-size: 16px; + color: var(--sk-text); + text-align: center; +} + +/* ========== Overflow-Warnung ========== */ +.sk-overflow-warning { + background: #fef2f2; + border: 1px solid var(--sk-error); + border-radius: var(--sk-radius); + padding: 16px; + margin-top: 16px; +} + +.sk-overflow-warning-title { + display: flex; + align-items: center; + gap: 8px; + font-weight: 600; + color: var(--sk-error); + margin-bottom: 12px; +} + +.sk-overflow-warning-title::before { + content: "⚠️"; +} + +.sk-overflow-warning-list { + list-style: none; + padding: 0; + margin: 0; + display: flex; + flex-direction: column; + gap: 8px; +} + +.sk-overflow-warning-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px 12px; + background: white; + border-radius: 4px; + font-size: 14px; +} + +.sk-overflow-warning-name { + font-weight: 500; +} + +.sk-overflow-warning-lines { + color: var(--sk-error); + font-weight: 600; +} + +.sk-overflow-warning-hint { + margin-top: 12px; + font-size: 13px; + color: var(--sk-muted); +} + +.sk-overflow-warning-more { + background: transparent; + color: var(--sk-muted); + font-style: italic; +} + +/* ========== Validierungs-Fehler ========== */ +.sk-validation-error { + background: #fef2f2; + border: 1px solid var(--sk-error); + border-radius: var(--sk-radius); + padding: 16px; + margin-bottom: 16px; +} + +.sk-validation-error-title { + display: flex; + align-items: center; + gap: 8px; + font-weight: 600; + color: var(--sk-error); + margin-bottom: 8px; +} + +.sk-validation-error-title::before { + content: "⚠️"; +} + +.sk-validation-error-message { + margin: 0 0 8px 0; + color: var(--sk-text); +} + +.sk-validation-error-hint { + margin: 0; + font-size: 13px; + color: var(--sk-muted); +} + +/* ========== PREISRECHNER SIDEBAR ========== */ + +.sk-calc-sidebar-card { + background: var(--sk-card); + border: 1px solid var(--sk-border); + border-radius: var(--sk-radius); + overflow: hidden; +} + +.sk-calc-main-price { + text-align: center; + padding: 24px 20px; + background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%); + border-bottom: 1px solid var(--sk-border); +} + +.sk-calc-main-price__value { + font-size: 42px; + font-weight: 700; + color: var(--sk-text); + line-height: 1; +} + +.sk-calc-main-price__label { + font-size: 14px; + color: var(--sk-muted); + margin-top: 4px; +} + +.sk-calc-main-price__note { + font-size: 12px; + color: var(--sk-muted); + margin-top: 8px; +} + +.sk-calc-details { + padding: 16px 20px; +} + +.sk-calc-price-table { + width: 100%; + border-collapse: collapse; +} + +.sk-calc-price-table td { + padding: 8px 0; + font-size: 14px; + border: none; +} + +.sk-calc-price-table td:first-child { + color: var(--sk-muted); + font-weight: 400; + width: auto; + min-width: 0; +} + +.sk-calc-price-table td:last-child { + text-align: right; + font-weight: 500; + color: var(--sk-text); +} + +.sk-calc-price-table tr:not(:last-child) td { + border-bottom: 1px solid var(--sk-border); +} + +.sk-calc-note { + font-size: 12px; + color: var(--sk-muted); + padding: 8px 0; + margin-top: 4px; +} + +.sk-calc-note--info { + background: rgba(59, 130, 246, 0.08); + border-radius: 6px; + padding: 10px 12px; + color: #1e40af; +} + +.sk-calc-setup-section { + padding: 16px 20px; + border-top: 1px solid var(--sk-border); + background: rgba(15, 23, 42, 0.02); +} + +.sk-calc-section-label { + font-size: 12px; + font-weight: 600; + color: var(--sk-muted); + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: 12px; +} + +.sk-calc-total-section { + padding: 16px 20px; + border-top: 1px solid var(--sk-border); + background: transparent; +} + +.sk-calc-total-row { + display: flex; + justify-content: space-between; + align-items: center; + font-size: 15px; +} + +.sk-calc-total-row span:first-child { + color: var(--sk-text); + font-weight: 500; +} + +.sk-calc-total-value { + font-weight: 700; + color: var(--sk-accent); + font-size: 18px; +} + +.sk-calc-cta-btn, +.sk-calc-cta-btn:link, +.sk-calc-cta-btn:visited { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + margin: 16px; + padding: 14px 20px; + background: var(--sk-accent); + color: #ffffff !important; + border: none; + border-radius: var(--sk-radius); + font-size: 15px; + font-weight: 600; + text-decoration: none; + cursor: pointer; + transition: all 0.2s ease; +} + +.sk-calc-cta-btn:hover, +.sk-calc-cta-btn:active { + background: #1a3258; + color: #ffffff !important; + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(30, 58, 106, 0.3); +} + +.sk-calc-cta-arrow { + font-size: 18px; +} + +/* Preisrechner max-width */ +.sk-configurator[data-skrift-preisrechner] { + max-width: 1200px; +} + +/* Preisrechner Mobile Sidebar */ +.sk-calc-mobile-sidebar { + display: none; +} + +@media (max-width: 980px) { + .sk-calc-mobile-sidebar { + display: block; + margin-top: var(--sk-gap); + } +} diff --git a/Webseite/skrift-configurator/assets/js/configurator-api.js b/Webseite/skrift-configurator/assets/js/configurator-api.js new file mode 100644 index 0000000..0666391 --- /dev/null +++ b/Webseite/skrift-configurator/assets/js/configurator-api.js @@ -0,0 +1,394 @@ +/** + * Skrift Backend API Client + * Kommunikation mit dem Node.js Backend über WordPress Proxy + * Der API-Token wird serverseitig gehandhabt und ist nicht im Frontend exponiert + */ + +class SkriftBackendAPI { + constructor() { + // WordPress REST API URL für den Proxy + this.restUrl = window.SkriftConfigurator?.restUrl || '/wp-json/'; + // API Key für WordPress REST API Authentifizierung + this.apiKey = window.SkriftConfigurator?.apiKey || ''; + // WordPress Nonce für CSRF-Schutz + this.nonce = window.SkriftConfigurator?.nonce || ''; + // Direkte Backend-URL nur für Preview-Bilder (read-only) + this.backendUrl = window.SkriftConfigurator?.settings?.backend_connection?.api_url || ''; + // Alias für Kompatibilität mit PreviewManager + this.baseURL = this.backendUrl; + this.sessionId = null; + this.previewCache = new Map(); + } + + /** + * Gibt Standard-Headers für WordPress REST API zurück + */ + getHeaders() { + const headers = { + 'Content-Type': 'application/json', + 'X-WP-Nonce': this.nonce, + }; + + if (this.apiKey) { + headers['X-Skrift-API-Key'] = this.apiKey; + } + + return headers; + } + + /** + * Generiert eine eindeutige Session-ID für Preview-Caching + */ + generateSessionId() { + return `session-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`; + } + + /** + * Health-Check: Prüft ob Backend erreichbar ist (über WordPress Proxy) + */ + async healthCheck() { + try { + const response = await fetch(`${this.restUrl}skrift/v1/proxy/health`, { + method: 'GET', + headers: this.getHeaders(), + }); + + if (!response.ok) { + throw new Error(`Health check failed: ${response.statusText}`); + } + + const data = await response.json(); + return data.status === 'ok'; + } catch (error) { + console.error('[API] Health check failed:', error); + return false; + } + } + + /** + * Preview Batch: Generiert eine Vorschau von Briefen (über WordPress Proxy) + * Briefe und Umschläge werden in derselben Session gespeichert + */ + async generatePreviewBatch(letters, options = {}) { + try { + // SessionId nur generieren wenn noch keine existiert oder explizit angefordert + // So bleiben Briefe und Umschläge in derselben Session + if (!this.sessionId || options.newSession) { + this.sessionId = this.generateSessionId(); + console.log('[API] New session created:', this.sessionId); + } else { + console.log('[API] Reusing existing session:', this.sessionId); + } + + const requestBody = { + sessionId: this.sessionId, + letters: letters.map(letter => ({ + index: letter.index, + text: letter.text, + font: letter.font || 'tilda', + format: letter.format || 'A4', + placeholders: letter.placeholders || {}, + type: letter.type || 'letter', + envelopeType: letter.envelopeType || 'recipient', + envelope: letter.envelope || null, + realisticHandwriting: letter.realisticHandwriting ?? false, + })), + }; + + const response = await fetch(`${this.restUrl}skrift/v1/proxy/preview/batch`, { + method: 'POST', + headers: this.getHeaders(), + body: JSON.stringify(requestBody), + }); + + if (!response.ok) { + const error = await response.json(); + + if (response.status === 429 && error.retryAfter) { + const err = new Error(error.error || 'Rate limit exceeded'); + err.retryAfter = error.retryAfter; + err.statusCode = 429; + throw err; + } + + throw new Error(error.error || error.message || `Preview generation failed: ${response.statusText}`); + } + + const data = await response.json(); + + // Session-ID vom Backend übernehmen (falls anders als gesendet) + if (data.sessionId) { + this.sessionId = data.sessionId; + } + + const previews = data.files ? data.files.map((file, index) => ({ + index: file.index !== undefined ? file.index : index, + url: file.url || file.path, + format: file.format, + pages: file.pages || 1, + lineCount: file.lineCount, + lineLimit: file.lineLimit, + overflow: file.overflow, + recipientName: file.recipientName, + })) : []; + + previews.forEach((preview, index) => { + this.previewCache.set(`${this.sessionId}-${index}`, preview); + }); + + return { + success: true, + sessionId: this.sessionId, + previews: previews, + batchInfo: data.batchInfo, + hasOverflow: data.hasOverflow || false, + overflowFiles: data.overflowFiles || [], + }; + } catch (error) { + console.error('[API] Preview batch error:', error); + return { + success: false, + error: error.message, + }; + } + } + + /** + * Get Preview: Ruft eine einzelne Preview-URL ab + * Hinweis: Diese Methode wird aktuell nicht verwendet, da Preview-URLs direkt vom Backend kommen + */ + async getPreviewUrl(sessionId, index) { + try { + const cacheKey = `${sessionId}-${index}`; + + if (this.previewCache.has(cacheKey)) { + return this.previewCache.get(cacheKey).url; + } + + // Über WordPress Proxy abrufen + const response = await fetch(`${this.restUrl}skrift/v1/proxy/preview/${sessionId}/${index}`, { + method: 'GET', + headers: this.getHeaders(), + }); + + if (!response.ok) { + throw new Error(`Preview not found: ${response.statusText}`); + } + + const svgText = await response.text(); + // Sicheres Base64-Encoding für Unicode + const dataUrl = `data:image/svg+xml;base64,${btoa(unescape(encodeURIComponent(svgText)))}`; + + return dataUrl; + } catch (error) { + console.error('[API] Get preview URL error:', error); + throw error; + } + } + + /** + * Finalize Order: Finalisiert eine Bestellung aus dem Preview-Cache (über WordPress Proxy) + */ + async finalizeOrder(sessionId, orderNumber, metadata = {}) { + try { + const requestBody = { + sessionId: sessionId, + orderNumber: orderNumber, + metadata: { + customer: metadata.customer || {}, + orderDate: metadata.orderDate || new Date().toISOString(), + ...metadata, + }, + }; + + const response = await fetch(`${this.restUrl}skrift/v1/proxy/order/finalize`, { + method: 'POST', + headers: this.getHeaders(), + body: JSON.stringify(requestBody), + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || `Order finalization failed: ${response.statusText}`); + } + + const data = await response.json(); + + return { + success: true, + orderNumber: data.orderNumber, + path: data.path, + files: data.files, + envelopesGenerated: data.envelopesGenerated || 0, + }; + } catch (error) { + console.error('[API] Finalize order error:', error); + return { + success: false, + error: error.message, + }; + } + } + + /** + * Generate Order: Generiert eine Bestellung ohne Preview (direkt, über WordPress Proxy) + * Backend erwartet alle Dokumente (Briefe + Umschläge) im letters-Array mit type-Property + */ + async generateOrder(orderNumber, letters, envelopes = [], metadata = {}) { + try { + // Letters vorbereiten + const preparedLetters = letters.map((letter, index) => ({ + index: letter.index !== undefined ? letter.index : index, + text: letter.text, + font: letter.font || 'tilda', + format: letter.format || 'A4', + placeholders: letter.placeholders || {}, + type: 'letter', + realisticHandwriting: letter.realisticHandwriting ?? false, + })); + + // Envelopes vorbereiten und anhängen + const preparedEnvelopes = envelopes.map((envelope, index) => ({ + index: envelope.index !== undefined ? envelope.index : index, + text: envelope.text || '', + font: envelope.font || 'tilda', + format: envelope.format || 'DIN_LANG', + placeholders: envelope.placeholders || {}, + type: 'envelope', + envelopeType: envelope.envelopeType || 'recipient', + })); + + // Alle Dokumente in einem Array für Backend + const allDocuments = [...preparedLetters, ...preparedEnvelopes]; + + const requestBody = { + orderNumber: orderNumber, + letters: allDocuments, + metadata: { + customer: metadata.customer || {}, + orderDate: metadata.orderDate || new Date().toISOString(), + ...metadata, + }, + }; + + const response = await fetch(`${this.restUrl}skrift/v1/proxy/order/generate`, { + method: 'POST', + headers: this.getHeaders(), + body: JSON.stringify(requestBody), + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || `Order generation failed: ${response.statusText}`); + } + + const data = await response.json(); + + return { + success: true, + orderNumber: data.orderNumber, + path: data.path, + files: data.files, + summary: data.summary, + }; + } catch (error) { + console.error('[API] Generate order error:', error); + return { + success: false, + error: error.message, + }; + } + } + + /** + * Generate Order Number: Holt fortlaufende Bestellnummer vom WordPress-Backend + * Schema: S-JAHR-MONAT-TAG-fortlaufendeNummer (z.B. S-2026-01-12-001) + */ + async generateOrderNumber() { + try { + const response = await fetch(`${this.restUrl}skrift/v1/order/generate-number`, { + method: 'POST', + headers: this.getHeaders(), + }); + + if (!response.ok) { + throw new Error(`Failed to generate order number: ${response.statusText}`); + } + + const data = await response.json(); + return data.orderNumber; + } catch (error) { + console.error('[API] Failed to generate order number from WP:', error); + // Fallback: Lokale Generierung (sollte nicht passieren) + const now = new Date(); + const year = now.getFullYear(); + const month = String(now.getMonth() + 1).padStart(2, '0'); + const day = String(now.getDate()).padStart(2, '0'); + const random = String(Math.floor(Math.random() * 1000)).padStart(3, '0'); + return `S-${year}-${month}-${day}-${random}`; + } + } + + /** + * Upload Motif: Lädt ein Motiv-Bild hoch (über WordPress Proxy) + * @param {File} file - Die hochzuladende Datei + * @param {string} orderNumber - Die Bestellnummer für die Dateinamenszuordnung + * @returns {Promise<{success: boolean, filename?: string, url?: string, error?: string}>} + */ + async uploadMotif(file, orderNumber) { + try { + const formData = new FormData(); + formData.append('motif', file); + formData.append('orderNumber', orderNumber || ''); + + const response = await fetch(`${this.restUrl}skrift/v1/proxy/motif/upload`, { + method: 'POST', + headers: { + 'X-WP-Nonce': this.nonce, + ...(this.apiKey ? { 'X-Skrift-API-Key': this.apiKey } : {}), + }, + body: formData, + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.message || `Motif upload failed: ${response.statusText}`); + } + + const data = await response.json(); + return { + success: true, + filename: data.filename, + url: data.url, + path: data.path, + }; + } catch (error) { + console.error('[API] Motif upload error:', error); + return { + success: false, + error: error.message, + }; + } + } + + /** + * Clear Preview Cache: Löscht den Preview-Cache und setzt Session zurück + */ + clearPreviewCache() { + this.previewCache.clear(); + this.sessionId = null; // Wird beim nächsten Preview-Aufruf neu generiert + } + + /** + * Start New Session: Erzwingt eine neue Session für den nächsten Preview-Aufruf + */ + startNewSession() { + this.sessionId = null; + this.previewCache.clear(); + } +} + +// Globale Instanz exportieren +window.SkriftBackendAPI = new SkriftBackendAPI(); + +export default SkriftBackendAPI; diff --git a/Webseite/skrift-configurator/assets/js/configurator-app.js b/Webseite/skrift-configurator/assets/js/configurator-app.js new file mode 100644 index 0000000..205514f --- /dev/null +++ b/Webseite/skrift-configurator/assets/js/configurator-app.js @@ -0,0 +1,220 @@ +/* global SkriftConfigurator */ +import { + createInitialState, + deriveContextFromUrl, + reducer, + STEPS, +} from "./configurator-state.js?ver=0.3.1"; +import { + render, + showValidationOverlay, + hideValidationOverlay, + showOverflowWarning, + showValidationError, + flushAllTables, +} from "./configurator-ui.js?ver=0.3.1"; +import "./configurator-api.js"; // Backend API initialisieren +import PreviewManager from "./configurator-preview-manager.js"; // Preview Management + +(function boot() { + const root = document.querySelector('[data-skrift-konfigurator="1"]'); + if (!root) return; + + // Guard: Verhindere doppelte Initialisierung + if (root.dataset.skriftInitialized === "1") { + console.warn("[Konfigurator] Bereits initialisiert, überspringe."); + return; + } + root.dataset.skriftInitialized = "1"; + + // Cleanup bei Navigation (SPA) oder Seiten-Unload + const cleanupHandlers = []; + + const cleanup = () => { + flushAllTables(); // Tabellen-Daten speichern vor Cleanup/Seiten-Unload + cleanupHandlers.forEach((handler) => handler()); + cleanupHandlers.length = 0; + if (window.envelopePreviewManager) { + window.envelopePreviewManager.destroy(); + } + if (window.contentPreviewManager) { + window.contentPreviewManager.destroy(); + } + }; + + // Cleanup bei Seiten-Unload + window.addEventListener("beforeunload", cleanup); + cleanupHandlers.push(() => + window.removeEventListener("beforeunload", cleanup), + ); + + const ctx = deriveContextFromUrl(window.location.search); + let state = createInitialState(ctx); + + const dom = { + topbar: document.getElementById("sk-topbar"), + stepper: document.getElementById("sk-stepper"), + form: document.getElementById("sk-form"), + prev: document.getElementById("sk-prev"), + next: document.getElementById("sk-next"), + preview: document.getElementById("sk-preview"), + previewMobile: document.getElementById("sk-preview-mobile"), + contactMobile: document.getElementById("sk-contact-mobile"), + }; + + // Preview Manager initialisieren + const api = window.SkriftBackendAPI; + if (api) { + window.envelopePreviewManager = new PreviewManager(api); + window.contentPreviewManager = new PreviewManager(api); + } + + const dispatch = (action) => { + // Scroll-Position VOR dem State-Update speichern + const scrollY = window.scrollY; + const scrollX = window.scrollX; + + state = reducer(state, action); + render({ state, dom, dispatch }); + + // Scroll-Position NACH dem Render wiederherstellen + // Nur bei Actions die NICHT den Step wechseln (Navigation) + const navigationActions = ["NAV_NEXT", "NAV_PREV", "SET_STEP"]; + if (!navigationActions.includes(action.type)) { + // requestAnimationFrame stellt sicher, dass das DOM komplett gerendert ist + requestAnimationFrame(() => { + window.scrollTo(scrollX, scrollY); + }); + } + }; + + // Event-Handler mit Cleanup-Tracking + const prevClickHandler = () => { + flushAllTables(); // Tabellen-Daten speichern vor Navigation + dispatch({ type: "NAV_PREV" }); + }; + + // Next-Handler mit Validierung bei Content-Step + const nextClickHandler = async () => { + flushAllTables(); // Tabellen-Daten speichern vor Navigation + + // WICHTIG: Globalen State in den Store synchronisieren + // Nötig weil Paste direkt in window.currentGlobalState schreibt (Performance) + const globalState = window.currentGlobalState; + if (globalState) { + // Alle Tabellen-Daten in einem einzigen Dispatch synchronisieren + dispatch({ + type: "SYNC_GLOBAL_STATE", + payload: { + recipientRows: globalState.recipientRows, + freeAddressRows: globalState.freeAddressRows, + placeholderValues: globalState.placeholderValues, + }, + }); + } + + // WICHTIG: Aktuellen State verwenden, nicht den gecachten aus dem Closure + const currentState = window.currentGlobalState || state; + + // Bei Content-Step: Textlänge validieren und alle Previews generieren + if (currentState.step === STEPS.CONTENT) { + const previewManager = window.contentPreviewManager; + if (previewManager) { + showValidationOverlay(); + try { + const validation = + await previewManager.validateTextLength(currentState); + + if (!validation.valid) { + hideValidationOverlay(); + // Bei Overflow: Warnung anzeigen + if ( + validation.overflowFiles && + validation.overflowFiles.length > 0 + ) { + showOverflowWarning(validation.overflowFiles, dom.form); + } else if (validation.error) { + // Bei Fehler (z.B. keine Anfragen mehr): Fehlermeldung anzeigen + showValidationError(validation.error, dom.form); + } + return; // Nicht weiter navigieren + } + + // Nach erfolgreicher Validierung: Umschlag-Previews generieren (gleiche Session) + // So sind alle Dokumente im Cache wenn die Bestellung finalisiert wird + const envelopeManager = window.envelopePreviewManager; + if (envelopeManager && currentState.answers?.envelope === true) { + try { + envelopeManager.previewCount = + parseInt(currentState.answers?.quantity) || 1; + await envelopeManager.loadAllPreviews(currentState, true, true); + console.log("[App] Envelope previews generated for cache"); + } catch (envError) { + console.error( + "[App] Envelope preview generation failed:", + envError, + ); + // Nicht blockieren - Umschläge sind nicht kritisch für Navigation + } + } + + hideValidationOverlay(); + } catch (error) { + hideValidationOverlay(); + console.error("[App] Validation error:", error); + showValidationError( + error.message || "Validierung fehlgeschlagen", + dom.form, + ); + return; // Nicht weiter navigieren + } + } + } + dispatch({ type: "NAV_NEXT" }); + }; + + dom.prev.addEventListener("click", prevClickHandler); + dom.next.addEventListener("click", nextClickHandler); + cleanupHandlers.push(() => { + dom.prev.removeEventListener("click", prevClickHandler); + dom.next.removeEventListener("click", nextClickHandler); + }); + + // Keyboard Navigation für Previews (nur innerhalb des aktuellen Batches) + const keydownHandler = (e) => { + if ( + window.envelopePreviewManager && + window.envelopePreviewManager.currentBatchPreviews.length > 0 + ) { + window.envelopePreviewManager.handleKeyboardNavigation( + e, + state, + dom.preview, + true, + ); + } + if ( + window.contentPreviewManager && + window.contentPreviewManager.currentBatchPreviews.length > 0 + ) { + window.contentPreviewManager.handleKeyboardNavigation( + e, + state, + dom.preview, + false, + ); + } + }; + + document.addEventListener("keydown", keydownHandler); + cleanupHandlers.push(() => + document.removeEventListener("keydown", keydownHandler), + ); + + render({ state, dom, dispatch }); + + // Konfigurator sichtbar machen nachdem erster Render abgeschlossen ist + requestAnimationFrame(() => { + root.classList.add("sk-ready"); + }); +})(); diff --git a/Webseite/skrift-configurator/assets/js/configurator-backend-integration.js b/Webseite/skrift-configurator/assets/js/configurator-backend-integration.js new file mode 100644 index 0000000..28dc0c2 --- /dev/null +++ b/Webseite/skrift-configurator/assets/js/configurator-backend-integration.js @@ -0,0 +1,585 @@ +/** + * Backend Integration für Skrift Konfigurator + * Erweitert handleOrderSubmit um Backend-API Calls + */ + +import SkriftBackendAPI from './configurator-api.js'; +import { preparePlaceholdersForIndex } from './configurator-utils.js'; + +/** + * Bereitet Letter-Daten für Backend vor + */ +function prepareLettersForBackend(state) { + const letters = []; + const quantity = parseInt(state.answers?.quantity) || 1; + + // Haupttext + const mainText = state.answers?.letterText || state.answers?.text || state.answers?.briefText || ''; + const font = state.answers?.font || 'tilda'; + const format = state.answers?.format || 'A4'; + + // Für jede Kopie einen Letter-Eintrag erstellen mit individuellen Platzhaltern + for (let i = 0; i < quantity; i++) { + const placeholders = preparePlaceholdersForIndex(state, i); + + letters.push({ + index: i, + text: mainText, + font: mapFontToBackend(font), + format: mapFormatToBackend(format), + placeholders: placeholders, + type: 'letter', + realisticHandwriting: state.answers?.realisticHandwriting ?? false, + }); + } + + return letters; +} + +/** + * Bereitet Envelope-Daten für Backend vor + */ +function prepareEnvelopesForBackend(state) { + const envelopes = []; + // envelope ist ein boolean (true/false), nicht 'yes'/'no' + const hasEnvelope = state.answers?.envelope === true; + + console.log('[Backend Integration] prepareEnvelopesForBackend:', { + envelope: state.answers?.envelope, + hasEnvelope, + envelopeMode: state.answers?.envelopeMode, + }); + + if (!hasEnvelope) { + return envelopes; + } + + const quantity = parseInt(state.answers?.quantity) || 1; + const envelopeMode = state.answers?.envelopeMode || 'recipientData'; + const format = state.answers?.format || 'A4'; + const font = state.answers?.envelopeFont || state.answers?.font || 'tilda'; + + // Envelope Format bestimmen + const envelopeFormat = format === 'a4' ? 'DIN_LANG' : 'C6'; + + if (envelopeMode === 'recipientData') { + // Empfängeradresse-Modus: Ein Envelope pro Brief mit individuellen Empfängerdaten + for (let i = 0; i < quantity; i++) { + const placeholders = preparePlaceholdersForIndex(state, i); + const recipient = state.recipientRows?.[i] || {}; + + // Umschlagtext aus Empfängerdaten zusammenbauen + const lines = []; + const fullName = `${recipient.firstName || ''} ${recipient.lastName || ''}`.trim(); + if (fullName) lines.push(fullName); + + const streetLine = `${recipient.street || ''} ${recipient.houseNumber || ''}`.trim(); + if (streetLine) lines.push(streetLine); + + const location = `${recipient.zip || ''} ${recipient.city || ''}`.trim(); + if (location) lines.push(location); + + if (recipient.country && recipient.country !== 'Deutschland') { + lines.push(recipient.country); + } + + envelopes.push({ + index: i, + text: lines.join('\n'), + font: mapFontToBackend(font), + format: envelopeFormat, + placeholders: placeholders, + type: 'envelope', + envelopeType: 'recipient', + }); + } + } else if (envelopeMode === 'customText') { + // Custom Text Modus mit Platzhaltern + const customText = state.answers?.envelopeCustomText || ''; + + for (let i = 0; i < quantity; i++) { + const placeholders = preparePlaceholdersForIndex(state, i); + + envelopes.push({ + index: i, + text: customText, + font: mapFontToBackend(font), + format: envelopeFormat, + placeholders: placeholders, + type: 'envelope', + envelopeType: 'custom', + }); + } + } + + return envelopes; +} + +// Hinweis: preparePlaceholdersForIndex ist jetzt in configurator-utils.js + +/** + * Mapped Frontend-Font zu Backend-Font + */ +function mapFontToBackend(frontendFont) { + const fontMap = { + 'tilda': 'tilda', + 'alva': 'alva', + 'ellie': 'ellie', + // Füge weitere Mappings hinzu falls nötig + }; + + return fontMap[frontendFont] || 'tilda'; +} + +/** + * Mapped Frontend-Format zu Backend-Format + */ +function mapFormatToBackend(frontendFormat) { + const formatMap = { + 'a4': 'A4', + 'a6p': 'A6_PORTRAIT', + 'a6l': 'A6_LANDSCAPE', + 'A4': 'A4', + 'A6_PORTRAIT': 'A6_PORTRAIT', + 'A6_LANDSCAPE': 'A6_LANDSCAPE', + }; + + return formatMap[frontendFormat] || 'A4'; +} + +/** + * Ermittelt das Umschlag-Format basierend auf Brief-Format + */ +function getEnvelopeFormat(letterFormat) { + const format = String(letterFormat).toLowerCase(); + if (format === 'a4') return 'DIN_LANG'; + if (format === 'a6p' || format === 'a6l') return 'C6'; + return 'DIN_LANG'; +} + +/** + * Formatiert Format für lesbare Ausgabe + */ +function formatFormatLabel(format) { + const labels = { + 'a4': 'A4 Hochformat', + 'a6p': 'A6 Hochformat', + 'a6l': 'A6 Querformat', + }; + return labels[format] || format; +} + +/** + * Formatiert Font für lesbare Ausgabe + */ +function formatFontLabel(font) { + const labels = { + 'tilda': 'Tilda', + 'alva': 'Alva', + 'ellie': 'Ellie', + }; + return labels[font] || font; +} + +/** + * Baut das komplette Webhook-Datenobjekt zusammen + * Enthält ALLE relevanten Felder für Bestellbestätigung und n8n Workflow + */ +function buildWebhookData(state, backendResult) { + const answers = state.answers || {}; + const order = state.order || {}; + const quote = state.quote || {}; + const ctx = state.ctx || {}; + + // Gutschein-Informationen + const voucherCode = order.voucherStatus?.valid ? order.voucherCode : null; + const voucherDiscount = order.voucherStatus?.valid ? (order.voucherStatus.discount || 0) : 0; + + // Umschlag-Format ermitteln + const envelopeFormat = answers.envelope ? getEnvelopeFormat(answers.format) : null; + + // Inland/Ausland zählen + let domesticCount = 0; + let internationalCount = 0; + const addressMode = state.addressMode || 'classic'; + const rows = addressMode === 'free' ? (state.freeAddressRows || []) : (state.recipientRows || []); + + for (const row of rows) { + if (!row) continue; + const country = addressMode === 'free' ? (row.line5 || '') : (row.country || ''); + const countryLower = country.toLowerCase().trim(); + const isDomestic = !countryLower || + countryLower === 'deutschland' || + countryLower === 'germany' || + countryLower === 'de'; + + if (isDomestic) { + domesticCount++; + } else { + internationalCount++; + } + } + + return { + // === BESTELLNUMMER & ZEITSTEMPEL === + orderNumber: backendResult?.orderNumber || null, + timestamp: new Date().toISOString(), + + // === KUNDE === + customerType: answers.customerType || 'private', + customerTypeLabel: answers.customerType === 'business' ? 'Geschäftskunde' : 'Privatkunde', + + // === PRODUKT === + product: ctx.product?.key || null, + productLabel: ctx.product?.label || null, + productCategory: ctx.product?.category || null, + + // === MENGE === + quantity: parseInt(answers.quantity) || 0, + domesticCount: domesticCount, + internationalCount: internationalCount, + + // === FORMAT & SCHRIFT === + format: answers.format || null, + formatLabel: formatFormatLabel(answers.format), + font: answers.font || 'tilda', + fontLabel: formatFontLabel(answers.font || 'tilda'), + + // === VERSAND === + shippingMode: answers.shippingMode || null, + shippingModeLabel: answers.shippingMode === 'direct' ? 'Einzelversand durch Skrift' : 'Sammellieferung', + + // === UMSCHLAG === + envelopeIncluded: answers.envelope === true, + envelopeFormat: envelopeFormat, + envelopeFormatLabel: envelopeFormat === 'DIN_LANG' ? 'DIN Lang' : (envelopeFormat === 'C6' ? 'C6' : null), + envelopeMode: answers.envelopeMode || null, + envelopeModeLabel: answers.envelopeMode === 'recipientData' ? 'Empfängeradresse' : + (answers.envelopeMode === 'customText' ? 'Individueller Text' : null), + envelopeFont: answers.envelopeFont || answers.font || 'tilda', + envelopeFontLabel: formatFontLabel(answers.envelopeFont || answers.font || 'tilda'), + envelopeCustomText: answers.envelopeCustomText || null, + + // === INHALT === + contentCreateMode: answers.contentCreateMode || null, + contentCreateModeLabel: answers.contentCreateMode === 'self' ? 'Selbst erstellt' : + (answers.contentCreateMode === 'textservice' ? 'Textservice' : null), + letterText: answers.letterText || null, + + // === MOTIV === + motifNeeded: answers.motifNeed === true, + motifSource: answers.motifSource || null, + motifSourceLabel: answers.motifSource === 'upload' ? 'Eigenes Motiv hochgeladen' : + (answers.motifSource === 'printed' ? 'Bedruckte Karten verwenden' : + (answers.motifSource === 'design' ? 'Designservice' : null)), + motifFileName: answers.motifFileName || null, + motifFileMeta: answers.motifFileMeta || null, + + // === SERVICES === + serviceText: answers.serviceText === true, + serviceDesign: answers.serviceDesign === true, + serviceApi: answers.serviceApi === true, + + // === FOLLOW-UP DETAILS (nur bei Follow-ups) === + followupYearlyVolume: ctx.product?.isFollowUp ? (answers.followupYearlyVolume || null) : null, + followupCreateMode: ctx.product?.isFollowUp ? (answers.followupCreateMode || null) : null, + followupCreateModeLabel: ctx.product?.isFollowUp ? ( + answers.followupCreateMode === 'auto' ? 'Automatisch (API)' : + (answers.followupCreateMode === 'manual' ? 'Manuell' : null) + ) : null, + followupSourceSystem: ctx.product?.isFollowUp ? (answers.followupSourceSystem || null) : null, + followupTriggerDescription: ctx.product?.isFollowUp ? (answers.followupTriggerDescription || null) : null, + followupCheckCycle: ctx.product?.isFollowUp ? (answers.followupCheckCycle || null) : null, + followupCheckCycleLabel: ctx.product?.isFollowUp ? ( + answers.followupCheckCycle === 'weekly' ? 'Wöchentlich' : + (answers.followupCheckCycle === 'monthly' ? 'Monatlich' : + (answers.followupCheckCycle === 'quarterly' ? 'Quartalsweise' : null)) + ) : null, + + // === GUTSCHEIN === + voucherCode: voucherCode, + voucherDiscount: voucherDiscount, + + // === PREISE === + currency: quote.currency || 'EUR', + subtotalNet: quote.subtotalNet || 0, + vatRate: quote.vatRate || 0.19, + vatAmount: quote.vatAmount || 0, + totalGross: quote.totalGross || 0, + priceLines: quote.lines || [], + + // === KUNDENDATEN (Rechnungsadresse) === + billingFirstName: order.billing?.firstName || '', + billingLastName: order.billing?.lastName || '', + billingCompany: order.billing?.company || '', + billingEmail: order.billing?.email || '', + billingPhone: order.billing?.phone || '', + billingStreet: order.billing?.street || '', + billingHouseNumber: order.billing?.houseNumber || '', + billingZip: order.billing?.zip || '', + billingCity: order.billing?.city || '', + billingCountry: order.billing?.country || 'Deutschland', + + // === LIEFERADRESSE (falls abweichend) === + shippingDifferent: order.shippingDifferent || false, + shippingFirstName: order.shippingDifferent ? (order.shipping?.firstName || '') : null, + shippingLastName: order.shippingDifferent ? (order.shipping?.lastName || '') : null, + shippingCompany: order.shippingDifferent ? (order.shipping?.company || '') : null, + shippingStreet: order.shippingDifferent ? (order.shipping?.street || '') : null, + shippingHouseNumber: order.shippingDifferent ? (order.shipping?.houseNumber || '') : null, + shippingZip: order.shippingDifferent ? (order.shipping?.zip || '') : null, + shippingCity: order.shippingDifferent ? (order.shipping?.city || '') : null, + shippingCountry: order.shippingDifferent ? (order.shipping?.country || 'Deutschland') : null, + + // === EMPFÄNGERLISTE === + addressMode: addressMode, + addressModeLabel: addressMode === 'free' ? 'Freie Adresszeilen' : 'Klassische Adresse', + recipients: addressMode === 'classic' ? (state.recipientRows || []).map((r, i) => ({ + index: i, + firstName: r?.firstName || '', + lastName: r?.lastName || '', + street: r?.street || '', + houseNumber: r?.houseNumber || '', + zip: r?.zip || '', + city: r?.city || '', + country: r?.country || 'Deutschland', + })) : null, + recipientsFree: addressMode === 'free' ? (state.freeAddressRows || []).map((r, i) => ({ + index: i, + line1: r?.line1 || '', + line2: r?.line2 || '', + line3: r?.line3 || '', + line4: r?.line4 || '', + line5: r?.line5 || '', + })) : null, + + // === PLATZHALTER === + placeholdersEnvelope: state.placeholders?.envelope || [], + placeholdersLetter: state.placeholders?.letter || [], + placeholderValues: state.placeholderValues || {}, + + // === BACKEND RESULT (falls vorhanden) === + backendPath: backendResult?.path || null, + backendFiles: backendResult?.files || [], + backendSummary: backendResult?.summary || null, + }; +} + +/** + * Bereitet Metadaten für Backend vor + */ +function prepareOrderMetadata(state) { + return { + customer: { + type: state.answers?.customerType || 'private', + firstName: state.order?.firstName || '', + lastName: state.order?.lastName || '', + company: state.order?.company || '', + email: state.order?.email || '', + phone: state.order?.phone || '', + street: state.order?.street || '', + zip: state.order?.zip || '', + city: state.order?.city || '', + }, + orderDate: new Date().toISOString(), + product: state.ctx?.product?.key || '', + quantity: state.answers?.quantity || 1, + format: state.answers?.format || 'A4', + shippingMode: state.answers?.shippingMode || 'direct', + quote: state.quote || {}, + voucherCode: state.order?.voucherCode || null, + }; +} + +/** + * Erweiterte Order-Submit Funktion mit Backend-Integration + */ +export async function handleOrderSubmitWithBackend(state) { + const isB2B = state.answers?.customerType === "business"; + const backend = window.SkriftConfigurator?.settings?.backend_connection || {}; + const webhookUrl = backend.order_webhook_url; + const redirectUrlBusiness = backend.redirect_url_business; + const redirectUrlPrivate = backend.redirect_url_private; + const api = window.SkriftBackendAPI; + + // Prüfe ob Backend konfiguriert ist + if (!backend.api_url) { + console.warn('[Backend Integration] Backend API URL nicht konfiguriert'); + // Fallback zur alten Logik + return handleOrderSubmitLegacy(state); + } + + try { + // 1. Backend Health Check + const isHealthy = await api.healthCheck(); + if (!isHealthy) { + throw new Error('Backend ist nicht erreichbar'); + } + + // 2. Bestellnummer generieren (fortlaufend vom WP-Backend) + const orderNumber = await api.generateOrderNumber(); + + // 3. Daten vorbereiten + const letters = prepareLettersForBackend(state); + const envelopes = prepareEnvelopesForBackend(state); + const metadata = prepareOrderMetadata(state); + + console.log('[Backend Integration] Generating order:', { + orderNumber, + letters, + envelopes, + metadata, + }); + + // 4. Order im Backend generieren + const result = await api.generateOrder( + orderNumber, + letters, + envelopes, + metadata + ); + + if (!result.success) { + throw new Error(result.error || 'Order generation failed'); + } + + console.log('[Backend Integration] Order generated successfully:', result); + + // 5. Gutschein als verwendet markieren (falls vorhanden) + const voucherCode = state.order?.voucherStatus?.valid + ? state.order.voucherCode + : null; + if (voucherCode) { + try { + const restUrl = window.SkriftConfigurator?.restUrl || "/wp-json/"; + await fetch(restUrl + "skrift/v1/voucher/use", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ code: voucherCode }), + }); + } catch (error) { + console.warn('[Backend Integration] Fehler beim Markieren des Gutscheins:', error); + } + } + + // 6. Webhook aufrufen (wenn konfiguriert) + if (webhookUrl) { + try { + const webhookData = buildWebhookData(state, result); + + const response = await fetch(webhookUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(webhookData), + }); + + if (!response.ok) { + console.warn('[Backend Integration] Webhook call failed:', response.statusText); + } + } catch (error) { + console.warn('[Backend Integration] Webhook error:', error); + } + } + + // 7. Weiterleitung + if (isB2B) { + if (redirectUrlBusiness) { + // Bestellnummer als Query-Parameter anhängen + const redirectUrl = new URL(redirectUrlBusiness); + redirectUrl.searchParams.set('orderNumber', result.orderNumber); + window.location.href = redirectUrl.toString(); + } else { + alert( + `Vielen Dank für Ihre Bestellung!\n\nBestellnummer: ${result.orderNumber}\n\nSie erhalten in Kürze eine Bestätigungs-E-Mail mit allen Details.` + ); + } + } else { + // Privatkunde: Zu PayPal weiterleiten + if (redirectUrlPrivate) { + const redirectUrl = new URL(redirectUrlPrivate); + redirectUrl.searchParams.set('orderNumber', result.orderNumber); + window.location.href = redirectUrl.toString(); + } else { + alert( + `Bestellung erfolgreich erstellt!\n\nBestellnummer: ${result.orderNumber}\n\nWeiterleitung zu PayPal folgt...` + ); + } + } + + } catch (error) { + console.error('[Backend Integration] Order submission failed:', error); + + alert( + `Fehler bei der Bestellverarbeitung:\n\n${error.message}\n\nBitte versuchen Sie es erneut oder kontaktieren Sie uns.` + ); + } +} + +/** + * Legacy Order-Submit (Fallback ohne Backend) + */ +async function handleOrderSubmitLegacy(state) { + const isB2B = state.answers?.customerType === "business"; + const backend = window.SkriftConfigurator?.settings?.backend_connection || {}; + const webhookUrl = backend.order_webhook_url; + const redirectUrlBusiness = backend.redirect_url_business; + const redirectUrlPrivate = backend.redirect_url_private; + + // Gutschein als verwendet markieren + const voucherCode = state.order?.voucherStatus?.valid + ? state.order.voucherCode + : null; + if (voucherCode) { + try { + const restUrl = window.SkriftConfigurator?.restUrl || "/wp-json/"; + await fetch(restUrl + "skrift/v1/voucher/use", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ code: voucherCode }), + }); + } catch (error) { + console.warn('Fehler beim Markieren des Gutscheins:', error); + } + } + + // Webhook aufrufen + if (isB2B && webhookUrl) { + try { + const webhookData = buildWebhookData(state, null); + + await fetch(webhookUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(webhookData), + }); + } catch (error) { + console.warn('Webhook error:', error); + } + } + + // Weiterleitung + if (isB2B && redirectUrlBusiness) { + window.location.href = redirectUrlBusiness; + } else if (!isB2B && redirectUrlPrivate) { + window.location.href = redirectUrlPrivate; + } else { + alert("Vielen Dank für Ihre Bestellung!"); + } +} + +export default { + handleOrderSubmitWithBackend, + prepareLettersForBackend, + prepareEnvelopesForBackend, + mapFontToBackend, + mapFormatToBackend, + buildWebhookData, +}; diff --git a/Webseite/skrift-configurator/assets/js/configurator-preview-manager.js b/Webseite/skrift-configurator/assets/js/configurator-preview-manager.js new file mode 100644 index 0000000..0f070b0 --- /dev/null +++ b/Webseite/skrift-configurator/assets/js/configurator-preview-manager.js @@ -0,0 +1,623 @@ +/** + * Preview Manager - Verwaltet Preview-Generation mit Batch-Loading, Navigation und Rate-Limiting + */ + +import { preparePlaceholdersForIndex } from './configurator-utils.js'; + +class PreviewManager { + constructor(api) { + this.api = api; + this.currentBatchIndex = 0; + this.currentDocIndex = 0; + this.previewCount = 0; + this.batchSize = 25; + this.currentBatchPreviews = []; + this.requestsRemaining = 10; + this.maxRequests = 10; + // Für Änderungserkennung und Validierungs-Caching + this.lastValidatedTextHash = null; + this.lastValidationResult = null; + this.lastOverflowFiles = null; // null = keine Validierung durchgeführt + } + + /** + * Erzeugt einen einfachen Hash für Text-Vergleich + */ + hashText(text, quantity, format, font) { + const str = `${text || ''}|${quantity || 1}|${format || 'a4'}|${font || 'tilda'}`; + let hash = 0; + for (let i = 0; i < str.length; i++) { + const char = str.charCodeAt(i); + hash = ((hash << 5) - hash) + char; + hash = hash & hash; + } + return hash.toString(36); + } + + /** + * Prüft ob sich der Text seit der letzten Validierung geändert hat + */ + hasTextChanged(state) { + const currentHash = this.hashText( + state.answers?.letterText, + state.answers?.quantity, + state.answers?.format, + state.answers?.font + ); + return currentHash !== this.lastValidatedTextHash; + } + + /** + * Speichert den aktuellen Text-Hash nach Validierung + */ + saveTextHash(state) { + this.lastValidatedTextHash = this.hashText( + state.answers?.letterText, + state.answers?.quantity, + state.answers?.format, + state.answers?.font + ); + } + + /** + * Bereitet Platzhalter für ein bestimmtes Dokument vor + * Verwendet jetzt die gemeinsame Utility-Funktion + */ + preparePlaceholders(state, index) { + return preparePlaceholdersForIndex(state, index); + } + + /** + * Generiert ein Letter-Objekt für ein bestimmtes Dokument + */ + prepareLetter(state, index, isEnvelope = false) { + const placeholders = this.preparePlaceholders(state, index); + + if (isEnvelope) { + const isRecipientMode = state.answers?.envelopeMode === 'recipientData'; + const isCustomMode = state.answers?.envelopeMode === 'customText'; + const addressMode = state.addressMode || 'classic'; + + let envelopeText = ''; + let envelopeType = 'recipient'; + + if (isRecipientMode) { + if (addressMode === 'free' && Array.isArray(state.freeAddressRows) && state.freeAddressRows.length > index) { + // Freie Adresse: Bis zu 5 Zeilen + const freeAddr = state.freeAddressRows[index]; + const lines = []; + if (freeAddr.line1) lines.push(freeAddr.line1); + if (freeAddr.line2) lines.push(freeAddr.line2); + if (freeAddr.line3) lines.push(freeAddr.line3); + if (freeAddr.line4) lines.push(freeAddr.line4); + if (freeAddr.line5) lines.push(freeAddr.line5); + envelopeText = lines.join('\n'); + envelopeType = 'free'; + } else if (addressMode === 'classic' && Array.isArray(state.recipientRows) && state.recipientRows.length > index) { + // Klassische Adresse + const recipient = state.recipientRows[index]; + const lines = []; + + const fullName = `${recipient.firstName || ''} ${recipient.lastName || ''}`.trim(); + if (fullName) lines.push(fullName); + + const streetLine = `${recipient.street || ''} ${recipient.houseNumber || ''}`.trim(); + if (streetLine) lines.push(streetLine); + + const location = `${recipient.zip || ''} ${recipient.city || ''}`.trim(); + if (location) lines.push(location); + + if (recipient.country && recipient.country !== 'Deutschland') { + lines.push(recipient.country); + } + + envelopeText = lines.join('\n'); + envelopeType = 'recipient'; + } + } else if (isCustomMode) { + envelopeText = state.answers?.envelopeCustomText || ''; + envelopeType = 'custom'; + } + + return { + index: index, + text: envelopeText, + font: state.answers?.envelopeFont || 'tilda', + format: state.answers?.format === 'a4' ? 'DIN_LANG' : 'C6', + placeholders: placeholders, + type: 'envelope', + envelopeType: envelopeType, + envelope: { + type: envelopeType + } + }; + } else { + return { + index: index, + text: state.answers?.letterText || state.answers?.text || state.answers?.briefText || '', + font: state.answers?.font || 'tilda', + format: state.answers?.format || 'a4', + placeholders: placeholders, + type: 'letter', + realisticHandwriting: state.answers?.realisticHandwriting ?? false, + }; + } + } + + /** + * Lädt ALLE Previews auf einmal + * @param {boolean} skipLimitCheck - Wenn true, wird das Request-Limit ignoriert (für Validierung) + */ + async loadAllPreviews(state, isEnvelope = false, skipLimitCheck = false) { + // Request-Limit nur prüfen wenn nicht übersprungen (normale Preview-Generierung) + if (!skipLimitCheck) { + if (this.requestsRemaining <= 0) { + throw new Error(`Maximale Anzahl von ${this.maxRequests} Vorschau-Anfragen erreicht.`); + } + this.requestsRemaining--; + } + + const letters = []; + for (let i = 0; i < this.previewCount; i++) { + letters.push(this.prepareLetter(state, i, isEnvelope)); + } + + try { + const result = await this.api.generatePreviewBatch(letters); + + if (!result.success) { + throw new Error(result.error || 'Preview generation failed'); + } + + this.currentBatchPreviews = result.previews; + this.lastValidationResult = result; // Speichere für Overflow-Check + return true; + } catch (error) { + console.error('[PreviewManager] Load error:', error); + throw error; + } + } + + /** + * Validiert Textlänge durch Preview-Generierung + * Wenn Text nicht geändert wurde und Preview bereits existiert, wird gecachtes Ergebnis verwendet + * @returns {Object} { valid: boolean, overflowFiles: Array, fromCache: boolean } + */ + async validateTextLength(state, forceRevalidate = false) { + // Prüfe ob wir gecachtes Ergebnis verwenden können + if (!forceRevalidate && !this.hasTextChanged(state) && this.lastOverflowFiles !== null) { + console.log('[PreviewManager] Using cached validation result'); + return { + valid: this.lastOverflowFiles.length === 0, + overflowFiles: this.lastOverflowFiles, + fromCache: true + }; + } + + this.previewCount = parseInt(state.answers?.quantity) || 1; + + try { + // skipLimitCheck = true: Validierung soll immer möglich sein, auch ohne verbleibende Anfragen + await this.loadAllPreviews(state, false, true); + + const result = this.lastValidationResult; + if (!result) { + this.lastOverflowFiles = []; + this.saveTextHash(state); + return { valid: true, overflowFiles: [], fromCache: false }; + } + + const hasOverflow = result.hasOverflow || false; + const overflowFiles = (result.overflowFiles || []).map(f => ({ + index: f.index, + lineCount: f.lineCount, + lineLimit: f.lineLimit + })); + + // Cache das Ergebnis + this.lastOverflowFiles = overflowFiles; + this.saveTextHash(state); + + return { + valid: !hasOverflow, + overflowFiles: overflowFiles, + fromCache: false + }; + } catch (error) { + console.error('[PreviewManager] Validation error:', error); + + // Bei Fehlern: Nicht durchlassen - Nutzer muss es erneut versuchen + return { + valid: false, + overflowFiles: [], + error: error.message, + fromCache: false + }; + } + } + + /** + * Gibt gecachte Overflow-Infos zurück (ohne neue Anfrage) + */ + getCachedOverflowFiles() { + return this.lastOverflowFiles || []; + } + + /** + * Prüft ob Validierung bereits erfolgt ist (für UI-Anzeige) + */ + hasValidationResult() { + return this.lastOverflowFiles !== null; + } + + /** + * Generiert Previews und zeigt Overflow-Warnung wenn nötig + * @returns {Object} { success: boolean, hasOverflow: boolean, overflowFiles: Array } + */ + async generatePreviews(state, dom, isEnvelope = false) { + const btn = dom.querySelector('.sk-preview-generate-btn'); + const statusEl = dom.querySelector('.sk-preview-status'); + const requestCounterEl = dom.querySelector('.sk-preview-request-counter'); + + if (!btn) return { success: false, hasOverflow: false, overflowFiles: [] }; + + try { + btn.disabled = true; + btn.textContent = 'Generiere Vorschau...'; + + this.previewCount = parseInt(state.answers?.quantity) || 1; + + if (statusEl) { + statusEl.textContent = `Lade alle ${this.previewCount} Dokumente...`; + } + + await this.loadAllPreviews(state, isEnvelope); + + // Text-Hash speichern für Änderungserkennung + if (!isEnvelope) { + this.saveTextHash(state); + } + + this.currentDocIndex = 0; + this.showPreview(0, dom); + this.showNavigationControls(dom, state, isEnvelope); + + if (statusEl) { + statusEl.textContent = `Dokument 1 von ${this.previewCount}`; + } + + if (requestCounterEl) { + requestCounterEl.textContent = `Verbleibende Vorschau-Anfragen: ${this.requestsRemaining}/${this.maxRequests}`; + } + + btn.disabled = false; + btn.textContent = btn.textContent.includes('Umschlag') ? 'Umschlag Vorschau generieren' : 'Vorschau Schriftstück generieren'; + + // Overflow-Prüfung für Briefe (nicht Umschläge) + if (!isEnvelope && this.lastValidationResult) { + const hasOverflow = this.lastValidationResult.hasOverflow || false; + const overflowFiles = (this.lastValidationResult.overflowFiles || []).map(f => ({ + index: f.index, + lineCount: f.lineCount, + lineLimit: f.lineLimit + })); + + // Cache speichern + this.lastOverflowFiles = overflowFiles; + + return { success: true, hasOverflow, overflowFiles }; + } + + return { success: true, hasOverflow: false, overflowFiles: [] }; + + } catch (error) { + console.error('[PreviewManager] Error:', error); + btn.disabled = false; + btn.textContent = isEnvelope ? 'Umschlag Vorschau generieren' : 'Vorschau generieren'; + + // Fehlermeldung im Preview-Bereich anzeigen + const previewBox = dom.querySelector('.sk-preview-box'); + if (previewBox) { + previewBox.innerHTML = ''; + const notice = document.createElement('div'); + notice.style.cssText = 'padding: 20px; text-align: center; color: #666;'; + notice.innerHTML = ` +

Die Vorschau konnte nicht generiert werden.

+

Bitte versuchen Sie es erneut oder kontaktieren Sie uns bei anhaltenden Problemen.

+ `; + previewBox.appendChild(notice); + } + + return { success: false, hasOverflow: false, overflowFiles: [], error: error.message }; + } + } + + /** + * Zeigt eine Preview an einem bestimmten Index + */ + showPreview(index, dom) { + const preview = this.currentBatchPreviews[index]; + if (!preview) { + console.warn('[PreviewManager] Preview not loaded:', index); + return; + } + + const previewBox = dom.querySelector('.sk-preview-box'); + const statusEl = dom.querySelector('.sk-preview-status'); + + if (!previewBox) { + console.warn('[PreviewManager] Preview box not found'); + return; + } + + const imgContainer = document.createElement('div'); + imgContainer.style.cssText = 'position: relative; overflow: hidden; margin-top: 15px; border: 1px solid #ddd; border-radius: 4px; cursor: pointer;'; + + const img = document.createElement('img'); + img.src = `${this.api.baseURL}${preview.url}?t=${Date.now()}`; + img.style.cssText = 'width: 100%; display: block;'; + + imgContainer.addEventListener('click', () => { + this.showFullscreenPreview(img.src); + }); + + imgContainer.addEventListener('mouseenter', () => { + imgContainer.style.opacity = '0.9'; + }); + + imgContainer.addEventListener('mouseleave', () => { + imgContainer.style.opacity = '1'; + }); + + imgContainer.appendChild(img); + previewBox.innerHTML = ''; + previewBox.appendChild(imgContainer); + + if (statusEl) { + statusEl.textContent = `Dokument ${index + 1} von ${this.previewCount}`; + } + + this.currentDocIndex = index; + } + + /** + * Navigiert zur nächsten/vorherigen Preview + */ + navigateWithinBatch(direction, dom) { + const newIndex = this.currentDocIndex + direction; + + if (newIndex < 0 || newIndex >= this.currentBatchPreviews.length) { + return; + } + + this.showPreview(newIndex, dom); + this.updateNavigationButtons(); + } + + // navigateToBatch wurde entfernt - Batch-Loading wird nicht mehr verwendet + // Alle Previews werden jetzt auf einmal geladen (loadAllPreviews) + + /** + * Ermittelt den aktuellen lokalen Index + */ + getCurrentLocalIndex(dom) { + const statusEl = dom.querySelector('.sk-preview-status'); + if (!statusEl) return 0; + + const match = statusEl.textContent.match(/Dokument (\d+) von/); + if (match) { + return parseInt(match[1]) - 1; + } + return 0; + } + + /** + * Aktualisiert den Request Counter im DOM + */ + updateRequestCounter(dom) { + const requestCounterEl = dom.querySelector('.sk-preview-request-counter'); + if (requestCounterEl) { + requestCounterEl.textContent = `Verbleibende Vorschau-Anfragen: ${this.requestsRemaining}/${this.maxRequests}`; + } + } + + /** + * Aktualisiert Button-States der Navigation + */ + updateNavigationButtons() { + const navWrapper = document.querySelector('.sk-preview-navigation-container'); + if (!navWrapper) return; + + const navContainer = navWrapper.querySelector('.sk-preview-navigation'); + if (!navContainer) return; + + const prevDocBtn = navContainer.querySelector('.sk-preview-prev-doc'); + const nextDocBtn = navContainer.querySelector('.sk-preview-next-doc'); + + if (prevDocBtn) { + const canPrev = this.currentDocIndex > 0; + prevDocBtn.disabled = !canPrev; + prevDocBtn.style.opacity = canPrev ? '1' : '0.5'; + prevDocBtn.style.cursor = canPrev ? 'pointer' : 'not-allowed'; + } + + if (nextDocBtn) { + const canNext = this.currentDocIndex < this.currentBatchPreviews.length - 1; + nextDocBtn.disabled = !canNext; + nextDocBtn.style.opacity = canNext ? '1' : '0.5'; + nextDocBtn.style.cursor = canNext ? 'pointer' : 'not-allowed'; + } + } + + /** + * Zeigt Navigation Controls + */ + showNavigationControls(dom, state, isEnvelope) { + const navWrapper = dom.querySelector('.sk-preview-navigation-container'); + if (!navWrapper) return; + + let navContainer = navWrapper.querySelector('.sk-preview-navigation'); + if (navContainer) { + navContainer.remove(); + } + + navContainer = document.createElement('div'); + navContainer.className = 'sk-preview-navigation'; + navContainer.style.display = 'flex'; + navContainer.style.gap = '10px'; + navContainer.style.alignItems = 'center'; + + const buttonStyle = { + padding: '8px 12px', + fontSize: '20px', + lineHeight: '1', + minWidth: '40px', + backgroundColor: '#fff', + border: '1px solid #ddd', + borderRadius: '4px', + cursor: 'pointer' + }; + + const prevDocBtn = document.createElement('button'); + prevDocBtn.type = 'button'; + prevDocBtn.className = 'sk-btn sk-btn-secondary sk-preview-prev-doc'; + prevDocBtn.textContent = '‹'; + prevDocBtn.title = 'Vorheriges Dokument'; + Object.assign(prevDocBtn.style, buttonStyle); + prevDocBtn.onclick = () => { + this.navigateWithinBatch(-1, dom); + }; + + const nextDocBtn = document.createElement('button'); + nextDocBtn.type = 'button'; + nextDocBtn.className = 'sk-btn sk-btn-secondary sk-preview-next-doc'; + nextDocBtn.textContent = '›'; + nextDocBtn.title = 'Nächstes Dokument'; + Object.assign(nextDocBtn.style, buttonStyle); + nextDocBtn.onclick = () => { + this.navigateWithinBatch(1, dom); + }; + + navContainer.appendChild(prevDocBtn); + navContainer.appendChild(nextDocBtn); + + navWrapper.appendChild(navContainer); + this.updateNavigationButtons(); + } + + /** + * Keyboard Event Handler + */ + handleKeyboardNavigation(event, state, dom, isEnvelope = false) { + if (this.currentBatchPreviews.length === 0) return; + + const currentLocalIndex = this.getCurrentLocalIndex(dom); + + if (event.key === 'ArrowLeft' && currentLocalIndex > 0) { + event.preventDefault(); + this.navigateWithinBatch(-1, dom); + } else if (event.key === 'ArrowRight' && currentLocalIndex < this.currentBatchPreviews.length - 1) { + event.preventDefault(); + this.navigateWithinBatch(1, dom); + } + } + + /** + * Cleanup + */ + destroy() { + this.currentBatchPreviews = []; + this.currentBatchIndex = 0; + this.previewCount = 0; + } + + /** + * Reset für neue Session + */ + reset() { + this.currentBatchPreviews = []; + this.currentBatchIndex = 0; + this.previewCount = 0; + this.requestsRemaining = this.maxRequests; + } + + /** + * Zeigt Fullscreen-Vorschau als Modal + */ + showFullscreenPreview(imgSrc) { + const existingModal = document.getElementById('sk-preview-fullscreen-modal'); + if (existingModal) { + existingModal.remove(); + } + + const modal = document.createElement('div'); + modal.id = 'sk-preview-fullscreen-modal'; + modal.style.cssText = ` + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + background: rgba(0, 0, 0, 0.9); + display: flex; + align-items: center; + justify-content: center; + z-index: 10000; + cursor: pointer; + `; + + const modalImg = document.createElement('img'); + modalImg.src = imgSrc; + modalImg.style.cssText = ` + max-width: 90vw; + max-height: 90vh; + object-fit: contain; + border-radius: 8px; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5); + `; + + const closeBtn = document.createElement('button'); + closeBtn.innerHTML = '×'; + closeBtn.style.cssText = ` + position: absolute; + top: 20px; + right: 30px; + font-size: 40px; + color: white; + background: none; + border: none; + cursor: pointer; + line-height: 1; + `; + + const hint = document.createElement('div'); + hint.textContent = 'Klicken zum Schließen'; + hint.style.cssText = ` + position: absolute; + bottom: 20px; + left: 50%; + transform: translateX(-50%); + color: rgba(255, 255, 255, 0.7); + font-size: 14px; + `; + + modal.appendChild(modalImg); + modal.appendChild(closeBtn); + modal.appendChild(hint); + + const closeModal = () => modal.remove(); + modal.addEventListener('click', closeModal); + closeBtn.addEventListener('click', closeModal); + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape') closeModal(); + }, { once: true }); + + document.body.appendChild(modal); + } +} + +// Globale Instanzen +window.envelopePreviewManager = null; +window.contentPreviewManager = null; + +export default PreviewManager; diff --git a/Webseite/skrift-configurator/assets/js/configurator-pricing.js b/Webseite/skrift-configurator/assets/js/configurator-pricing.js new file mode 100644 index 0000000..80303bf --- /dev/null +++ b/Webseite/skrift-configurator/assets/js/configurator-pricing.js @@ -0,0 +1,1035 @@ +/** + * Pricing Calculator für Skrift Konfigurator + * Berechnet Preise basierend auf Kundentyp (B2B/B2C) und Produktkonfiguration + */ + +/** + * Sichere mathematische Formel-Auswertung ohne eval() + * Unterstützt: +, -, *, /, Klammern, Zahlen, Dezimalzahlen, + * Vergleichsoperatoren (>=, <=, >, <, ==, !=), ternärer Operator (?:), + * und Math-Funktionen (sqrt, abs, min, max, pow, floor, ceil, round) + */ +function safeEvaluateMathFormula(formula) { + // Erlaubte Math-Funktionen (Whitelist) + const mathFunctions = { + 'sqrt': Math.sqrt, + 'abs': Math.abs, + 'min': Math.min, + 'max': Math.max, + 'pow': Math.pow, + 'floor': Math.floor, + 'ceil': Math.ceil, + 'round': Math.round, + }; + + // Tokenizer: Zerlegt Formel in Tokens + function tokenize(str) { + const tokens = []; + let i = 0; + str = str.replace(/\s+/g, ''); // Whitespace entfernen + + while (i < str.length) { + const char = str[i]; + + // Math.xxx Funktionen + if (str.substring(i, i + 5) === 'Math.') { + i += 5; + let funcName = ''; + while (i < str.length && /[a-zA-Z]/.test(str[i])) { + funcName += str[i]; + i++; + } + if (mathFunctions[funcName]) { + tokens.push({ type: 'function', name: funcName }); + } else { + throw new Error(`Unbekannte Math-Funktion: Math.${funcName}`); + } + } + // Zahl (inkl. Dezimalzahlen) + else if (/[0-9.]/.test(char) || (char === '-' && (tokens.length === 0 || ['(', '+', '-', '*', '/', '>=', '<=', '>', '<', '==', '!=', '?', ':'].includes(tokens[tokens.length - 1]?.type || tokens[tokens.length - 1])))) { + let numStr = ''; + if (char === '-') { + numStr = '-'; + i++; + } + while (i < str.length && /[0-9.]/.test(str[i])) { + numStr += str[i]; + i++; + } + const num = parseFloat(numStr); + if (isNaN(num)) { + throw new Error(`Ungültige Zahl: ${numStr}`); + } + tokens.push({ type: 'number', value: num }); + } + // Zwei-Zeichen-Operatoren + else if (str.substring(i, i + 2) === '>=') { + tokens.push({ type: '>=' }); + i += 2; + } + else if (str.substring(i, i + 2) === '<=') { + tokens.push({ type: '<=' }); + i += 2; + } + else if (str.substring(i, i + 2) === '==') { + tokens.push({ type: '==' }); + i += 2; + } + else if (str.substring(i, i + 2) === '!=') { + tokens.push({ type: '!=' }); + i += 2; + } + // Ein-Zeichen-Operatoren und Klammern + else if (['+', '-', '*', '/', '(', ')', '>', '<', '?', ':', ','].includes(char)) { + tokens.push({ type: char }); + i++; + } + // Ungültiges Zeichen + else { + throw new Error(`Ungültiges Zeichen in Formel: ${char}`); + } + } + + return tokens; + } + + // Rekursiver Parser mit Operator-Präzedenz + function parse(tokens) { + let pos = 0; + + function peek() { + return tokens[pos]; + } + + function consume(expectedType) { + const token = tokens[pos]; + if (expectedType && token?.type !== expectedType) { + throw new Error(`Erwartet ${expectedType}, gefunden ${token?.type}`); + } + pos++; + return token; + } + + // Ternärer Operator (niedrigste Präzedenz) + function parseTernary() { + let condition = parseComparison(); + + if (peek()?.type === '?') { + consume('?'); + const trueValue = parseTernary(); + consume(':'); + const falseValue = parseTernary(); + return condition ? trueValue : falseValue; + } + + return condition; + } + + // Vergleichsoperatoren + function parseComparison() { + let left = parseAddSub(); + + while (peek()?.type && ['>=', '<=', '>', '<', '==', '!='].includes(peek().type)) { + const op = consume().type; + const right = parseAddSub(); + switch (op) { + case '>=': left = left >= right ? 1 : 0; break; + case '<=': left = left <= right ? 1 : 0; break; + case '>': left = left > right ? 1 : 0; break; + case '<': left = left < right ? 1 : 0; break; + case '==': left = left === right ? 1 : 0; break; + case '!=': left = left !== right ? 1 : 0; break; + } + } + + return left; + } + + // Addition und Subtraktion + function parseAddSub() { + let left = parseMulDiv(); + + while (peek()?.type && ['+', '-'].includes(peek().type)) { + const op = consume().type; + const right = parseMulDiv(); + left = op === '+' ? left + right : left - right; + } + + return left; + } + + // Multiplikation und Division + function parseMulDiv() { + let left = parseUnary(); + + while (peek()?.type && ['*', '/'].includes(peek().type)) { + const op = consume().type; + const right = parseUnary(); + if (op === '/') { + if (right === 0) throw new Error('Division durch Null'); + left = left / right; + } else { + left = left * right; + } + } + + return left; + } + + // Unäre Operatoren (negatives Vorzeichen) + function parseUnary() { + if (peek()?.type === '-') { + consume('-'); + return -parseUnary(); + } + return parsePrimary(); + } + + // Primäre Ausdrücke (Zahlen, Klammern, Funktionen) + function parsePrimary() { + const token = peek(); + + if (!token) { + throw new Error('Unerwartetes Ende der Formel'); + } + + // Zahl + if (token.type === 'number') { + consume(); + return token.value; + } + + // Funktion + if (token.type === 'function') { + consume(); + consume('('); + + // Argumente sammeln + const args = []; + if (peek()?.type !== ')') { + args.push(parseTernary()); + while (peek()?.type === ',') { + consume(','); + args.push(parseTernary()); + } + } + + consume(')'); + + const func = mathFunctions[token.name]; + return func(...args); + } + + // Geklammerter Ausdruck + if (token.type === '(') { + consume('('); + const value = parseTernary(); + consume(')'); + return value; + } + + throw new Error(`Unerwartetes Token: ${token.type}`); + } + + const result = parseTernary(); + + if (pos < tokens.length) { + throw new Error(`Unerwartete Tokens am Ende: ${tokens.slice(pos).map(t => t.type).join(', ')}`); + } + + return result; + } + + const tokens = tokenize(formula); + return parse(tokens); +} + +/** + * Holt Preise aus Backend-Settings + */ +function getPrices() { + return window.SkriftConfigurator?.settings?.prices || {}; +} + +/** + * Holt dynamische Pricing-Einstellungen aus Backend + */ +function getDynamicPricing() { + return window.SkriftConfigurator?.settings?.dynamic_pricing || {}; +} + +/** + * Prüft ob es sich um Follow-ups handelt + */ +function isFollowups(state) { + return state.ctx?.product?.key === "follow-ups"; +} + +/** + * Prüft ob Kunde Business-Kunde ist + */ +function isBusinessCustomer(state) { + return state.answers?.customerType === "business"; +} + +/** + * Prüft ob ein Land Deutschland ist (DE oder Deutschland) + * Case-insensitive Prüfung + */ +function isGermany(country) { + if (!country || country.trim() === "") return true; // Leeres Land = Deutschland (Standardannahme) + const normalized = country.trim().toLowerCase(); + return normalized === "de" || normalized === "deutschland" || normalized === "germany" || normalized === "ger" || normalized === "deu"; +} + +/** + * Zählt Inland- und Ausland-Empfänger + * Berücksichtigt sowohl klassische Adressen (recipientRows) als auch freie Adressen (freeAddressRows) + */ +export function countRecipientsByCountry(state) { + const quantity = state.answers?.quantity || 0; + const addressMode = state.addressMode || 'classic'; + + let domesticCount = 0; + let internationalCount = 0; + + if (addressMode === 'classic') { + const rows = Array.isArray(state.recipientRows) ? state.recipientRows : []; + for (let i = 0; i < quantity; i++) { + const country = rows[i]?.country || ""; + if (isGermany(country)) { + domesticCount++; + } else { + internationalCount++; + } + } + } else { + // Freie Adresse: line5 ist das Land + const rows = Array.isArray(state.freeAddressRows) ? state.freeAddressRows : []; + for (let i = 0; i < quantity; i++) { + const country = rows[i]?.line5 || ""; + if (isGermany(country)) { + domesticCount++; + } else { + internationalCount++; + } + } + } + + return { domesticCount, internationalCount }; +} + +/** + * Berechnet die Versandpreise pro Stück für Inland und Ausland + * Formel: Porto + Serviceaufschlag + Kuvert + Aufschlag Beschriftung + */ +export function calculateDirectShippingPrices(state) { + const prices = getPrices(); + + // Basis-Komponenten + const portoDomestic = prices.shipping_domestic || 0.95; + const portoInternational = prices.shipping_international || 1.25; + const serviceCharge = prices.shipping_service || 0.95; + const envelopeBase = prices.envelope_base || 0.50; + const labelingCharge = prices.envelope_labeling || 0.50; + + // Versand Inland = Porto DE + Serviceaufschlag + Kuvert + Aufschlag Beschriftung + const domesticPrice = portoDomestic + serviceCharge + envelopeBase + labelingCharge; + + // Versand Ausland = Porto Ausland + Serviceaufschlag + Kuvert + Aufschlag Beschriftung + const internationalPrice = portoInternational + serviceCharge + envelopeBase + labelingCharge; + + return { + domesticPrice, + internationalPrice, + portoDomestic, + portoInternational, + serviceCharge, + envelopeBase, + labelingCharge, + }; +} + +/** + * Berechnet Multiplikator basierend auf monatlichem Volumen (nur Follow-ups) + */ +function getFollowupMultiplier(monthlyVolume) { + const prices = getPrices(); + + if (monthlyVolume >= 1000) return prices.followup_mult_1000_plus || 1.0; + if (monthlyVolume >= 500) return prices.followup_mult_500_999 || 1.2; + if (monthlyVolume >= 200) return prices.followup_mult_200_499 || 1.4; + if (monthlyVolume >= 50) return prices.followup_mult_50_199 || 1.7; + if (monthlyVolume >= 5) return prices.followup_mult_5_49 || 2.0; + + return 2.0; // Default für < 5 +} + +/** + * Berechnet dynamischen Preis-Multiplikator basierend auf Menge + * (außer für Follow-ups) + */ +function getDynamicPriceMultiplier(state) { + // Follow-ups verwenden eigene Logik + if (isFollowups(state)) { + return 1.0; + } + + const quantity = state.answers.quantity || 0; + const isB2B = isBusinessCustomer(state); + const dynamicPricing = getDynamicPricing(); + + // Welche Formel verwenden? + const formula = isB2B + ? dynamicPricing.business_formula + : dynamicPricing.private_formula; + + // Welche Parameter verwenden? + const normQty = isB2B + ? (dynamicPricing.business_normal_quantity || 200) + : (dynamicPricing.private_normal_quantity || 50); + + const minQty = isB2B + ? (dynamicPricing.business_min_quantity || 50) + : (dynamicPricing.private_min_quantity || 10); + + // Keine Formel definiert? Standard-Multiplikator 1 + if (!formula || formula.trim() === '') { + return 1.0; + } + + try { + // Platzhalter ersetzen + let evaluableFormula = formula + .replace(/%qty%/g, String(quantity)) + .replace(/%norm_b%/g, String(dynamicPricing.business_normal_quantity || 200)) + .replace(/%mind_b%/g, String(dynamicPricing.business_min_quantity || 50)) + .replace(/%norm_p%/g, String(dynamicPricing.private_normal_quantity || 50)) + .replace(/%mind_p%/g, String(dynamicPricing.private_min_quantity || 10)); + + // Sichere Formel-Auswertung ohne eval() + const multiplier = safeEvaluateMathFormula(evaluableFormula); + + // Sicherheitsprüfung + if (typeof multiplier !== 'number' || isNaN(multiplier) || !isFinite(multiplier) || multiplier < 0) { + console.warn('Ungültiger Multiplikator aus Formel:', multiplier); + return 1.0; + } + + return multiplier; + } catch (error) { + console.error('Fehler beim Auswerten der dynamischen Preisformel:', error); + return 1.0; + } +} + +/** + * Berechnet Preis pro Schriftstück + * Bei Direktversand wird der Durchschnittspreis basierend auf Inland/Ausland-Verteilung berechnet + */ +export function calculatePricePerPiece(state) { + const prices = getPrices(); + const product = state.ctx?.product; + + if (!product) return 0; + + let basePrice = product.basePrice || 0; + + // A4 Aufpreis (nur für Einladungen und Follow-ups, NICHT für Postkarten) + const needsA4Surcharge = + (product.key === "einladungen" || product.key === "follow-ups") && + state.answers.format === "a4"; + + if (needsA4Surcharge) { + basePrice += prices.a4_upgrade_surcharge || 0; + } + + // Multiplikator NUR auf Basispreis anwenden (nicht auf Versand/Umschlag) + let pricePerPiece = basePrice; + if (isFollowups(state) && state.answers.followupYearlyVolume) { + // Follow-ups: Spezielle Mengenstaffel + const monthlyVolume = parseInt(state.answers.followupYearlyVolume) || 0; + const multiplier = getFollowupMultiplier(monthlyVolume); + pricePerPiece *= multiplier; + } else { + // Alle anderen Produkte: Dynamische Preisberechnung + const dynamicMultiplier = getDynamicPriceMultiplier(state); + pricePerPiece *= dynamicMultiplier; + } + + // Versandkosten bei Direktversand - immer Inlandspreis für "ab"-Preis + if (state.answers.shippingMode === "direct") { + const shippingPrices = calculateDirectShippingPrices(state); + // Immer Inlandspreis verwenden für den "ab"-Preis + pricePerPiece += shippingPrices.domesticPrice; + } + + // Umschlag-Kosten (nur bei Bulk-Versand) + if ( + state.answers.shippingMode === "bulk" && + state.answers.envelope === true + ) { + // Grundpreis für Kuvert + pricePerPiece += prices.envelope_base || 0; + + // Zusätzliche Beschriftungskosten wenn Umschlag beschrieben wird + const labelingPrice = prices.envelope_labeling || prices.envelope_recipient_address || 0; + if (state.answers.envelopeMode === "recipientData" || state.answers.envelopeMode === "customText") { + pricePerPiece += labelingPrice; + } + // Bei "none" nur Grundpreis + } + + // Motiv-Kosten sind Einmalkosten und werden NICHT auf Preis pro Stück gerechnet + // (werden separat in calculateSetupCosts berechnet) + + // Kaufmännische Rundung auf 2 Dezimalstellen + return Math.round(pricePerPiece * 100) / 100; +} + +/** + * Berechnet Grundpreis pro Stück für Follow-ups OHNE Multiplikator + * (für die Anzeige in der Kopfzeile) + */ +export function calculateFollowupBasePricePerPiece(state) { + const prices = getPrices(); + const product = state.ctx?.product; + + if (!product) return 0; + + let pricePerPiece = product.basePrice || 0; + + // A4 Aufpreis (nur für Einladungen und Follow-ups, NICHT für Postkarten) + const needsA4Surcharge = + (product.key === "einladungen" || product.key === "follow-ups") && + state.answers.format === "a4"; + + if (needsA4Surcharge) { + pricePerPiece += prices.a4_upgrade_surcharge || 0; + } + + // Versandkosten für Follow-ups (immer Direktversand, Inland-Preis als Basis) + const shippingPrices = calculateDirectShippingPrices(state); + pricePerPiece += shippingPrices.domesticPrice; + + // KEINE Einmalkosten (API-Anbindung, Motiv-Upload, etc.) - nur wiederkehrende Kosten pro Stück + // KEIN Multiplikator hier - das ist der Grundpreis + + return pricePerPiece; +} + +/** + * Berechnet Versandkosten + * Rückgabe: { total, shipping0Pct, shipping19Pct, domesticCount, internationalCount, shippingPrices } + */ +export function calculateShippingCosts(state) { + const prices = getPrices(); + const quantity = state.answers.quantity || 1; + + const result = { + total: 0, + shipping0Pct: 0, // Portoanteil mit 0% MwSt + shipping19Pct: 0, // Service + Kuvert + Beschriftung mit 19% MwSt + domesticCount: 0, + internationalCount: 0, + shippingPrices: null, + }; + + if (state.answers.shippingMode === "direct") { + // Direktversand: Preis abhängig vom Land + // Komponenten: Porto (0% MwSt) + Serviceaufschlag + Kuvert + Beschriftung (alle 19% MwSt) + const shippingPrices = calculateDirectShippingPrices(state); + result.shippingPrices = shippingPrices; + + // Empfänger nach Land zählen + const { domesticCount, internationalCount } = countRecipientsByCountry(state); + result.domesticCount = domesticCount; + result.internationalCount = internationalCount; + + // Porto (0% MwSt) + const totalPorto = (domesticCount * shippingPrices.portoDomestic) + + (internationalCount * shippingPrices.portoInternational); + result.shipping0Pct = totalPorto; + + // Service + Kuvert + Beschriftung (19% MwSt) - gleich für alle + const servicePerPiece = shippingPrices.serviceCharge + + shippingPrices.envelopeBase + + shippingPrices.labelingCharge; + result.shipping19Pct = servicePerPiece * quantity; + + result.total = result.shipping0Pct + result.shipping19Pct; + } else if (state.answers.shippingMode === "bulk") { + // Bulkversand: Einmalig, 0% MwSt + const shippingBulk = prices.shipping_bulk || 4.95; + result.shipping0Pct = shippingBulk; + result.total = shippingBulk; + } + + return result; +} + +/** + * Berechnet einmalige Einrichtungskosten + */ +export function calculateSetupCosts(state) { + const prices = getPrices(); + let setupTotal = 0; + + // API-Anbindung (nur bei Follow-ups mit auto mode) + if (isFollowups(state) && state.answers.followupCreateMode === "auto") { + setupTotal += prices.api_connection || 0; + } + + // Motiv Upload - NUR wenn motifNeed = true + if (state.answers.motifNeed === true && state.answers.motifSource === "upload") { + setupTotal += prices.motif_upload || 0; + } + + // Designservice - NUR wenn motifNeed = true + if (state.answers.motifNeed === true && state.answers.motifSource === "design") { + setupTotal += prices.motif_design || 0; + } + + // Textservice + if (state.answers.contentCreateMode === "textservice") { + setupTotal += prices.textservice || 0; + } + + return setupTotal; +} + +/** + * Formatiert Preis inkl/zzgl MwSt je nach Kundentyp + */ +export function formatPrice(netPrice, state, options = {}) { + const isB2B = isBusinessCustomer(state); + const taxRate = (getPrices().tax_rate || 19) / 100; + + // Option für 0% MwSt (Versand) + const actualTaxRate = options.zeroTax ? 0 : taxRate; + + const grossPrice = netPrice * (1 + actualTaxRate); + + if (isB2B) { + // Business: Netto + "zzgl. MwSt" + return { + display: `${formatEUR(netPrice)}`, + net: netPrice, + gross: grossPrice, + isB2B: true, + }; + } else { + // Endkunde: Brutto (inkl. MwSt) + return { + display: formatEUR(grossPrice), + net: netPrice, + gross: grossPrice, + isB2B: false, + }; + } +} + +/** + * Hilfsfunktion: EUR formatieren + */ +function formatEUR(amount) { + const safe = typeof amount === "number" && !isNaN(amount) ? amount : 0; + return safe.toLocaleString("de-DE", { style: "currency", currency: "EUR" }); +} + +/** + * Berechnet vollständiges Quote für Nicht-Follow-ups + */ +export function calculateStandardQuote(state) { + const quantity = state.answers.quantity || 1; + const pricePerPiece = calculatePricePerPiece(state); + const shipping = calculateShippingCosts(state); + const setup = calculateSetupCosts(state); + const taxRate = (getPrices().tax_rate || 19) / 100; + const isB2B = isBusinessCustomer(state); + + // Erst die Zeilen erstellen (pricePerPiece übergeben um doppelte Berechnung zu vermeiden) + const lines = buildQuoteLines(state, { + productNet: pricePerPiece * quantity, + pricePerPiece, + shipping, + setup, + taxRate, + }); + + // Gesamtpreise berechnen: + // - Summe aller totalGross aus den Zeilen (NICHT aus isSubItem!) + // - Alles hat 19% MwSt. (auch Porto!) + + // Gesamtsummen aus den Zeilen berechnen + // Nur Zeilen die NICHT isSubItem sind, werden zur Summe addiert + // (isSubItem sind nur Aufschlüsselungen innerhalb einer Hauptzeile) + let subtotalNet = 0; + let totalGross = 0; + + for (const line of lines) { + // Sub-Items nicht zur Summe addieren (sind bereits in Hauptzeile enthalten) + if (!line.isSubItem) { + subtotalNet += line.totalNet || 0; + totalGross += line.totalGross || 0; + } + } + + // Runden + subtotalNet = Math.round(subtotalNet * 100) / 100; + totalGross = Math.round(totalGross * 100) / 100; + + // MwSt. berechnen (Differenz zwischen Brutto und Netto) + const vatAmount = Math.round((totalGross - subtotalNet) * 100) / 100; + + // Gutschein-Rabatt berechnen + const voucher = state.order?.voucherStatus?.valid ? state.order.voucherStatus.voucher : null; + let discountAmount = 0; + let totalAfterDiscount = totalGross; + let vatAfterDiscount = vatAmount; + + if (voucher) { + if (voucher.type === 'percent') { + // Prozentual: vom Bruttopreis abziehen + discountAmount = Math.round(totalGross * (voucher.value / 100) * 100) / 100; + } else { + // Festbetrag + discountAmount = voucher.value; + } + + // Sicherstellen dass Rabatt nicht größer als Gesamtpreis ist + discountAmount = Math.min(discountAmount, totalGross); + + // Neuer Gesamtpreis + totalAfterDiscount = Math.round((totalGross - discountAmount) * 100) / 100; + + // MwSt. neu berechnen (proportional zum Rabatt) + if (totalGross > 0) { + vatAfterDiscount = Math.round(vatAmount * (totalAfterDiscount / totalGross) * 100) / 100; + } + } + + return { + currency: "EUR", + quantity, + pricePerPiece, + productNet: subtotalNet, + shipping, + setup, + subtotalNet, + vatRate: taxRate, + vatAmount: vatAfterDiscount, + totalGross: totalAfterDiscount, + discountAmount, + totalBeforeDiscount: totalGross, + voucher, + lines, + }; +} + +/** + * Erstellt Zeilen für Quote-Tabelle mit detaillierter Aufschlüsselung + */ +function buildQuoteLines(state, calc) { + const lines = []; + const quantity = state.answers.quantity || 1; + const product = state.ctx?.product; + const prices = getPrices(); + const taxRate = calc.taxRate || 0.19; + + // Produktzeile (vollständiger Preis inkl. Versand etc.) + // Hilfsfunktion für kaufmännische Rundung auf 2 Dezimalstellen + const round2 = (val) => Math.round(val * 100) / 100; + + if (product) { + // pricePerPiece aus calc verwenden (bereits in calculateStandardQuote berechnet) + // Falls nicht vorhanden, neu berechnen + const pricePerPiece = calc.pricePerPiece !== undefined + ? calc.pricePerPiece + : calculatePricePerPiece(state); + + lines.push({ + description: product.label, + unitNet: pricePerPiece, + unitGross: round2(pricePerPiece * (1 + taxRate)), + quantity, + totalNet: round2(pricePerPiece * quantity), + totalGross: round2(pricePerPiece * quantity * (1 + taxRate)), + isMainProduct: true, + }); + + // Basispreis-Zeile: Berechne aus pricePerPiece minus Versand/Kuvert-Kosten + // (statt nochmal den teuren Multiplier zu berechnen) + let basePriceWithMultiplier = pricePerPiece; + + // Versandkosten abziehen (falls Direktversand) + if (state.answers.shippingMode === "direct") { + const shippingPrices = calc.shipping?.shippingPrices || calculateDirectShippingPrices(state); + basePriceWithMultiplier -= shippingPrices.domesticPrice || 0; + } + + // Kuvert-Kosten abziehen (falls Bulk mit Kuvert) + if (state.answers.shippingMode === "bulk" && state.answers.envelope === true) { + basePriceWithMultiplier -= prices.envelope_base || 0; + if (state.answers.envelopeMode === "recipientData" || state.answers.envelopeMode === "customText") { + basePriceWithMultiplier -= prices.envelope_labeling || prices.envelope_recipient_address || 0; + } + } + + basePriceWithMultiplier = round2(basePriceWithMultiplier); + + lines.push({ + description: "Basispreis Schriftstück", + unitNet: basePriceWithMultiplier, + unitGross: round2(basePriceWithMultiplier * (1 + taxRate)), + quantity, + totalNet: round2(basePriceWithMultiplier * quantity), + totalGross: round2(basePriceWithMultiplier * quantity * (1 + taxRate)), + isSubItem: true, + }); + + // A4 Aufpreis (nur für Einladungen und Follow-ups) + const needsA4Surcharge = + (product.key === "einladungen" || product.key === "follow-ups") && + state.answers.format === "a4"; + if (needsA4Surcharge) { + const a4Price = prices.a4_upgrade_surcharge || 0; + lines.push({ + description: "A4 Format Aufpreis", + unitNet: a4Price, + unitGross: a4Price * (1 + taxRate), + quantity, + totalNet: a4Price * quantity, + totalGross: a4Price * quantity * (1 + taxRate), + isSubItem: true, + }); + } + + // Direktversand - getrennte Zeilen für Inland und Ausland + // WICHTIG: Porto hat jetzt auch 19% MwSt. (nicht mehr 0%) + if (state.answers.shippingMode === "direct") { + const shipping = calc.shipping || {}; + const shippingPrices = shipping.shippingPrices || calculateDirectShippingPrices(state); + const domesticCount = shipping.domesticCount || 0; + const internationalCount = shipping.internationalCount || 0; + + // Service + Kuvert + Beschriftung + Porto - ALLES mit 19% MwSt + const servicePerPiece = shippingPrices.serviceCharge + + shippingPrices.envelopeBase + + shippingPrices.labelingCharge; + + // Inland-Zeile (wenn Inland-Empfänger vorhanden) + if (domesticCount > 0) { + const domesticUnitNet = shippingPrices.portoDomestic + servicePerPiece; + const domesticUnitGross = domesticUnitNet * (1 + taxRate); + const domesticTotalNet = domesticUnitNet * domesticCount; + const domesticTotalGross = domesticUnitGross * domesticCount; + + lines.push({ + description: "Direktversand mit Kuvertierung (Inland)", + unitNet: domesticUnitNet, + unitGross: domesticUnitGross, + quantity: domesticCount, + totalNet: domesticTotalNet, + totalGross: domesticTotalGross, + isSubItem: true, + isShippingLine: true, + }); + } + + // Ausland-Zeile (wenn Ausland-Empfänger vorhanden) + if (internationalCount > 0) { + const intlUnitNet = shippingPrices.portoInternational + servicePerPiece; + const intlUnitGross = intlUnitNet * (1 + taxRate); + const intlTotalNet = intlUnitNet * internationalCount; + const intlTotalGross = intlUnitGross * internationalCount; + + lines.push({ + description: "Direktversand mit Kuvertierung (Ausland)", + unitNet: intlUnitNet, + unitGross: intlUnitGross, + quantity: internationalCount, + totalNet: intlTotalNet, + totalGross: intlTotalGross, + isSubItem: true, + isShippingLine: true, + }); + } + } + + // Kuvert bei Bulkversand + if ( + state.answers.shippingMode === "bulk" && + state.answers.envelope === true + ) { + const envelopeBase = prices.envelope_base || 0; + lines.push({ + description: "Kuvert", + unitNet: envelopeBase, + unitGross: envelopeBase * (1 + taxRate), + quantity, + totalNet: envelopeBase * quantity, + totalGross: envelopeBase * quantity * (1 + taxRate), + isSubItem: true, + }); + + // Beschriftung (neues Feld: envelope_labeling, Fallback auf alte Felder) + if (state.answers.envelopeMode === "recipientData" || state.answers.envelopeMode === "customText") { + const labelPrice = prices.envelope_labeling || prices.envelope_recipient_address || 0; + const labelDescription = state.answers.envelopeMode === "recipientData" + ? "Beschriftung mit Empfängeradresse" + : "Beschriftung mit individuellem Text"; + lines.push({ + description: labelDescription, + unitNet: labelPrice, + unitGross: labelPrice * (1 + taxRate), + quantity, + totalNet: labelPrice * quantity, + totalGross: labelPrice * quantity * (1 + taxRate), + isSubItem: true, + }); + } + } + + // Bedruckte Karten - NUR wenn motifNeed = true + if (state.answers.motifNeed === true && state.answers.motifSource === "printed") { + const printedPrice = prices.motif_printed || 0; + if (printedPrice > 0) { + lines.push({ + description: "Bedruckte Karten zusenden", + unitNet: printedPrice, + unitGross: printedPrice * (1 + taxRate), + quantity, + totalNet: printedPrice * quantity, + totalGross: printedPrice * quantity * (1 + taxRate), + isSubItem: true, + }); + } + } + } + + // Sammelversand (einmalig) - jetzt auch mit 19% MwSt + if (state.answers.shippingMode === "bulk") { + const bulkPrice = prices.shipping_bulk || 0; + lines.push({ + description: "Sammelversand (einmalig)", + unitNet: bulkPrice, + unitGross: bulkPrice * (1 + taxRate), + quantity: 1, + totalNet: bulkPrice, + totalGross: bulkPrice * (1 + taxRate), + }); + } + + // Einmalige Setup-Kosten + const setupCosts = []; + + // Motiv Upload - NUR wenn motifNeed = true + if (state.answers.motifNeed === true && state.answers.motifSource === "upload") { + const uploadPrice = prices.motif_upload || 0; + if (uploadPrice > 0) { + setupCosts.push({ + description: "Motiv Upload (einmalig)", + unitNet: uploadPrice, + unitGross: uploadPrice * (1 + taxRate), + quantity: 1, + totalNet: uploadPrice, + totalGross: uploadPrice * (1 + taxRate), + }); + } + } + + // Designservice - NUR wenn motifNeed = true + if (state.answers.motifNeed === true && state.answers.motifSource === "design") { + const designPrice = prices.motif_design || 0; + if (designPrice > 0) { + setupCosts.push({ + description: "Designservice (einmalig)", + unitNet: designPrice, + unitGross: designPrice * (1 + taxRate), + quantity: 1, + totalNet: designPrice, + totalGross: designPrice * (1 + taxRate), + }); + } + } + + // Textservice + if (state.answers.contentCreateMode === "textservice") { + const textPrice = prices.textservice || 0; + if (textPrice > 0) { + setupCosts.push({ + description: "Textservice (einmalig)", + unitNet: textPrice, + unitGross: textPrice * (1 + taxRate), + quantity: 1, + totalNet: textPrice, + totalGross: textPrice * (1 + taxRate), + }); + } + } + + // API-Anbindung (nur bei Follow-ups) + if (isFollowups(state) && state.answers.followupCreateMode === "auto") { + const apiPrice = prices.api_connection || 0; + if (apiPrice > 0) { + setupCosts.push({ + description: "API-Anbindung (einmalig)", + unitNet: apiPrice, + unitGross: apiPrice * (1 + taxRate), + quantity: 1, + totalNet: apiPrice, + totalGross: apiPrice * (1 + taxRate), + }); + } + } + + return [...lines, ...setupCosts]; +} + +/** + * Berechnet Follow-up Preisstaffelung (für Tabelle) + */ +export function calculateFollowupPricing(state) { + if (!isFollowups(state)) return null; + + const pricePerPiece = calculatePricePerPiece(state); + const taxRate = (getPrices().tax_rate || 19) / 100; + const isB2B = isBusinessCustomer(state); + + // Preis-Stufen basierend auf monatlichem Volumen + const tiers = [ + { min: 5, max: 49, label: "5-49 Stück/Monat" }, + { min: 50, max: 199, label: "50-199 Stück/Monat" }, + { min: 200, max: 499, label: "200-499 Stück/Monat" }, + { min: 500, max: 999, label: "500-999 Stück/Monat" }, + { min: 1000, max: null, label: "1000+ Stück/Monat" }, + ]; + + const pricing = tiers.map((tier) => { + const avgVolume = tier.max ? (tier.min + tier.max) / 2 : 1000; + const multiplier = getFollowupMultiplier(avgVolume); + const piecePrice = pricePerPiece / multiplier || 0; // Basispreis vor Multiplikator + const monthlyNet = piecePrice * avgVolume * multiplier; + const monthlyGross = monthlyNet * (1 + taxRate); + + return { + label: tier.label, + multiplier, + pricePerPiece: piecePrice * multiplier, + monthlyNet, + monthlyGross, + displayNet: formatEUR(monthlyNet), + displayGross: formatEUR(monthlyGross), + }; + }); + + return { + tiers: pricing, + setup: calculateSetupCosts(state), + shippingNote: null, // Bei Follow-ups keine Versandkosten-Meldung + isB2B, + }; +} + +export { formatEUR }; diff --git a/Webseite/skrift-configurator/assets/js/configurator-state.js b/Webseite/skrift-configurator/assets/js/configurator-state.js new file mode 100644 index 0000000..0265025 --- /dev/null +++ b/Webseite/skrift-configurator/assets/js/configurator-state.js @@ -0,0 +1,1007 @@ +// Skrift Konfigurator State - Überarbeitet +// Optimierter Frageprozess mit bedingter Logik + +// Import Pricing Logic +import { calculateStandardQuote } from "./configurator-pricing.js"; + +// Basis-Produktdefinitionen (werden mit Backend-Settings überschrieben) +const PRODUCT_BASE_CONFIG = { + businessbriefe: { + key: "businessbriefe", + formats: ["a4"], + category: "business", + supportsMotif: false, + }, + "business-postkarten": { + key: "business-postkarten", + formats: ["a6p", "a6l"], + category: "business", + supportsMotif: true, + }, + "follow-ups": { + key: "follow-ups", + formats: ["a4", "a6p", "a6l"], + category: "business", + supportsMotif: true, + isFollowUp: true, + }, + einladungen: { + key: "einladungen", + formats: ["a4", "a6p", "a6l"], + category: "private", + supportsMotif: true, + }, + "private-briefe": { + key: "private-briefe", + formats: ["a4"], + category: "private", + supportsMotif: false, + }, +}; + +// Produktdefinitionen mit Backend-Settings mergen +function getProductDefinitions() { + const settings = window.SkriftConfigurator?.settings?.products || {}; + const products = {}; + + for (const [key, baseConfig] of Object.entries(PRODUCT_BASE_CONFIG)) { + const backendSettings = settings[key] || {}; + products[key] = { + ...baseConfig, + label: backendSettings.label || key, + description: + backendSettings.description || + "Professionelle handgeschriebene Korrespondenz", + basePrice: parseFloat(backendSettings.base_price) || 2.5, + imageUrl: backendSettings.image_url || "", + }; + } + + return products; +} + +const PRODUCT_BY_PARAM = getProductDefinitions(); + +export const STEPS = { + PRODUCT: 0, // Kundentyp + Produktauswahl zusammen + QUANTITY: 1, // Mengenabfrage + ENVELOPE: 2, // Versand + Umschlag zusammen + CONTENT: 3, + CUSTOMER_DATA: 4, + REVIEW: 5, +}; + +export function deriveContextFromUrl(search) { + const q = new URLSearchParams(search); + const keys = Array.from(q.keys()); + + // Produkt ermitteln (erster Key der ein Produkt ist, oder 'product' Parameter) + let product = null; + let urlParam = null; + + // Prüfe ob ein Produkt-Schlüssel direkt als Parameter vorhanden ist + for (const key of keys) { + if (PRODUCT_BY_PARAM[key]) { + product = PRODUCT_BY_PARAM[key]; + urlParam = key; + break; + } + } + + // Quantity aus URL + const quantityParam = q.get("quantity"); + const quantity = quantityParam ? parseInt(quantityParam, 10) : null; + + // Format aus URL (a4, a6h = A6 Hochformat, a6q = A6 Querformat) + const formatParam = q.get("format")?.toLowerCase(); + let format = null; + if (formatParam === "a4") format = "a4"; + else if (formatParam === "a6h") + format = "a6p"; // Hochformat + else if (formatParam === "a6q") format = "a6l"; // Querformat + + // noPrice Parameter (Preise ausblenden) + const noPrice = q.has("noPrice") || q.has("noprice"); + + // noLimits Parameter (keine Mindestmengen) + const noLimits = q.has("noLimits") || q.has("nolimits"); + + return { + urlParam: urlParam || null, + product, + quantity: quantity && !isNaN(quantity) && quantity > 0 ? quantity : null, + format, + noPrice, + noLimits, + }; +} + +export function normalizePlaceholderName(raw) { + return String(raw || "") + .toLowerCase() + .replace(/\s+/g, ""); +} + +export function extractPlaceholders(text) { + const out = new Set(); + const re = /\[\[([^\]]+)\]\]/g; + let m; + while ((m = re.exec(String(text || "")))) { + const name = normalizePlaceholderName(m[1]); + if (name) out.add(name); + } + return Array.from(out); +} + +export function getEnvelopeTypeByFormat(format) { + if (format === "a4") return "dinlang"; + if (format === "a6p" || format === "a6l") return "c6"; + return null; +} + +export function createInitialState(ctx) { + const hasPreselectedProduct = !!ctx?.product?.key; + + // Wenn Produkt vorgewählt, Produktauswahl überspringen + let initialStep = STEPS.PRODUCT; + let initialCustomerType = null; + + if (hasPreselectedProduct) { + initialStep = STEPS.QUANTITY; + initialCustomerType = + ctx.product.category === "business" ? "business" : "private"; + } + + // URL-Parameter für Menge und Format + const urlQuantity = ctx?.quantity || null; + const urlFormat = ctx?.format || null; + + // Wenn Produkt + Quantity + Format aus URL: Direkt zu Umschlag-Step springen + if (hasPreselectedProduct && urlQuantity && urlFormat) { + // Prüfen ob Format vom Produkt unterstützt wird + const supportedFormats = ctx.product.formats || []; + if (supportedFormats.includes(urlFormat)) { + initialStep = STEPS.ENVELOPE; + } + } + + // Standardmenge auf beste Preismenge (normalQuantity) setzen, außer URL-Parameter + const dynamicPricing = + window.SkriftConfigurator?.settings?.dynamic_pricing || {}; + const isB2B = initialCustomerType === "business"; + const defaultQuantity = + urlQuantity || + (isB2B + ? dynamicPricing.business_normal_quantity || 200 + : dynamicPricing.private_normal_quantity || 50); + + // Format aus URL oder null + const initialFormat = urlFormat || null; + + // noPrice Mode aus URL + const noPrice = ctx?.noPrice || false; + + // noLimits Mode aus URL (keine Mindestmengen) + const noLimits = ctx?.noLimits || false; + + return { + step: initialStep, + history: [], + ctx, + noPrice, // Preise ausblenden wenn true + noLimits, // Keine Mindestmengen wenn true + + quote: { + currency: "EUR", + subtotalNet: 0, + vatRate: 0.19, + vatAmount: 0, + totalGross: 0, + lines: [], + }, + + answers: { + customerType: initialCustomerType, + quantity: defaultQuantity, + format: initialFormat, + + // Follow-ups Spezifisch + followupYearlyVolume: null, // Neu: 5-49, 50-199, etc. + followupCreateMode: null, // 'auto' | 'manual' + followupSourceSystem: "", + followupTriggerDescription: "", // Neu: Was löst Follow-up aus + followupCheckCycle: "monthly", + + // Versand + shippingMode: null, // 'direct' | 'bulk' + + // Umschlag + envelope: null, + envelopeLabeled: null, + envelopeMode: null, // 'recipientData' | 'customText' | 'none' + envelopeCustomText: "", + + // Inhalt + contentCreateMode: null, // 'self' | 'textservice' + letterText: "", + font: "tilda", + envelopeFont: "tilda", + realisticHandwriting: false, + + // Motiv + motifNeed: null, + motifSource: null, // 'upload' | 'printed' | 'design' + motifFileName: "", + motifFileMeta: null, + + // Services + serviceText: false, + serviceDesign: false, + serviceApi: false, + }, + + recipientRows: [], + // Adressmodus: 'classic' (Name, Anschrift) oder 'free' (5 freie Zeilen) + addressMode: "classic", + // Freie Adresszeilen (separat gespeichert, damit beim Wechsel nichts verloren geht) + freeAddressRows: [], + placeholders: { + envelope: [], + letter: [], + }, + placeholderValues: {}, + + order: { + billing: { + firstName: "", + lastName: "", + company: "", + email: "", + phone: "", + street: "", + houseNumber: "", + zip: "", + city: "", + country: "Deutschland", + }, + shippingDifferent: false, + shipping: { + firstName: "", + lastName: "", + company: "", + street: "", + houseNumber: "", + zip: "", + city: "", + country: "Deutschland", + }, + acceptedAgb: false, + acceptedPrivacy: false, + }, + }; +} + +// Helper Functions +export function isFollowups(state) { + return state?.ctx?.product?.isFollowUp === true; +} + +export function isInvitation(state) { + return state?.ctx?.product?.key === "einladungen"; +} + +export function isPostcardLike(state) { + const k = state?.ctx?.product?.key; + return ( + k === "business-postkarten" || + (k === "follow-ups" && + (state.answers.format === "a6p" || state.answers.format === "a6l")) + ); +} + +export function supportsMotif(state) { + const product = state?.ctx?.product; + if (!product?.supportsMotif) return false; + + // Postkarten: immer Motiv möglich (nur A6 verfügbar) + if (product.key === "business-postkarten") return true; + + // Einladungen und Follow-ups: nur bei A6 Format + if (product.key === "einladungen" || product.key === "follow-ups") { + return state.answers.format === "a6p" || state.answers.format === "a6l"; + } + + return false; +} + +export function productSupportsFormat(state, format) { + const formats = state?.ctx?.product?.formats || []; + return formats.includes(format); +} + +export function calcEffectiveEnvelopeType(state) { + return getEnvelopeTypeByFormat(state.answers.format); +} + +export function getAvailableProductsForCustomerType(customerType) { + const fresh = getProductDefinitions(); + return Object.values(fresh).filter((p) => p.category === customerType); +} + +export function syncPlaceholders(state) { + const pEnv = + state.answers.envelopeMode === "customText" + ? extractPlaceholders(state.answers.envelopeCustomText) + : []; + + const pLetter = extractPlaceholders(state.answers.letterText); + + const builtIn = + state.answers.envelopeMode === "recipientData" ? ["vorname", "name"] : []; + + return { + ...state, + placeholders: { + envelope: pEnv, + letter: Array.from(new Set([...pLetter, ...builtIn])), + }, + }; +} + +export function requiredRowCount(state) { + const q = Number(state.answers.quantity); + return Number.isFinite(q) && q > 0 ? q : 0; +} + +export function ensurePlaceholderArrays(state) { + const rows = requiredRowCount(state); + const all = new Set([ + ...state.placeholders.envelope, + ...state.placeholders.letter, + ]); + + const nextValues = { ...state.placeholderValues }; + + for (const name of all) { + if (!Array.isArray(nextValues[name])) nextValues[name] = []; + if (nextValues[name].length < rows) { + nextValues[name] = [ + ...nextValues[name], + ...new Array(rows - nextValues[name].length).fill(""), + ]; + } else if (nextValues[name].length > rows) { + nextValues[name] = nextValues[name].slice(0, rows); + } + } + + return { ...state, placeholderValues: nextValues }; +} + +export function validateStep(state) { + const s = state.step; + + if (s === STEPS.PRODUCT) { + // Kundentyp und Produkt müssen gewählt sein + if (!state.answers.customerType) return false; + return !!state?.ctx?.product?.key; + } + + if (s === STEPS.QUANTITY) { + // Bei Follow-ups keine Mengenabfrage, nur Volumen + if (!isFollowups(state)) { + const qty = Number(state.answers.quantity); + if (!Number.isFinite(qty) || qty <= 0) return false; + } + + // Format Auswahl validieren + const formats = state?.ctx?.product?.formats || []; + if (formats.length > 0 && !state.answers.format) return false; + + // Follow-ups spezifische Validierung + if (isFollowups(state)) { + if (!state.answers.followupYearlyVolume) return false; + if (!state.answers.followupCreateMode) return false; + + if (state.answers.followupCreateMode === "auto") { + if (!state.answers.followupSourceSystem?.trim()) return false; + if (!state.answers.followupTriggerDescription?.trim()) return false; + } + } + + return true; + } + + if (s === STEPS.ENVELOPE) { + // Versandart muss gewählt sein + if (!state.answers.shippingMode) return false; + + if (state.answers.shippingMode !== "direct") { + // Bulk versand + if (state.answers.envelope === null) return false; + + if (state.answers.envelope === true) { + if (!state.answers.envelopeMode) return false; + + if (state.answers.envelopeMode === "customText") { + if (!state.answers.envelopeCustomText?.trim()) return false; + } + } + } + + return true; + } + + if (s === STEPS.CONTENT) { + if (!state.answers.contentCreateMode) return false; + + // Wenn self, muss Text vorhanden sein + if (state.answers.contentCreateMode === "self") { + if (!state.answers.letterText?.trim()) return false; + } + + // Motiv Validierung + if (supportsMotif(state)) { + if (state.answers.motifNeed === null) return false; + + if (state.answers.motifNeed === true) { + if (!state.answers.motifSource) return false; + + // Nur bei Upload muss eine Datei vorhanden sein + // Bei "printed" (bedruckte Karten) und "design" (Designservice) nicht + if (state.answers.motifSource === "upload") { + if (!state.answers.motifFileName) return false; + } + } + } + + return true; + } + + if (s === STEPS.CUSTOMER_DATA) { + const b = state.order.billing; + // houseNumber entfernt - ist jetzt Teil von street + const req = [ + "firstName", + "lastName", + "email", + "phone", + "street", + "zip", + "city", + "country", + ]; + + for (const k of req) { + if (!String(b[k] || "").trim()) return false; + } + + // E-Mail Format validieren + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!emailRegex.test(b.email)) return false; + + if (state.answers.customerType === "business") { + if (!String(b.company || "").trim()) return false; + } + + if (state.order.shippingDifferent) { + const sh = state.order.shipping; + // Shipping braucht kein E-Mail/Telefon - nur Adressfelder + const shippingReq = [ + "firstName", + "lastName", + "street", + "zip", + "city", + "country", + ]; + for (const k of shippingReq) { + if (!String(sh[k] || "").trim()) return false; + } + } + + return true; + } + + if (s === STEPS.REVIEW) { + if (!state.order.acceptedAgb) return false; + if (!state.order.acceptedPrivacy) return false; + return true; + } + + return false; +} + +// Preisrelevante Felder die eine Neuberechnung auslösen +const PRICE_RELEVANT_FIELDS = new Set([ + "quantity", + "format", + "shippingMode", + "envelope", + "envelopeMode", + "motifSource", + "motifNeed", + "contentCreateMode", + "followupYearlyVolume", + "followupCreateMode", + "customerType", +]); + +/** + * Prüft ob ein Patch preisrelevante Änderungen enthält + */ +function hasPriceRelevantChanges(patch) { + if (!patch) return false; + return Object.keys(patch).some((key) => PRICE_RELEVANT_FIELDS.has(key)); +} + +/** + * Zählt Inland/Ausland-Empfänger für Vergleich + */ +function countCountryDistribution(rows, addressMode) { + let domestic = 0; + let international = 0; + + if (!Array.isArray(rows)) return { domestic, international }; + + for (const row of rows) { + if (!row) continue; + + let country = ""; + if (addressMode === "free") { + country = row.line5 || ""; + } else { + country = row.country || ""; + } + + const countryLower = country.toLowerCase().trim(); + const isDomestic = + !countryLower || + countryLower === "deutschland" || + countryLower === "germany" || + countryLower === "de" || + countryLower === "ger"; + + if (isDomestic) { + domestic++; + } else { + international++; + } + } + + return { domestic, international }; +} + +/** + * Berechnet Quote neu basierend auf aktuellem State + * Wird nur aufgerufen wenn preisrelevante Änderungen vorliegen + */ +function recalculateQuote(state, forceRecalculate = false) { + // Nur für Nicht-Follow-ups Quote berechnen + if (!isFollowups(state)) { + try { + const quote = calculateStandardQuote(state); + return { ...state, quote }; + } catch (e) { + console.error("Error calculating quote:", e); + } + } + return state; +} + +export function reducer(state, action) { + switch (action.type) { + case "HYDRATE_ALL": { + // Alle gespeicherten Daten in EINEM Durchgang wiederherstellen + const p = action.payload; + let next = { ...state }; + + // Produkt wiederherstellen + if (p.productKey) { + const products = listProducts(); + const savedProduct = products.find((prod) => prod.key === p.productKey); + if (savedProduct) { + next.ctx = { ...next.ctx, product: savedProduct }; + } + } + + // Answers wiederherstellen (URL-Parameter haben Vorrang) + const answerPatch = { ...p.answers }; + if (p.urlQuantity !== null) delete answerPatch.quantity; + if (p.urlFormat !== null) delete answerPatch.format; + next.answers = { ...next.answers, ...answerPatch }; + + // Auto-Logik: Bei Direktversand immer envelopeMode sicherstellen + if (next.answers.shippingMode === "direct" && !next.answers.envelopeMode) { + next.answers = { ...next.answers, envelope: true, envelopeMode: "recipientData" }; + } + + // Empfängerdaten + if (Array.isArray(p.recipientRows)) { + next.recipientRows = p.recipientRows; + } + if (p.addressMode) { + next.addressMode = p.addressMode; + } + if (Array.isArray(p.freeAddressRows)) { + next.freeAddressRows = p.freeAddressRows; + } + if (p.placeholderValues && typeof p.placeholderValues === "object") { + next.placeholderValues = p.placeholderValues; + } + + // Order + if (p.order && typeof p.order === "object") { + next.order = { + ...next.order, + billing: p.order.billing || next.order?.billing, + shipping: p.order.shipping || next.order?.shipping, + shippingDifferent: !!p.order.shippingDifferent, + acceptedAgb: false, + acceptedPrivacy: false, + }; + } + + // Step wiederherstellen (nur wenn nicht im PRODUCT Step) + if ( + typeof p.step === "number" && + p.step >= 0 && + p.currentStep !== STEPS.PRODUCT + ) { + next.step = p.step; + next.history = []; + for (let i = 0; i < p.step; i++) { + next.history.push(i); + } + } + + // Wenn nur ein Format verfügbar und keins gespeichert: automatisch setzen + const hydrateFormats = next.ctx?.product?.formats || []; + if (hydrateFormats.length === 1 && !next.answers.format) { + next.answers = { ...next.answers, format: hydrateFormats[0] }; + } + + // Quote einmal am Ende berechnen + next = recalculateQuote(next); + + return next; + } + + case "SET_CUSTOMER_TYPE": { + // Bleibt im PRODUCT Step, zeigt jetzt Produktauswahl an + return { + ...state, + answers: { ...state.answers, customerType: action.customerType }, + }; + } + + case "SET_PRODUCT": { + let next = { + ...state, + ctx: { ...state.ctx, product: action.product }, + }; + + // Format zurücksetzen wenn nicht unterstützt + if ( + next.answers.format && + !productSupportsFormat(next, next.answers.format) + ) { + next.answers = { ...next.answers, format: null }; + } + + // Wenn nur ein Format verfügbar: automatisch setzen + const availFormats = action.product?.formats || []; + if (availFormats.length === 1 && !next.answers.format) { + next.answers = { ...next.answers, format: availFormats[0] }; + } + // Follow-ups: Automatisch Direktversand setzen + if (action.product?.key === "follow-ups") { + next.answers = { ...next.answers, shippingMode: "direct" }; + } + + return next; + } + + case "RESTORE_PRODUCT": { + // Wie SET_PRODUCT, aber ohne Step zu ändern (für localStorage-Hydration) + let next = { + ...state, + ctx: { ...state.ctx, product: action.product }, + }; + + // Format zurücksetzen wenn nicht unterstützt + if ( + next.answers.format && + !productSupportsFormat(next, next.answers.format) + ) { + next.answers = { ...next.answers, format: null }; + } + + // Wenn nur ein Format verfügbar: automatisch setzen + const availFormats = action.product?.formats || []; + if (availFormats.length === 1 && !next.answers.format) { + next.answers = { ...next.answers, format: availFormats[0] }; + } + // Follow-ups: Automatisch Direktversand setzen + if (action.product?.key === "follow-ups") { + next.answers = { ...next.answers, shippingMode: "direct" }; + } + + return next; + } + + case "SET_STEP": { + return { ...state, step: action.step }; + } + + case "ANSWER": { + let next = { ...state, answers: { ...state.answers, ...action.patch } }; + + // WICHTIG: Preview Cache löschen wenn envelopeMode geändert wird + if (action.patch && "envelopeMode" in action.patch) { + console.log("[State] envelopeMode changed, clearing envelope previews"); + if (window.envelopePreviewManager) { + window.envelopePreviewManager.currentBatchPreviews = []; + window.envelopePreviewManager.currentDocIndex = 0; + } + } + + // KEIN automatisches Preview-Löschen mehr für andere Änderungen + // Benutzer muss auf "Vorschau generieren" klicken um neu zu laden + // (verwendet dann 1 Request) + + // Auto-Logik für Versand -> Umschlag + if (action.patch && "shippingMode" in action.patch) { + if (action.patch.shippingMode === "direct") { + next.answers = { + ...next.answers, + envelope: true, + envelopeMode: "recipientData", + }; + // Bei Einzelversand: Leere Länder-Felder auf "Deutschland" setzen (Default) + if (Array.isArray(next.recipientRows)) { + next.recipientRows = next.recipientRows.map((row) => ({ + ...row, + // Nur setzen wenn Feld leer ist + country: + row.country && row.country.trim() !== "" + ? row.country + : "Deutschland", + })); + } + } + } + + // Auto-Logik für Kundentyp-Wechsel: Standardmenge anpassen NUR wenn noch nicht gesetzt + if (action.patch && "customerType" in action.patch) { + // Nur setzen wenn Menge noch nicht vom Benutzer geändert wurde + // (d.h. wenn sie noch dem alten Default entspricht oder nicht gesetzt ist) + const currentQty = next.answers.quantity; + const dynamicPricing = + window.SkriftConfigurator?.settings?.dynamic_pricing || {}; + + // Alte Default-Werte berechnen + const wasB2B = state.answers.customerType === "business"; + const oldDefaultQty = wasB2B + ? dynamicPricing.business_normal_quantity || 200 + : dynamicPricing.private_normal_quantity || 50; + + // Nur überschreiben wenn Menge noch dem alten Default entspricht oder nicht gesetzt ist + if (!currentQty || currentQty === oldDefaultQty || currentQty === 1) { + const isB2B = action.patch.customerType === "business"; + const newDefaultQuantity = isB2B + ? dynamicPricing.business_normal_quantity || 200 + : dynamicPricing.private_normal_quantity || 50; + next.answers = { ...next.answers, quantity: newDefaultQuantity }; + } + } + + // Auto-Logik für textservice + if (action.patch && "contentCreateMode" in action.patch) { + if (action.patch.contentCreateMode === "textservice") { + next.answers = { ...next.answers, serviceText: true }; + } else { + next.answers = { ...next.answers, serviceText: false }; + } + } + + // Auto-Logik für Motiv + Designservice + if (action.patch && "motifSource" in action.patch) { + if (action.patch.motifSource === "design") { + next.answers = { ...next.answers, serviceDesign: true }; + } + } + + // Auto-Logik für motifNeed: Wenn kein Motiv gewählt, motifSource zurücksetzen + if (action.patch && "motifNeed" in action.patch) { + if (action.patch.motifNeed === false) { + next.answers = { + ...next.answers, + motifSource: null, + serviceDesign: false, + }; + } + } + + // Auto-Logik für Format-Wechsel: Bei A4 nur "printed" erlaubt + if (action.patch && "format" in action.patch) { + if (action.patch.format === "a4") { + // Wenn upload oder design ausgewählt war, auf printed umstellen + if ( + next.answers.motifSource === "upload" || + next.answers.motifSource === "design" + ) { + next.answers = { ...next.answers, motifSource: "printed" }; + } + } + } + + next = syncPlaceholders(next); + next = ensurePlaceholderArrays(next); + + // Quote nur neu berechnen wenn preisrelevante Änderungen vorliegen + if (hasPriceRelevantChanges(action.patch)) { + next = recalculateQuote(next); + } + + return next; + } + + case "SET_RECIPIENT_ROWS": { + // Quote nur neu berechnen wenn sich Inland/Ausland-Verteilung geändert hat + let next = { ...state, recipientRows: action.rows }; + if (state.answers?.shippingMode === "direct") { + const oldDist = countCountryDistribution( + state.recipientRows, + state.addressMode, + ); + const newDist = countCountryDistribution( + action.rows, + state.addressMode, + ); + if ( + oldDist.domestic !== newDist.domestic || + oldDist.international !== newDist.international + ) { + next = recalculateQuote(next); + } + } + return next; + } + + case "SET_ADDRESS_MODE": { + // Quote neu berechnen da Adressmodus die Länder-Erkennung beeinflusst + let next = { ...state, addressMode: action.mode }; + if (state.answers?.shippingMode === "direct") { + next = recalculateQuote(next); + } + return next; + } + + case "SET_FREE_ADDRESS_ROWS": { + // Quote nur neu berechnen wenn sich Inland/Ausland-Verteilung geändert hat + let next = { ...state, freeAddressRows: action.rows }; + if (state.answers?.shippingMode === "direct") { + const oldDist = countCountryDistribution(state.freeAddressRows, "free"); + const newDist = countCountryDistribution(action.rows, "free"); + if ( + oldDist.domestic !== newDist.domestic || + oldDist.international !== newDist.international + ) { + next = recalculateQuote(next); + } + } + return next; + } + + case "SET_PLACEHOLDER_VALUE": { + const name = normalizePlaceholderName(action.name); + const row = Number(action.row); + const value = String(action.value ?? ""); + + const nextValues = { ...state.placeholderValues }; + const arr = Array.isArray(nextValues[name]) ? [...nextValues[name]] : []; + const rows = requiredRowCount(state); + + while (arr.length < rows) arr.push(""); + if (row >= 0 && row < rows) arr[row] = value; + + nextValues[name] = arr; + + return { ...state, placeholderValues: nextValues }; + } + + case "SET_PLACEHOLDER_VALUES": { + // Komplettes Platzhalter-Objekt setzen (analog zu SET_RECIPIENT_ROWS) + return { ...state, placeholderValues: action.values || {} }; + } + + case "SYNC_GLOBAL_STATE": { + // Synchronisiert alle direkt mutierten Daten (aus window.currentGlobalState) + // in einem einzigen Dispatch ohne mehrfaches Re-Render + const p = action.payload || {}; + let next = { ...state }; + + if (Array.isArray(p.recipientRows)) { + next.recipientRows = p.recipientRows; + } + if (Array.isArray(p.freeAddressRows)) { + next.freeAddressRows = p.freeAddressRows; + } + if (p.placeholderValues && typeof p.placeholderValues === "object") { + next.placeholderValues = p.placeholderValues; + } + + // Quote neu berechnen falls relevant + if (next.answers?.shippingMode === "direct") { + next = recalculateQuote(next); + } + + return next; + } + + case "SET_ORDER": { + const nextState = { + ...state, + order: { ...state.order, ...action.patch }, + }; + // Quote neu berechnen wenn Gutschein geändert wurde + return recalculateQuote(nextState); + } + + case "SET_ORDER_BILLING": { + return { + ...state, + order: { + ...state.order, + billing: { ...state.order.billing, ...action.patch }, + }, + }; + } + + case "SET_ORDER_SHIPPING": { + return { + ...state, + order: { + ...state.order, + shipping: { ...state.order.shipping, ...action.patch }, + }, + }; + } + + case "NAV_NEXT": { + const ok = validateStep(state); + if (!ok) return state; + + const nextStep = Math.min(STEPS.REVIEW, state.step + 1); + + // Nach oben scrollen + window.scrollTo({ top: 0, behavior: "smooth" }); + + return { + ...state, + history: [...state.history, state.step], + step: nextStep, + }; + } + + case "NAV_PREV": { + const hist = state.history || []; + if (hist.length === 0) return state; + const prev = hist[hist.length - 1]; + + // Nach oben scrollen + window.scrollTo({ top: 0, behavior: "smooth" }); + + return { ...state, step: prev, history: hist.slice(0, -1) }; + } + + default: + return state; + } +} + +export function listProducts() { + // Immer frisch laden damit Backend-Settings (inkl. imageUrl) verfügbar sind + const fresh = getProductDefinitions(); + return Object.values(fresh); +} diff --git a/Webseite/skrift-configurator/assets/js/configurator-ui.js b/Webseite/skrift-configurator/assets/js/configurator-ui.js new file mode 100644 index 0000000..f0bda2e --- /dev/null +++ b/Webseite/skrift-configurator/assets/js/configurator-ui.js @@ -0,0 +1,5666 @@ +import { + STEPS, + listProducts, + isFollowups, + isInvitation, + isPostcardLike, + supportsMotif, + calcEffectiveEnvelopeType, + extractPlaceholders, + normalizePlaceholderName, + validateStep, + getAvailableProductsForCustomerType, +} from "./configurator-state.js?ver=0.3.1"; + +import { + calculatePricePerPiece, + calculateFollowupBasePricePerPiece, + formatPrice, + calculateFollowupPricing, + calculateDirectShippingPrices, + formatEUR as fmtEUR, +} from "./configurator-pricing.js"; + +import BackendIntegration from "./configurator-backend-integration.js"; + +/* Persistenz */ +const LS_KEY = "skrift.configurator.v2"; +let hydratedForProductKey = null; + +// Globaler State-Tracker für Modals +let currentGlobalState = null; + +// Registry für Tabellen-Save-Funktionen (für flush vor Navigation) +const tableSaveRegistry = new Set(); + +/** + * Registriert eine Save-Funktion für eine Tabelle + * @param {Function} saveFn - Funktion zum Speichern der lokalen Änderungen + * @returns {Function} - Unregister-Funktion + */ +function registerTableSave(saveFn) { + tableSaveRegistry.add(saveFn); + return () => tableSaveRegistry.delete(saveFn); +} + +/** + * Speichert alle registrierten Tabellen (lokale Änderungen -> State) + * Wird vor Navigation und bei Page Leave aufgerufen + */ +export function flushAllTables() { + tableSaveRegistry.forEach((saveFn) => { + try { + saveFn(); + } catch (e) { + console.warn("[UI] Error flushing table:", e); + } + }); +} + +// Performante Persistenz: Nur periodisch + bei Page Leave +let persistDirty = false; +let persistInterval = null; +const PERSIST_INTERVAL_MS = 60000; // 1 Minute + +function markPersistDirty() { + persistDirty = true; +} + +function flushPersistIfDirty() { + if (persistDirty && currentGlobalState) { + writePersistedNow(currentGlobalState); + persistDirty = false; + } +} + +function startPersistInterval() { + if (persistInterval) return; + persistInterval = setInterval(flushPersistIfDirty, PERSIST_INTERVAL_MS); + + // Page Leave Detection: beforeunload, visibilitychange, pagehide + window.addEventListener("beforeunload", flushPersistIfDirty); + window.addEventListener("pagehide", flushPersistIfDirty); + document.addEventListener("visibilitychange", () => { + if (document.visibilityState === "hidden") { + flushPersistIfDirty(); + } + }); +} + +function stopPersistInterval() { + if (persistInterval) { + clearInterval(persistInterval); + persistInterval = null; + } +} + +// ========== Validierungs-Overlay und Overflow-Warnung ========== + +/** + * Zeigt Lade-Overlay während der Textvalidierung + */ +export function showValidationOverlay() { + // Entferne vorhandenes Overlay + hideValidationOverlay(); + + const overlay = document.createElement("div"); + overlay.id = "sk-validation-overlay"; + overlay.className = "sk-validation-overlay"; + overlay.innerHTML = ` +
+
Text wird geprüft...
+ `; + document.body.appendChild(overlay); +} + +/** + * Versteckt das Lade-Overlay + */ +export function hideValidationOverlay() { + const overlay = document.getElementById("sk-validation-overlay"); + if (overlay) { + overlay.remove(); + } +} + +/** + * Zeigt Overflow-Warnung mit betroffenen Briefen + * @param {Array} overflowFiles - Array von { index, lineCount, lineLimit } + * @param {HTMLElement} container - Container für die Warnung + */ +export function showOverflowWarning(overflowFiles, container) { + // Entferne vorherige Warnung + hideOverflowWarning(container); + + if (!overflowFiles || overflowFiles.length === 0) return; + + const warning = document.createElement("div"); + warning.className = "sk-overflow-warning"; + warning.id = "sk-overflow-warning"; + + // Max 10 Fehler anzeigen + const maxDisplay = 10; + const displayFiles = overflowFiles.slice(0, maxDisplay); + const remaining = overflowFiles.length - maxDisplay; + + const itemsHtml = displayFiles + .map((f) => { + // Zeige Tabellenzeile (index + 1 für 1-basierte Anzeige) + const rowNumber = f.index + 1; + return ` +
  • + Empfänger in Tabellenzeile ${rowNumber} + ${f.lineCount} / ${f.lineLimit} Zeilen +
  • + `; + }) + .join(""); + + // "und X weitere" Hinweis + const remainingHtml = + remaining > 0 + ? `
  • + ... und ${remaining} weitere Empfänger + +
  • ` + : ""; + + warning.innerHTML = ` +
    + Text zu lang für das gewählte Format +
    +
      + ${itemsHtml} + ${remainingHtml} +
    +

    + Bitte kürzen Sie den Text oder reduzieren Sie die Zeilenanzahl, um fortzufahren. + Die Zeilennummern beziehen sich auf die Empfänger-/Platzhaltertabelle. +

    + `; + + // Am Anfang des Containers einfügen + container.insertBefore(warning, container.firstChild); + + // Scroll zur Warnung + warning.scrollIntoView({ behavior: "smooth", block: "start" }); +} + +/** + * Versteckt die Overflow-Warnung + */ +export function hideOverflowWarning(container) { + const warning = container?.querySelector("#sk-overflow-warning"); + if (warning) { + warning.remove(); + } +} + +/** + * Zeigt eine Validierungs-Fehlermeldung an + * @param {string} message - Fehlermeldung + * @param {HTMLElement} container - Container für die Meldung + */ +export function showValidationError(message, container) { + // Entferne vorherige Fehlermeldungen + hideValidationError(container); + hideOverflowWarning(container); + + const errorDiv = document.createElement("div"); + errorDiv.className = "sk-validation-error"; + errorDiv.id = "sk-validation-error"; + + errorDiv.innerHTML = ` +
    + Validierung nicht möglich +
    +

    ${escapeHtml(message)}

    +

    + Bitte laden Sie die Seite neu und versuchen Sie es erneut. +

    + `; + + // Am Anfang des Containers einfügen + container.insertBefore(errorDiv, container.firstChild); + + // Scroll zur Meldung + errorDiv.scrollIntoView({ behavior: "smooth", block: "start" }); +} + +/** + * Versteckt die Validierungs-Fehlermeldung + */ +export function hideValidationError(container) { + const error = container?.querySelector("#sk-validation-error"); + if (error) { + error.remove(); + } +} + +/** + * HTML escaping für sichere Ausgabe + */ +function escapeHtml(text) { + const div = document.createElement("div"); + div.textContent = text; + return div.innerHTML; +} + +// Preview-Generierung Funktionen +// Alte Preview-Funktionen entfernt - jetzt wird PreviewManager verwendet + +// Preview-Funktionen verwenden jetzt PreviewManager + +function readPersisted() { + try { + const raw = localStorage.getItem(LS_KEY); + if (!raw) return null; + const data = JSON.parse(raw); + if (!data || typeof data !== "object") return null; + return data; + } catch { + return null; + } +} + +// Sofortige Speicherung (intern verwendet) +function writePersistedNow(state) { + try { + const productKey = state?.ctx?.product?.key || null; + // Auch ohne Produktauswahl speichern, wenn customerType gesetzt ist + if (!productKey && !state.answers.customerType) return; + + // Order-Daten OHNE AGB/Datenschutz speichern (müssen immer neu bestätigt werden) + const orderWithoutConsent = { ...(state.order || {}) }; + delete orderWithoutConsent.acceptedAgb; + delete orderWithoutConsent.acceptedPrivacy; + + const payload = { + productKey, + answers: state.answers || {}, + recipientRows: Array.isArray(state.recipientRows) + ? state.recipientRows + : [], + addressMode: state.addressMode || "classic", + freeAddressRows: Array.isArray(state.freeAddressRows) + ? state.freeAddressRows + : [], + placeholderValues: state.placeholderValues || {}, + order: orderWithoutConsent, + step: state.step || 0, + history: state.history || [], + }; + localStorage.setItem(LS_KEY, JSON.stringify(payload)); + } catch {} +} + +// Markiert State als dirty - Speicherung erfolgt periodisch oder bei Page Leave +function writePersisted(state) { + markPersistDirty(); +} + +function hydrateIfPossible(state, dispatch) { + // Nur EINMAL hydrieren - beim ersten Laden + if (hydratedForProductKey !== null) return false; + + // Flag SOFORT setzen um Mehrfachausführung zu verhindern + hydratedForProductKey = "done"; + + const productKey = state?.ctx?.product?.key || null; + const saved = readPersisted(); + + if (!saved) return false; + + // Wenn ein Produkt ausgewählt ist, nur hydrieren wenn es übereinstimmt + if (productKey && saved.productKey !== productKey) return false; + + // URL-Parameter haben Vorrang + const urlQuantity = state?.ctx?.quantity || null; + const urlFormat = state?.ctx?.format || null; + + // EINEN großen HYDRATE dispatch statt vieler kleiner + dispatch({ + type: "HYDRATE_ALL", + payload: { + productKey: !productKey && saved.productKey ? saved.productKey : null, + answers: saved.answers || {}, + recipientRows: saved.recipientRows || null, + addressMode: saved.addressMode || null, + freeAddressRows: saved.freeAddressRows || null, + placeholderValues: saved.placeholderValues || null, + order: saved.order || null, + step: saved.step, + urlQuantity, + urlFormat, + currentStep: state.step, + }, + }); + + return true; // Hydration wurde durchgeführt, neuer Render wurde ausgelöst +} + +/* DOM helper */ +function h(tag, attrs = {}, children = []) { + const el = document.createElement(tag); + + for (const [k, v] of Object.entries(attrs || {})) { + if (k === "class") { + el.className = v; + continue; + } + + if (k === "text") { + el.textContent = v; + continue; + } + + if (k.startsWith("on") && typeof v === "function") { + el.addEventListener(k.slice(2), v); + continue; + } + + if ( + k === "value" && + (tag === "input" || tag === "textarea" || tag === "select") + ) { + el.value = v ?? ""; + continue; + } + if (k === "checked" && tag === "input") { + el.checked = !!v; + continue; + } + if (k === "disabled") { + el.disabled = !!v; + continue; + } + if (k === "selected" && tag === "option") { + el.selected = !!v; + continue; + } + + if (v === true) el.setAttribute(k, ""); + else if (v !== false && v != null) el.setAttribute(k, String(v)); + } + + for (const c of children) + el.appendChild(typeof c === "string" ? document.createTextNode(c) : c); + return el; +} + +function clear(el) { + if (!el) return; + while (el.firstChild) el.removeChild(el.firstChild); +} + +/* Fokus Erhalt */ +function captureFocus(dom) { + const a = document.activeElement; + if (!a || !dom.form || !dom.form.contains(a)) return null; + + const key = a.getAttribute("data-sk-focus"); + if (!key) return null; + + const start = typeof a.selectionStart === "number" ? a.selectionStart : null; + const end = typeof a.selectionEnd === "number" ? a.selectionEnd : null; + + // Scroll-Position des Textfelds speichern + const scrollTop = typeof a.scrollTop === "number" ? a.scrollTop : null; + const scrollLeft = typeof a.scrollLeft === "number" ? a.scrollLeft : null; + + return { key, start, end, scrollTop, scrollLeft }; +} + +function restoreFocus(dom, snapshot) { + if (!snapshot) return; + + const el = dom.form.querySelector(`[data-sk-focus="${snapshot.key}"]`); + if (!el) return; + + el.focus({ preventScroll: true }); + + if ( + snapshot.start != null && + snapshot.end != null && + typeof el.setSelectionRange === "function" + ) { + try { + el.setSelectionRange(snapshot.start, snapshot.end); + } catch {} + } + + // Scroll-Position des Textfelds wiederherstellen + if (snapshot.scrollTop != null) { + el.scrollTop = snapshot.scrollTop; + } + if (snapshot.scrollLeft != null) { + el.scrollLeft = snapshot.scrollLeft; + } +} + +/* Utils */ +// fmtEUR is now imported from configurator-pricing.js + +function stepTitle(step) { + switch (step) { + case STEPS.PRODUCT: + return "Produkt"; + case STEPS.QUANTITY: + return "Allgemein"; + case STEPS.ENVELOPE: + return "Umschlag"; + case STEPS.CONTENT: + return "Inhalt"; + case STEPS.CUSTOMER_DATA: + return "Kundendaten"; + case STEPS.REVIEW: + return "Prüfen"; + default: + return ""; + } +} + +/** + * Rendert einen Link zu den Schriftbeispielen (öffnet in neuem Tab) + */ +function renderFontSampleLink() { + const fontSampleUrl = + window.SkriftConfigurator?.settings?.font_sample?.url || ""; + if (!fontSampleUrl) return null; + + return h("div", { class: "sk-help", style: "margin-top: 8px;" }, [ + h("a", { + href: fontSampleUrl, + target: "_blank", + rel: "noopener noreferrer", + style: "color: #0073aa; text-decoration: underline;", + text: "Schriftbeispiele ansehen →", + }), + ]); +} + +/** + * Rendert Platzhalter-Hilfetext mit optionalem Link zur Hilfeseite + * @param {string[]} hints - Zusätzliche Hinweise (optional) + */ +function renderPlaceholderHelpText(hints = []) { + const helpUrl = + window.SkriftConfigurator?.settings?.font_sample?.placeholder_help_url || + ""; + + const elements = [ + document.createTextNode( + "Sie können Platzhalter verwenden, um dynamische Inhalte einzufügen. Platzhalter werden in doppelten eckigen Klammern geschrieben (z.B. ", + ), + h("code", { text: "[[vorname]]" }), + document.createTextNode(" oder "), + h("code", { text: "[[firma]]" }), + document.createTextNode( + "). Diese werden später durch die tatsächlichen Werte ersetzt.", + ), + ]; + + // Hints hinzufügen + if (hints.length > 0) { + elements.push(document.createTextNode(" | " + hints.join(" | "))); + } + + // Link hinzufügen + if (helpUrl) { + elements.push(document.createTextNode(" ")); + elements.push( + h("a", { + href: helpUrl, + target: "_blank", + rel: "noopener noreferrer", + style: "color: #0073aa; text-decoration: underline;", + text: "Mehr erfahren →", + }), + ); + } + + return h("div", { class: "sk-help" }, elements); +} + +function renderCard(title, subtitle, bodyNodes = [], footerNodes = []) { + const card = h("div", { class: "sk-card" }, []); + + if (title) { + const head = h("div", { class: "sk-card-head" }, [ + h("h3", { class: "sk-card-title", text: title }), + ]); + if (subtitle) { + head.appendChild(h("div", { class: "sk-card-subtitle", text: subtitle })); + } + card.appendChild(head); + } + + card.appendChild(h("div", { class: "sk-card-body" }, bodyNodes)); + + if (footerNodes && footerNodes.length) { + card.appendChild(h("div", { class: "sk-card-foot" }, footerNodes)); + } + + return card; +} + +function renderStepper(stepperEl, state, dispatch) { + clear(stepperEl); + + const steps = [ + { id: STEPS.PRODUCT, label: "Produkt", icon: "📦" }, + { id: STEPS.QUANTITY, label: "Allgemein", icon: "⚙️" }, + { id: STEPS.ENVELOPE, label: "Umschlag", icon: "✉️" }, + { id: STEPS.CONTENT, label: "Inhalt", icon: "✍️" }, + { id: STEPS.CUSTOMER_DATA, label: "Kundendaten", icon: "📋" }, + { id: STEPS.REVIEW, label: "Prüfen", icon: "✓" }, + ]; + + const stepperContent = h("div", { class: "sk-stepper" }, []); + + for (let i = 0; i < steps.length; i++) { + const s = steps[i]; + const isActive = state.step === s.id; + const isComplete = s.id < state.step; + const canJumpBack = s.id <= state.step; + + // Button Content: Häkchen-Icon für erledigte Steps + const btnChildren = []; + if (isComplete) { + btnChildren.push(h("span", { class: "sk-chip-check", text: "✓" })); + } + btnChildren.push(s.label); + + const btn = h( + "button", + { + type: "button", + class: `sk-chip ${isActive ? "is-active" : ""} ${ + isComplete ? "is-complete" : "" + }`, + disabled: !canJumpBack, + onclick: () => dispatch({ type: "SET_STEP", step: s.id }), + }, + btnChildren, + ); + stepperContent.appendChild(btn); + + // Pfeil nach jedem Step außer dem letzten (dünner Chevron-Pfeil) + if (i < steps.length - 1) { + stepperContent.appendChild( + h("span", { class: "sk-stepper-arrow", text: "›" }), + ); + } + } + + // Stepper in Card integrieren (ohne Titel) + const card = renderCard(null, null, [stepperContent]); + stepperEl.appendChild(card); + + // Mobile: Auto-Scroll zum aktiven Step + requestAnimationFrame(() => { + const activeChip = stepperContent.querySelector(".sk-chip.is-active"); + if (activeChip && stepperContent) { + // Prüfe ob wir auf einem mobilen Gerät sind (stepper ist horizontal scrollbar) + const isMobile = window.innerWidth <= 640; + + if (isMobile) { + // Berechne die Scroll-Position um den aktiven Chip zu zentrieren + const containerWidth = stepperContent.offsetWidth; + const chipLeft = activeChip.offsetLeft; + const chipWidth = activeChip.offsetWidth; + const scrollLeft = chipLeft - containerWidth / 2 + chipWidth / 2; + + stepperContent.scrollTo({ + left: Math.max(0, scrollLeft), + behavior: "smooth", + }); + } else { + // Desktop: Standard scrollIntoView + activeChip.scrollIntoView({ + behavior: "smooth", + block: "nearest", + inline: "center", + }); + } + } + }); +} + +function renderTopbar(dom, state) { + clear(dom.topbar); + + const q = state.quote || {}; + const isB2B = state.answers?.customerType === "business"; + const isFU = isFollowups(state); + + let priceText = "bitte Produkt wählen"; + let priceNote = ""; + + if (isFU) { + // Follow-ups: Zeige "Ab-Preis" OHNE Multiplikator und OHNE Einmalkosten (nur pro Stück) + const basePrice = calculateFollowupBasePricePerPiece(state); + if (basePrice > 0) { + const formatted = formatPrice(basePrice, state); + priceText = "ab " + formatted.display; + priceNote = isB2B ? "zzgl. MwSt. pro Stück" : "inkl. MwSt. pro Stück"; + } else { + priceText = "ab auf Anfrage"; + priceNote = ""; + } + } else if (state.answers.quantity > 0 && state.ctx?.product) { + // Standard-Produkte: Zeige "ab" + Preis pro Stück + const product = state.ctx.product; + const prices = window.SkriftConfigurator?.settings?.prices || {}; + const taxRate = (prices.tax_rate || 19) / 100; + + // Im Product- und Quantity-Step: Nur dynamischer Mengenpreis (Basispreis × Multiplikator) + // Keine zusätzlichen Kosten (A4, Motiv, Versand) einberechnen + const isEarlyStep = + state.step === STEPS.PRODUCT || state.step === STEPS.QUANTITY; + + if (isEarlyStep) { + // Nur Basispreis × dynamische Mengenformel + const basePrice = product.basePrice || 0; + const dynamicPricing = + window.SkriftConfigurator?.settings?.dynamic_pricing || {}; + const formula = isB2B + ? dynamicPricing.business_formula || "1" + : dynamicPricing.private_formula || "1"; + + const qty = Number(state.answers.quantity) || 1; + const normalQty = isB2B + ? dynamicPricing.business_normal_quantity || 200 + : dynamicPricing.private_normal_quantity || 50; + const minQty = isB2B + ? dynamicPricing.business_min_quantity || 50 + : dynamicPricing.private_min_quantity || 10; + + // Formel auswerten + let multiplier = 1; + try { + const formulaWithValues = formula + .replace(/%qty%/g, qty) + .replace(/%norm_b%/g, dynamicPricing.business_normal_quantity || 200) + .replace(/%mind_b%/g, dynamicPricing.business_min_quantity || 50) + .replace(/%norm_p%/g, dynamicPricing.private_normal_quantity || 50) + .replace(/%mind_p%/g, dynamicPricing.private_min_quantity || 10); + multiplier = eval(formulaWithValues); + if (isNaN(multiplier) || multiplier < 1) multiplier = 1; + } catch (e) { + multiplier = 1; + } + + const priceNet = basePrice * multiplier; + const priceGross = priceNet * (1 + taxRate); + const displayPrice = isB2B + ? Math.round(priceNet * 100) / 100 + : Math.round(priceGross * 100) / 100; + + priceText = "ab " + fmtEUR(displayPrice); + priceNote = isB2B ? "zzgl. MwSt. pro Stück" : "inkl. MwSt. pro Stück"; + } else { + // Ab Envelope-Step: Vollständige Preisberechnung inkl. aller Optionen + // calculatePricePerPiece verwendet bereits immer den Inlandspreis + const pricePerPiece = calculatePricePerPiece(state); + + if (pricePerPiece > 0) { + const formatted = formatPrice(pricePerPiece, state); + priceText = "ab " + formatted.display; + priceNote = isB2B ? "zzgl. MwSt. pro Stück" : "inkl. MwSt. pro Stück"; + } + } + } + + const priceSection = h("div", { class: "sk-price" }, [ + h("div", { class: "sk-price-label", text: "Ihr Preis" }), + h("div", { + class: "sk-price-value", + text: priceText, + }), + h("div", { class: "sk-price-note", text: priceNote }), + ]); + + const productInfo = h("div", { class: "sk-product-info" }, []); + + if (state?.ctx?.product) { + productInfo.appendChild( + state.ctx.product.imageUrl + ? h("img", { + src: state.ctx.product.imageUrl, + alt: state.ctx.product.label, + class: "sk-product-icon sk-product-icon-img", + }) + : h("div", { + class: "sk-product-icon", + text: state.ctx.product.label[0], + }), + ); + productInfo.appendChild( + h("div", {}, [ + h("div", { class: "sk-product-label", text: state.ctx.product.label }), + h("div", { + class: "sk-text-small sk-text-muted", + text: stepTitle(state.step), + }), + ]), + ); + } + + dom.topbar.appendChild(priceSection); + dom.topbar.appendChild(productInfo); +} + +/* STEP RENDERERS */ + +// Step 1: Produkt (Kundentyp + Produktauswahl kombiniert) +function renderProductStep(state, dispatch) { + const blocks = []; + + // Kundentyp Auswahl + const customerOptions = [ + { + value: "business", + label: "Geschäftskunde", + desc: "Für Unternehmen, Selbstständige und gewerbliche Kunden", + icon: "🏢", + }, + { + value: "private", + label: "Privatkunde", + desc: "Für private Anlässe und persönliche Korrespondenz", + icon: "🏠", + }, + ]; + + const customerBody = customerOptions.map((opt) => + h( + "div", + { + class: `sk-option ${ + state.answers.customerType === opt.value ? "is-selected" : "" + }`, + onclick: () => + dispatch({ type: "SET_CUSTOMER_TYPE", customerType: opt.value }), + }, + [ + h("input", { + type: "radio", + name: "customerType", + checked: state.answers.customerType === opt.value, + onchange: () => {}, + }), + h("div", { class: "sk-option-content" }, [ + h("div", { + class: "sk-option-label", + text: `${opt.icon} ${opt.label}`, + }), + h("div", { class: "sk-option-desc", text: opt.desc }), + ]), + ], + ), + ); + + blocks.push( + renderCard( + "Als was möchten Sie einkaufen?", + "Wählen Sie Ihren Kundentyp aus", + [h("div", { class: "sk-options" }, customerBody)], + ), + ); + + // Produktauswahl (nur wenn Kundentyp gewählt) + if (state.answers.customerType) { + const products = getAvailableProductsForCustomerType( + state.answers.customerType, + ); + + const grid = h("div", { class: "sk-selection-grid" }, []); + for (const p of products) { + const isSelected = state?.ctx?.product?.key === p.key; + + // Preisberechnung für Anzeige: Nur Basispreis (ohne Versand, Optionen etc.) + let displayPrice = p.basePrice || 2.5; + + // MwSt. aufschlagen (nur für Privatkunden) + const isBusinessCustomer = state.answers?.customerType === "business"; + const prices = window.SkriftConfigurator?.settings?.prices || {}; + const taxRate = (prices.tax_rate || 19) / 100; + + // Business: Nettopreis, Privat: Bruttopreis + const finalPrice = isBusinessCustomer + ? displayPrice + : displayPrice * (1 + taxRate); + + const priceText = `ab ${fmtEUR(finalPrice)}`; + const descText = + p.description || "Professionelle handgeschriebene Korrespondenz"; + + const card = h( + "div", + { + class: `sk-selection-card ${isSelected ? "is-selected" : ""}`, + onclick: () => dispatch({ type: "SET_PRODUCT", product: p }), + }, + [ + h("div", { class: "sk-selection-card-image" }, [ + p.imageUrl + ? h("img", { + src: p.imageUrl, + alt: p.label, + class: "sk-selection-card-img", + }) + : h("div", { text: "📄", style: "font-size: 48px" }), + ]), + h("div", { class: "sk-selection-card-content" }, [ + h("div", { class: "sk-selection-card-title", text: p.label }), + h("div", { class: "sk-selection-card-price", text: priceText }), + h("div", { + class: "sk-selection-card-desc", + text: descText, + }), + ]), + ], + ); + grid.appendChild(card); + } + + blocks.push( + renderCard( + "Welches Produkt benötigen Sie?", + "Wählen Sie das passende Produkt für Ihren Bedarf", + [grid], + ), + ); + } + + return h("div", { class: "sk-stack" }, blocks); +} + +// Step 3: Menge & Format +function renderQuantityStep(state, dispatch) { + const blocks = []; + + // Mengenabfrage (für normale Produkte) + if (!isFollowups(state)) { + // Mindestmenge ermitteln (bei noLimits = 1) + const dynamicPricing = + window.SkriftConfigurator?.settings?.dynamic_pricing || {}; + const isB2B = state.answers?.customerType === "business"; + const noLimits = state.noLimits || false; + + const minQuantity = noLimits + ? 1 + : isB2B + ? dynamicPricing.business_min_quantity || 50 + : dynamicPricing.private_min_quantity || 10; + + const normalQuantity = isB2B + ? dynamicPricing.business_normal_quantity || 200 + : dynamicPricing.private_normal_quantity || 50; + + // Standardmenge auf beste Preismenge setzen (normalQuantity statt minQuantity) + const currentQty = state.answers.quantity || normalQuantity; + const isBelow = currentQty < minQuantity; + + // Erstelle Input-Element mit manuellem Value-Management + const inputEl = h("input", { + class: `sk-input ${isBelow ? "sk-input-error" : ""}`, + type: "number", + min: String(minQuantity), + "data-sk-focus": "quantity.amount", + onchange: (e) => { + // Update state nur bei change (Enter oder Blur) + const val = + e.target.value === "" ? minQuantity : Number(e.target.value); + const finalVal = isNaN(val) || val < minQuantity ? minQuantity : val; + e.target.value = String(finalVal); + // Nur dispatchen wenn sich der Wert geändert hat + if (finalVal !== state.answers.quantity) { + dispatch({ + type: "ANSWER", + patch: { quantity: finalVal }, + }); + } + }, + }); + + // Setze initialen Wert manuell + inputEl.value = String(currentQty); + + const quantityInput = [inputEl]; + + // Fehlermeldung bei Unterschreitung + if (isBelow) { + quantityInput.push( + h("div", { + class: "sk-error-message", + style: "color: #d32f2f; margin-top: 8px; font-size: 14px;", + text: `Mindestmenge: ${minQuantity} Stück`, + }), + ); + } + + // Beschreibungstext je nach noLimits-Modus + const quantityDescription = noLimits + ? `Unser bester Preis gilt ab ${normalQuantity} Stück` + : `Mindestmenge: ${minQuantity} Stück • Unser bester Preis gilt ab ${normalQuantity} Stück`; + + blocks.push( + renderCard( + "Wie viele Schriftstücke benötigen Sie?", + quantityDescription, + quantityInput, + ), + ); + } + + // Follow-ups: Jahresvolumen + if (isFollowups(state)) { + const volumes = [ + { value: "5-49", label: "5-49 Follow-ups pro Monat" }, + { value: "50-199", label: "50-199 Follow-ups pro Monat" }, + { value: "200-499", label: "200-499 Follow-ups pro Monat" }, + { value: "500-999", label: "500-999 Follow-ups pro Monat" }, + { value: "1000+", label: "1000+ Follow-ups pro Monat" }, + ]; + + const volBody = volumes.map((vol) => + h( + "div", + { + class: `sk-option ${ + state.answers.followupYearlyVolume === vol.value + ? "is-selected" + : "" + }`, + onclick: () => + dispatch({ + type: "ANSWER", + patch: { followupYearlyVolume: vol.value }, + }), + }, + [ + h("input", { + type: "radio", + name: "volume", + checked: state.answers.followupYearlyVolume === vol.value, + onchange: () => {}, + }), + h("div", { class: "sk-option-content" }, [ + h("div", { class: "sk-option-label", text: vol.label }), + ]), + ], + ), + ); + + blocks.push( + renderCard( + "Um wie viele Follow-ups geht es ca. pro Monat?", + "Dies hilft uns bei der Preiskalkulation", + [h("div", { class: "sk-options" }, volBody)], + ), + ); + } + + // Format Auswahl + const formats = state?.ctx?.product?.formats || []; + if (formats.length > 1) { + const prices = window.SkriftConfigurator?.settings?.prices || {}; + const a4Surcharge = prices.a4_upgrade_surcharge || 0; + const product = state?.ctx?.product; + + // B2C Brutto-Preis Hilfsfunktion + const isB2B = state.answers?.customerType === "business"; + const taxRate = (prices.tax_rate || 19) / 100; + const displayPrice = (nettoPrice) => + isB2B ? nettoPrice : nettoPrice * (1 + taxRate); + + // Prüfen ob A4 Aufpreis gilt (nur für Einladungen und Follow-ups) + const hasA4Surcharge = + (product?.key === "einladungen" || product?.key === "follow-ups") && + formats.includes("a4"); + + const formatMap = { + a4: { + label: "A4", + desc: "Standardformat für Briefe", + price: hasA4Surcharge ? `+ ${fmtEUR(displayPrice(a4Surcharge))}` : "", + }, + a6p: { + label: "A6 Hochformat", + desc: "Kompakt, ideal für Postkarten", + price: "", + }, + a6l: { + label: "A6 Querformat", + desc: "Kompakt, ideal für Postkarten", + price: "", + }, + }; + + const formatBody = formats.map((f) => { + const info = formatMap[f] || { label: f, desc: "", price: "" }; + return h( + "div", + { + class: `sk-option ${state.answers.format === f ? "is-selected" : ""}`, + onclick: () => dispatch({ type: "ANSWER", patch: { format: f } }), + }, + [ + h("input", { + type: "radio", + name: "format", + checked: state.answers.format === f, + onchange: () => {}, + }), + h( + "div", + { class: "sk-option-content" }, + [ + h("div", { class: "sk-option-label", text: info.label }), + h("div", { class: "sk-option-desc", text: info.desc }), + info.price + ? h("div", { class: "sk-option-price", text: info.price }) + : null, + ].filter(Boolean), + ), + ], + ); + }); + + blocks.push( + renderCard("Welches Format soll das Schriftstück haben?", null, [ + h("div", { class: "sk-options" }, formatBody), + ]), + ); + } + + // Follow-ups: Erstellungsmodus + if (isFollowups(state)) { + // API-Preis aus Backend holen + const prices = window.SkriftConfigurator?.settings?.prices || {}; + const apiPrice = prices.api_connection || 250; + + // B2C Brutto-Preis Hilfsfunktion + const isB2B = state.answers?.customerType === "business"; + const taxRate = (prices.tax_rate || 19) / 100; + const displayPrice = (nettoPrice) => + isB2B ? nettoPrice : nettoPrice * (1 + taxRate); + + const createModes = [ + { + value: "auto", + label: "Automatisch aus System", + desc: "Wir verbinden uns mit Ihrem CRM/Shop-System", + price: `Einmalig ${fmtEUR(displayPrice(apiPrice))}`, + }, + { + value: "manual", + label: "Manuell", + desc: "Sie senden uns die Empfängerliste im gewählten Rhythmus", + }, + ]; + + const createBody = createModes.map((mode) => + h( + "div", + { + class: `sk-option ${ + state.answers.followupCreateMode === mode.value ? "is-selected" : "" + }`, + onclick: () => + dispatch({ + type: "ANSWER", + patch: { followupCreateMode: mode.value }, + }), + }, + [ + h("input", { + type: "radio", + name: "createMode", + checked: state.answers.followupCreateMode === mode.value, + onchange: () => {}, + }), + h( + "div", + { class: "sk-option-content" }, + [ + h("div", { class: "sk-option-label", text: mode.label }), + h("div", { class: "sk-option-desc", text: mode.desc }), + mode.price + ? h("div", { class: "sk-option-price", text: mode.price }) + : null, + ].filter(Boolean), + ), + ], + ), + ); + + blocks.push( + renderCard("Wie sollen die Follow-ups erstellt werden?", null, [ + h("div", { class: "sk-options" }, createBody), + ]), + ); + + // Automatische Erstellung Details + if (state.answers.followupCreateMode === "auto") { + blocks.push( + renderCard("Details zur Systemanbindung", null, [ + h("div", { class: "sk-field" }, [ + h("label", { + class: "sk-field-label is-required", + text: "Aus welchem System?", + }), + h("input", { + class: "sk-input", + type: "text", + value: state.answers.followupSourceSystem || "", + "data-sk-focus": "followup.system", + placeholder: "z.B. Shopify, WooCommerce, Sevdesk, HubSpot", + oninput: (e) => + dispatch({ + type: "ANSWER", + patch: { followupSourceSystem: e.target.value }, + }), + }), + ]), + h("div", { class: "sk-field" }, [ + h("label", { + class: "sk-field-label is-required", + text: "Was soll die Erstellung eines Follow-ups auslösen?", + }), + h("textarea", { + class: "sk-textarea", + rows: "3", + value: state.answers.followupTriggerDescription || "", + "data-sk-focus": "followup.trigger", + placeholder: + "z.B. Wenn ein neuer Kundenauftrag eingeht oder der Kunde seit 3 Monaten nichts mehr bestellt hat.", + oninput: (e) => + dispatch({ + type: "ANSWER", + patch: { followupTriggerDescription: e.target.value }, + }), + }), + ]), + h("div", { class: "sk-field" }, [ + h("label", { class: "sk-field-label", text: "Prüfrhythmus" }), + h( + "select", + { + class: "sk-select", + value: state.answers.followupCheckCycle || "monthly", + onchange: (e) => + dispatch({ + type: "ANSWER", + patch: { followupCheckCycle: e.target.value }, + }), + }, + [ + h("option", { value: "monthly" }, ["Monatlich"]), + h("option", { value: "2m" }, ["Alle 2 Monate"]), + h("option", { value: "3m" }, ["Alle 3 Monate"]), + h("option", { value: "6m" }, ["Alle 6 Monate"]), + h("option", { value: "yearly" }, ["Jährlich"]), + ], + ), + ]), + ]), + ); + } + + // Info-Nachricht für Follow-ups (wird immer angezeigt) + blocks.push( + h("div", { class: "sk-alert sk-alert-info" }, [ + "Nach Ihrer Bestellung werden wir uns mit Ihnen in Verbindung setzen, um alle Details zu besprechen und die optimale Lösung für Ihre Follow-ups zu finden.", + ]), + ); + } + + return h("div", { class: "sk-stack" }, blocks); +} + +// Step 3: Umschlag (Versand + Umschlag kombiniert) +function renderEnvelopeStep(state, dispatch) { + const blocks = []; + + // Schriftart-Auswahl für Umschlag (ERSTE FRAGE - Dropdown) + blocks.push( + renderCard("Schriftart für Umschlag", null, [ + h( + "div", + { class: "sk-field" }, + [ + h( + "select", + { + class: "sk-select", + value: state.answers.envelopeFont || "tilda", + onchange: (e) => + dispatch({ + type: "ANSWER", + patch: { envelopeFont: e.target.value }, + }), + }, + [ + h("option", { + value: "tilda", + text: "Tilda - Druckschrift, leicht lesbar und steril", + selected: (state.answers.envelopeFont || "tilda") === "tilda", + }), + h("option", { + value: "alva", + text: "Alva - Druckschrift, organisch und informell", + selected: state.answers.envelopeFont === "alva", + }), + h("option", { + value: "ellie", + text: "Ellie - Schreibschrift, elegant und geschwungen", + selected: state.answers.envelopeFont === "ellie", + }), + ], + ), + renderFontSampleLink(), + ].filter(Boolean), + ), + ]), + ); + + // Preise aus Backend holen + const prices = window.SkriftConfigurator?.settings?.prices || {}; + const shippingBulk = prices.shipping_bulk || 4.95; + + // B2C Brutto-Preis Hilfsfunktion + const isB2B = state.answers?.customerType === "business"; + const taxRate = (prices.tax_rate || 19) / 100; + const displayPrice = (nettoPrice) => + isB2B ? nettoPrice : nettoPrice * (1 + taxRate); + + // Direktversand-Preise berechnen (Inland/Ausland) + const shippingPrices = calculateDirectShippingPrices(state); + const domesticPriceText = fmtEUR(displayPrice(shippingPrices.domesticPrice)); + const internationalPriceText = fmtEUR( + displayPrice(shippingPrices.internationalPrice), + ); + + // Versandart Auswahl + // Bei Follow-ups ist nur Direktversand möglich und kein Aufpreis wird angezeigt + const shippingOptions = isFollowups(state) + ? [ + { + value: "direct", + label: "Einzeln an die Empfänger", + desc: "Direktversand an Ihre Empfänger – inklusive Kuvertierung und Beschriftung.", + price: "", // Kein Aufpreis bei Follow-ups anzeigen + }, + ] + : [ + { + value: "direct", + label: "Einzeln an die Empfänger", + desc: "Wir versenden direkt an Ihre Empfänger – inklusive Kuvertierung und Beschriftung", + price: `Inland: ${domesticPriceText} / Ausland: ${internationalPriceText} pro Stück`, + }, + { + value: "bulk", + label: "Sammelversand an Sie", + desc: "Sie erhalten alle Schriftstücke zur eigenen Verteilung", + price: `Einmalig ${fmtEUR(displayPrice(shippingBulk))} Versandkosten`, + }, + ]; + + const shippingBody = shippingOptions.map((opt) => + h( + "div", + { + class: `sk-option ${ + state.answers.shippingMode === opt.value ? "is-selected" : "" + }`, + onclick: () => + dispatch({ type: "ANSWER", patch: { shippingMode: opt.value } }), + }, + [ + h("input", { + type: "radio", + name: "shipping", + checked: state.answers.shippingMode === opt.value, + onchange: () => {}, + }), + h("div", { class: "sk-option-content" }, [ + h("div", { class: "sk-option-label", text: opt.label }), + h("div", { class: "sk-option-desc", text: opt.desc }), + h("div", { class: "sk-option-price", text: opt.price }), + ]), + ], + ), + ); + + blocks.push( + renderCard( + "Wie sollen die Schriftstücke versendet werden?", + "Wählen Sie die passende Versandart", + [h("div", { class: "sk-options" }, shippingBody)], + ), + ); + + // Umschlag-Optionen (nur wenn Versandart gewählt) + if (!state.answers.shippingMode) { + return h("div", { class: "sk-stack" }, blocks); + } + + // Bei Direktversand automatisch Umschlag + if (state.answers.shippingMode === "direct") { + blocks.push( + h("div", { class: "sk-alert sk-alert-info" }, [ + "Bei Direktversand an Empfänger wird automatisch ein beschrifteter Umschlag verwendet.", + ]), + ); + } else { + // Bulk: Umschlag optional + const envelopeBase = prices.envelope_base || 0.5; + const envOptions = [ + { + value: true, + label: "Ja, ich wünsche ein Kuvert", + price: `+ ${fmtEUR(displayPrice(envelopeBase))}`, + }, + { value: false, label: "Nein, ich benötige kein Kuvert", price: "" }, + ]; + + const envBody = envOptions.map((opt) => + h( + "div", + { + class: `sk-option ${ + state.answers.envelope === opt.value ? "is-selected" : "" + }`, + onclick: () => + dispatch({ type: "ANSWER", patch: { envelope: opt.value } }), + }, + [ + h("input", { + type: "radio", + name: "envelope", + checked: state.answers.envelope === opt.value, + onchange: () => {}, + }), + h( + "div", + { class: "sk-option-content" }, + [ + h("div", { class: "sk-option-label", text: opt.label }), + opt.price + ? h("div", { class: "sk-option-price", text: opt.price }) + : null, + ].filter(Boolean), + ), + ], + ), + ); + + blocks.push( + renderCard("Benötigen Sie ein Kuvert?", null, [ + h("div", { class: "sk-options" }, envBody), + ]), + ); + } + + // Beschriftungsmodus + // Bei Direktversand: Direkt Empfängerdaten-Tabelle zeigen, keine Auswahl + if (state.answers.shippingMode === "direct") { + // envelopeMode wird automatisch durch den Reducer auf "recipientData" gesetzt + // wenn shippingMode === "direct" (siehe configurator-state.js Zeile 486-492) + + // Bei Follow-ups: Unterscheidung zwischen auto und manual + const isFollowupsProduct = state?.ctx?.product?.isFollowUp === true; + if (!isFollowupsProduct) { + // Normale Produkte: Empfängerdaten-Tabelle anzeigen + blocks.push(renderRecipientsTable(state, dispatch)); + } else if (state.answers.followupCreateMode === "auto") { + // Follow-ups mit automatischer Erstellung: Daten kommen aus System + blocks.push( + renderCard("Empfängerdaten", null, [ + h("div", { class: "sk-alert sk-alert-info" }, [ + "Die Empfängerdaten werden automatisch aus Ihrem System übernommen. " + + "Im Rahmen der Einrichtung werden wir gemeinsam die Schnittstelle zu Ihrem System konfigurieren.", + ]), + ]), + ); + } else { + // Follow-ups mit manueller Erstellung: Daten werden später übermittelt + blocks.push( + renderCard("Empfängerdaten", null, [ + h("div", { class: "sk-alert sk-alert-info" }, [ + "Im Nachgang wird Ihnen eine E-Mail zugesendet mit weiteren Angaben, " + + "wohin Sie die Empfängerdaten hochladen können.", + ]), + ]), + ); + } + } else if (state.answers.envelope === true) { + // Bei Bulk-Versand mit Umschlag: Beschriftungsmodus-Auswahl anzeigen + // Neues Feld: envelope_labeling, Fallback auf alte Felder + const envelopeLabeling = + prices.envelope_labeling || prices.envelope_recipient_address || 0.5; + + const modeOptions = [ + { + value: "recipientData", + label: "Empfängerdaten", + desc: "Klassische Adressierung mit Name und Anschrift", + price: `+ ${fmtEUR(displayPrice(envelopeLabeling))}`, + }, + { + value: "customText", + label: "Individueller Text", + desc: "Freier Text mit Platzhaltern", + price: `+ ${fmtEUR(displayPrice(envelopeLabeling))}`, + }, + { + value: "none", + label: "Keine Beschriftung", + desc: "Umschlag bleibt unbeschriftet", + price: "", + }, + ]; + + const modeBody = modeOptions.map((opt) => + h( + "div", + { + class: `sk-option ${ + state.answers.envelopeMode === opt.value ? "is-selected" : "" + }`, + onclick: () => + dispatch({ type: "ANSWER", patch: { envelopeMode: opt.value } }), + }, + [ + h("input", { + type: "radio", + name: "envelopeMode", + checked: state.answers.envelopeMode === opt.value, + onchange: () => {}, + }), + h( + "div", + { class: "sk-option-content" }, + [ + h("div", { class: "sk-option-label", text: opt.label }), + h("div", { class: "sk-option-desc", text: opt.desc }), + opt.price + ? h("div", { class: "sk-option-price", text: opt.price }) + : null, + ].filter(Boolean), + ), + ], + ), + ); + + blocks.push( + renderCard("Womit soll das Kuvert beschriftet werden?", null, [ + h("div", { class: "sk-options" }, modeBody), + ]), + ); + + // Empfängerdaten Tabelle (nur wenn gewählt) + if (state.answers.envelopeMode === "recipientData") { + blocks.push(renderRecipientsTable(state, dispatch)); + } + + // Custom Text + if (state.answers.envelopeMode === "customText") { + blocks.push( + renderCard( + "Text für Umschlag", + "Max. 160 Zeichen, keine Zeilenumbrüche", + [ + h("input", { + type: "text", + class: "sk-input", + maxlength: "160", + value: state.answers.envelopeCustomText || "", + "data-sk-focus": "envelope.customText", + placeholder: "z.B. Herzliche Grüße an [[vorname]] [[nachname]]", + oninput: (() => { + let debounceTimer = null; + return (e) => { + // Zeilenumbrüche entfernen (falls durch Paste eingefügt) + const value = e.target.value.replace(/[\r\n]/g, " "); + e.target.value = value; + // Debounced dispatch (300ms) + if (debounceTimer) clearTimeout(debounceTimer); + debounceTimer = setTimeout(() => { + dispatch({ + type: "ANSWER", + patch: { envelopeCustomText: value }, + }); + }, 300); + }; + })(), + }), + renderPlaceholderHelpText(), + ], + ), + ); + + const ph = extractPlaceholders(state.answers.envelopeCustomText); + if (ph.length > 0) { + blocks.push( + renderPlaceholderTable(state, dispatch, ph, "Platzhalter-Werte"), + ); + } + } + } + + return h("div", { class: "sk-stack" }, blocks); +} + +// Step 6: Inhalt +function renderContentStep(state, dispatch) { + const blocks = []; + + // Schriftart-Auswahl für Schriftstück (ERSTE FRAGE - Dropdown) + blocks.push( + renderCard("Schriftart für Schriftstück", null, [ + h( + "div", + { class: "sk-field" }, + [ + h( + "select", + { + class: "sk-select", + value: state.answers.font || "tilda", + onchange: (e) => + dispatch({ + type: "ANSWER", + patch: { font: e.target.value }, + }), + }, + [ + h("option", { + value: "tilda", + text: "Tilda - Druckschrift, leicht lesbar und steril", + selected: (state.answers.font || "tilda") === "tilda", + }), + h("option", { + value: "alva", + text: "Alva - Druckschrift, organisch und informell", + selected: state.answers.font === "alva", + }), + h("option", { + value: "ellie", + text: "Ellie - Schreibschrift, elegant und geschwungen", + selected: state.answers.font === "ellie", + }), + ], + ), + h("div", { + class: `sk-option ${state.answers.realisticHandwriting ? "is-selected" : ""}`, + style: "margin-top:12px;", + onclick: () => + dispatch({ + type: "ANSWER", + patch: { realisticHandwriting: !(state.answers.realisticHandwriting ?? false) }, + }), + }, [ + h("input", { + type: "checkbox", + checked: state.answers.realisticHandwriting ?? false, + onchange: () => {}, + }), + h("div", { class: "sk-option-content" }, [ + h("div", { class: "sk-option-label", text: "Realistische Handschrift aktivieren" }), + h("div", { class: "sk-option-desc", text: "Buchstaben neigen sich leicht, die Baseline variiert und Zeilen haben eine natürliche Neigung — wie beim echten Schreiben." }), + ]), + ]), + renderFontSampleLink(), + ].filter(Boolean), + ), + ]), + ); + + // Texterstellung + // Preise aus Backend holen + const prices = window.SkriftConfigurator?.settings?.prices || {}; + const textservicePrice = prices.textservice || 0; + + // B2C Brutto-Preis Hilfsfunktion + const isB2B = state.answers?.customerType === "business"; + const taxRate = (prices.tax_rate || 19) / 100; + const displayPrice = (nettoPrice) => + isB2B ? nettoPrice : nettoPrice * (1 + taxRate); + + const contentOptions = [ + { + value: "self", + label: "Ich erstelle den Text selbst", + desc: "Sie geben den vollständigen Text vor", + }, + { + value: "textservice", + label: "Textservice beauftragen", + desc: "Wir erstellen den Text professionell für Sie", + price: + textservicePrice > 0 + ? `Einmalig ${fmtEUR(displayPrice(textservicePrice))}` + : "", + }, + ]; + + const contentBody = contentOptions.map((opt) => + h( + "div", + { + class: `sk-option ${ + state.answers.contentCreateMode === opt.value ? "is-selected" : "" + }`, + onclick: () => + dispatch({ type: "ANSWER", patch: { contentCreateMode: opt.value } }), + }, + [ + h("input", { + type: "radio", + name: "contentMode", + checked: state.answers.contentCreateMode === opt.value, + onchange: () => {}, + }), + h( + "div", + { class: "sk-option-content" }, + [ + h("div", { class: "sk-option-label", text: opt.label }), + h("div", { class: "sk-option-desc", text: opt.desc }), + opt.price + ? h("div", { class: "sk-option-price", text: opt.price }) + : null, + ].filter(Boolean), + ), + ], + ), + ); + + blocks.push( + renderCard("Möchten Sie den Inhalt selbst erstellen?", null, [ + h("div", { class: "sk-options" }, contentBody), + ]), + ); + + // Textfeld (wenn selbst erstellt) + if (state.answers.contentCreateMode === "self") { + const maxChars = state.answers.format === "a4" ? 2500 : 500; + const hints = []; + + if (state.answers.envelopeMode === "recipientData") { + hints.push("Verfügbare Platzhalter: [[vorname]], [[name]]"); + } else { + const phEnv = state.placeholders?.envelope || []; + if (phEnv.length > 0) { + hints.push( + `Umschlag-Platzhalter: ${phEnv.map((p) => `[[${p}]]`).join(", ")}`, + ); + } + } + + blocks.push( + renderCard( + "Inhalt des Schriftstücks", + `Max. ${maxChars} Zeichen`, + [ + h("textarea", { + class: "sk-textarea", + rows: "10", + maxlength: String(maxChars), + value: state.answers.letterText || "", + "data-sk-focus": "content.text", + placeholder: + "Ihr Text hier. Nutzen Sie [[Platzhalter]] für individuelle Inhalte.", + oninput: (() => { + let debounceTimer = null; + return (e) => { + const value = e.target.value; + // Debounced dispatch (300ms) + if (debounceTimer) clearTimeout(debounceTimer); + debounceTimer = setTimeout(() => { + dispatch({ + type: "ANSWER", + patch: { letterText: value }, + }); + }, 300); + }; + })(), + }), + renderPlaceholderHelpText(hints), + ].filter(Boolean), + ), + ); + + // Platzhalter Verarbeitung + const usedInLetter = extractPlaceholders(state.answers.letterText); + const envPlaceholders = state.placeholders?.envelope || []; + const envSet = new Set(envPlaceholders); + + // Bei Follow-ups: Keine Platzhalter-Tabelle anzeigen + // (Empfängerdaten kommen aus CRM, Platzhalter werden dort verwaltet) + if (isFollowups(state)) { + // Platzhalter-Tabelle für Follow-ups ausgeblendet + } else { + // Prüfen ob Empfängerdaten für Platzhalter verfügbar sind: + // - envelopeMode muss "recipientData" sein + // - addressMode muss "classic" sein (nicht "free") + // - envelope darf nicht false sein (kein Kuvert) + const hasClassicRecipientData = + state.answers.envelopeMode === "recipientData" && + (state.addressMode || "classic") === "classic" && + state.answers.envelope !== false; + + if (hasClassicRecipientData) { + // Bei klassischen Empfängerdaten: vorname, name, ort sind verfügbar + neue eigene Platzhalter + const predefinedRecipient = new Set(["vorname", "name", "ort"]); + const newPlaceholders = usedInLetter.filter( + (p) => !predefinedRecipient.has(p), + ); + + // Kombinierte Tabelle für reguläre Produkte + blocks.push( + renderCombinedPlaceholderTable( + state, + dispatch, + Array.from(predefinedRecipient), + newPlaceholders, + "Platzhalter-Werte", + ), + ); + } else if (state.answers.envelopeMode === "customText") { + // Bei individuellem Text: Alle Umschlag-Platzhalter verfügbar + neue eigene Platzhalter + const newPlaceholders = usedInLetter.filter((p) => !envSet.has(p)); + + // Kombinierte Tabelle: Umschlag-Platzhalter (read-only) + neue (editierbar) + if (envPlaceholders.length > 0 || newPlaceholders.length > 0) { + blocks.push( + renderCombinedPlaceholderTable( + state, + dispatch, + envPlaceholders, + newPlaceholders, + "Platzhalter-Werte", + ), + ); + } + } else { + // Kein Kuvert oder freie Adresse: Nur eigene Platzhalter anzeigen (ohne vorname, name, ort) + if (usedInLetter.length > 0) { + blocks.push( + renderPlaceholderTable( + state, + dispatch, + usedInLetter, + "Platzhalter-Werte", + false, + ), + ); + } + } + } + } else if (state.answers.contentCreateMode === "textservice") { + blocks.push( + h("div", { class: "sk-alert sk-alert-info" }, [ + "Unser Textservice erstellt professionelle, individuell auf Ihre Bedürfnisse zugeschnittene Texte. " + + "Nach der Bestellung werden wir Sie kontaktieren, um Details zu besprechen.", + ]), + ); + } + + // Motiv (nur bei Postkarten, oder bei Einladungen/Follow-ups wenn A6 Format) + if (supportsMotif(state)) { + const motifOptions = [ + { value: true, label: "Ja, ich möchte ein Motiv" }, + { value: false, label: "Nein, kein Motiv" }, + ]; + + const motifBody = motifOptions.map((opt) => + h( + "div", + { + class: `sk-option ${ + state.answers.motifNeed === opt.value ? "is-selected" : "" + }`, + onclick: () => + dispatch({ type: "ANSWER", patch: { motifNeed: opt.value } }), + }, + [ + h("input", { + type: "radio", + name: "motif", + checked: state.answers.motifNeed === opt.value, + onchange: () => {}, + }), + h("div", { class: "sk-option-content" }, [ + h("div", { class: "sk-option-label", text: opt.label }), + ]), + ], + ), + ); + + blocks.push( + renderCard( + "Soll ein Motiv auf der Vorderseite abgebildet werden?", + null, + [h("div", { class: "sk-options" }, motifBody)], + ), + ); + + // Motiv Quelle + if (state.answers.motifNeed === true) { + const motifUploadPrice = prices.motif_upload || 0.3; + const motifDesignPrice = prices.motif_design || 0; + + // Bei A4-Format ist Upload nicht möglich, nur bedruckte Karten + const isA4 = state.answers.format === "a4"; + + const sourceOptions = [ + !isA4 && { + value: "upload", + label: "Eigenes Motiv hochladen", + desc: "Sie laden Ihre Datei hoch", + price: + motifUploadPrice > 0 + ? `Einmalig ${fmtEUR(displayPrice(motifUploadPrice))}` + : "", + }, + { + value: "printed", + label: "Bedruckte Karten zusenden", + desc: isA4 + ? "Sie senden uns fertig bedruckte A4-Karten" + : "Sie senden uns fertig bedruckte A6-Karten", + price: "", + }, + !isA4 && { + value: "design", + label: "Designservice beauftragen", + desc: "Wir erstellen ein individuelles Design", + price: + motifDesignPrice > 0 + ? `Einmalig ${fmtEUR(displayPrice(motifDesignPrice))}` + : "", + }, + ].filter(Boolean); + + const sourceBody = sourceOptions.map((opt) => + h( + "div", + { + class: `sk-option ${ + state.answers.motifSource === opt.value ? "is-selected" : "" + }`, + onclick: () => + dispatch({ type: "ANSWER", patch: { motifSource: opt.value } }), + }, + [ + h("input", { + type: "radio", + name: "motifSource", + checked: state.answers.motifSource === opt.value, + onchange: () => {}, + }), + h( + "div", + { class: "sk-option-content" }, + [ + h("div", { class: "sk-option-label", text: opt.label }), + h("div", { class: "sk-option-desc", text: opt.desc }), + opt.price + ? h("div", { class: "sk-option-price", text: opt.price }) + : null, + ].filter(Boolean), + ), + ], + ), + ); + + blocks.push( + renderCard("Woher kommt das Motiv?", null, [ + h("div", { class: "sk-options" }, sourceBody), + ]), + ); + + // Upload + if (state.answers.motifSource === "upload") { + blocks.push( + renderCard( + "Motiv hochladen", + "Erlaubt: SVG, PDF, PNG, JPG, WEBP im A6-Format", + [ + h("input", { + class: "sk-input", + type: "file", + accept: ".svg,.pdf,image/*", + "data-sk-focus": "motif.upload", + onchange: (e) => { + const f = e.target.files && e.target.files[0]; + if (!f) return; + // Datei global speichern für späteren Upload + window.motifFileToUpload = f; + dispatch({ + type: "ANSWER", + patch: { + motifFileName: f.name, + motifFileMeta: { + name: f.name, + size: f.size, + type: f.type, + }, + }, + }); + }, + }), + state.answers.motifFileName + ? h("div", { + class: "sk-help", + text: `Ausgewählt: ${state.answers.motifFileName}`, + }) + : null, + ].filter(Boolean), + ), + ); + } + + // Info-Nachricht für "Bedruckte Karten zusenden" + if (state.answers.motifSource === "printed") { + blocks.push( + h("div", { class: "sk-alert sk-alert-info" }, [ + "Alle weiteren Informationen, wohin die bedruckten Karten gesendet werden sollen, erhalten Sie danach per E-Mail.", + ]), + ); + } + + // Info-Nachricht für "Designservice" + if (state.answers.motifSource === "design") { + blocks.push( + h("div", { class: "sk-alert sk-alert-info" }, [ + "Unser Designservice erstellt ein individuelles Design, das perfekt auf Ihre Bedürfnisse zugeschnitten ist. " + + "Nach der Bestellung werden wir Sie kontaktieren, um Ihre Vorstellungen und Wünsche im Detail zu besprechen.", + ]), + ); + } + } + } + + return h("div", { class: "sk-stack" }, blocks); +} + +// Step 7: Kundendaten +function renderCustomerDataStep(state, dispatch) { + const blocks = []; + const b = state.order.billing; + + const fields = [ + { key: "firstName", label: "Vorname", required: true, type: "text" }, + { key: "lastName", label: "Nachname", required: true, type: "text" }, + ]; + + if (state.answers.customerType === "business") { + fields.push({ + key: "company", + label: "Unternehmensname", + required: true, + type: "text", + }); + } + + // E-Mail und Telefon (Pflichtfelder) + fields.push( + { key: "email", label: "E-Mail-Adresse", required: true, type: "email" }, + { key: "phone", label: "Telefonnummer", required: true, type: "tel" }, + ); + + fields.push( + { + key: "street", + label: "Straße und Hausnummer", + required: true, + type: "text", + }, + { key: "zip", label: "PLZ", required: true, type: "text" }, + { key: "city", label: "Stadt", required: true, type: "text" }, + ); + + const billingFields = fields.map((f) => + h("div", { class: "sk-field" }, [ + h("label", { + class: `sk-field-label ${f.required ? "is-required" : ""}`, + text: f.label, + }), + h("input", { + class: "sk-input", + type: f.type, + value: b[f.key] || "", + "data-sk-focus": `billing.${f.key}`, + ...(f.key === "zip" ? { pattern: "[0-9]*", inputmode: "numeric" } : {}), + oninput: (e) => { + let value = e.target.value; + // PLZ: Nur Zahlen erlauben + if (f.key === "zip") { + value = value.replace(/[^0-9]/g, ""); + e.target.value = value; + } + dispatch({ + type: "SET_ORDER_BILLING", + patch: { [f.key]: value }, + }); + }, + }), + ]), + ); + + billingFields.push( + h("div", { class: "sk-field" }, [ + h("label", { class: "sk-field-label is-required", text: "Land" }), + h( + "select", + { + class: "sk-select", + value: b.country || "Deutschland", + "data-sk-focus": "billing.country", + onchange: (e) => + dispatch({ + type: "SET_ORDER_BILLING", + patch: { country: e.target.value }, + }), + }, + [ + h("option", { value: "Deutschland" }, ["Deutschland"]), + h("option", { value: "Luxemburg" }, ["Luxemburg"]), + h("option", { value: "Frankreich" }, ["Frankreich"]), + h("option", { value: "Österreich" }, ["Österreich"]), + ], + ), + ]), + ); + + blocks.push(renderCard("Rechnungsdaten", null, billingFields)); + + // Alternative Lieferadresse + blocks.push( + renderCard("Lieferadresse", null, [ + h("label", { class: "sk-option", onclick: (e) => e.stopPropagation() }, [ + h("input", { + type: "checkbox", + checked: !!state.order.shippingDifferent, + onclick: (e) => e.stopPropagation(), + onchange: (e) => { + e.stopPropagation(); + dispatch({ + type: "SET_ORDER", + patch: { shippingDifferent: e.target.checked }, + }); + }, + }), + h("div", { class: "sk-option-content" }, [ + h("div", { + class: "sk-option-label", + text: "Alternative Lieferadresse angeben", + }), + ]), + ]), + ]), + ); + + if (state.order.shippingDifferent) { + const sh = state.order.shipping; + const shippingFields = fields.map((f) => + h("div", { class: "sk-field" }, [ + h("label", { + class: `sk-field-label ${f.required ? "is-required" : ""}`, + text: f.label, + }), + h("input", { + class: "sk-input", + type: f.type, + value: sh[f.key] || "", + "data-sk-focus": `shipping.${f.key}`, + ...(f.key === "zip" + ? { pattern: "[0-9]*", inputmode: "numeric" } + : {}), + oninput: (e) => { + let value = e.target.value; + // PLZ: Nur Zahlen erlauben + if (f.key === "zip") { + value = value.replace(/[^0-9]/g, ""); + e.target.value = value; + } + dispatch({ + type: "SET_ORDER_SHIPPING", + patch: { [f.key]: value }, + }); + }, + }), + ]), + ); + + shippingFields.push( + h("div", { class: "sk-field" }, [ + h("label", { class: "sk-field-label is-required", text: "Land" }), + h( + "select", + { + class: "sk-select", + value: sh.country || "Deutschland", + "data-sk-focus": "shipping.country", + onchange: (e) => + dispatch({ + type: "SET_ORDER_SHIPPING", + patch: { country: e.target.value }, + }), + }, + [ + h("option", { value: "Deutschland" }, ["Deutschland"]), + h("option", { value: "Luxemburg" }, ["Luxemburg"]), + h("option", { value: "Frankreich" }, ["Frankreich"]), + h("option", { value: "Österreich" }, ["Österreich"]), + ], + ), + ]), + ); + + blocks.push(renderCard("Alternative Lieferadresse", null, shippingFields)); + } + + return h("div", { class: "sk-stack" }, blocks); +} + +// Motiv hochladen wenn vorhanden (vor Bestellabschluss) +async function uploadMotifIfNeeded(state, orderNumber) { + const api = window.SkriftBackendAPI; + if ( + state.answers.motifSource === "upload" && + window.motifFileToUpload && + api + ) { + try { + console.log("[Order] Uploading motif file..."); + const motifResult = await api.uploadMotif( + window.motifFileToUpload, + orderNumber, + ); + if (motifResult.success) { + console.log("[Order] Motif uploaded:", motifResult.url); + return motifResult.url; + } else { + console.error("[Order] Motif upload failed:", motifResult.error); + } + } catch (error) { + console.error("[Order] Motif upload error:", error); + } + } + return null; +} + +// Webhook-Aufruf nach Bestellung +async function handleOrderSubmit(state) { + const isB2B = state.answers?.customerType === "business"; + const backend = window.SkriftConfigurator?.settings?.backend_connection || {}; + const webhookUrlBusiness = backend.webhook_url_business; + const webhookUrlPrivate = backend.webhook_url_private; + const redirectUrlBusiness = backend.redirect_url_business; + const redirectUrlPrivate = backend.redirect_url_private; + + // Bestellnummer generieren (fortlaufend vom WP-Backend) + const api = window.SkriftBackendAPI; + const orderNumber = api ? await api.generateOrderNumber() : `S-${Date.now()}`; + + // Motiv hochladen wenn vorhanden + const motifUrl = await uploadMotifIfNeeded(state, orderNumber); + if (motifUrl) { + state.answers.motifFileUrl = motifUrl; + } + + // Gutschein als verwendet markieren (falls vorhanden) + const voucherCode = state.order?.voucherStatus?.valid + ? state.order.voucherCode + : null; + if (voucherCode) { + try { + const restUrl = window.SkriftConfigurator?.restUrl || "/wp-json/"; + await fetch(restUrl + "skrift/v1/voucher/use", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ code: voucherCode }), + }); + } catch (error) { + // Fehler beim Markieren des Gutscheins - trotzdem fortfahren + } + } + + // Bestellung finalisieren: Preview-Dateien aus Cache in Output-Ordner kopieren + let finalizeResult = null; + if (api && api.sessionId) { + try { + finalizeResult = await api.finalizeOrder(api.sessionId, orderNumber, { + customer: state.order, + quote: state.quote, + letterFormat: { a4: "A4", a6p: "A6 Hochformat", a6l: "A6 Querformat" }[state.answers.format] || state.answers.format || null, + envelopeFormat: state.answers.format === "a4" ? "DIN Lang" : (state.answers.format === "a6p" || state.answers.format === "a6l") ? "C6" : null, + envelopeBeschriftung: { recipientData: "Empfängeradresse", customText: "Individueller Text", none: "Keine Beschriftung" }[state.answers.envelopeMode] || state.answers.envelopeMode || null, + }); + console.log("[Order] Finalize result:", finalizeResult); + } catch (error) { + console.error("[Order] Finalize error:", error); + } + } else { + console.warn("[Order] Cannot finalize - no API or session available"); + } + + // Für Business: Webhook sofort aufrufen + // Für Privat: Erst PayPal-Zahlung, dann Webhook + if (isB2B) { + // Business-Kunde: Webhook aufrufen und dann weiterleiten + if (webhookUrlBusiness) { + try { + const orderData = { + order_number: orderNumber, + customer_type: state.answers.customerType, + product: state.ctx?.product?.key, + quantity: state.answers.quantity, + format: state.answers.format, + shipping_mode: state.answers.shippingMode, + envelope: state.answers.envelope, + envelope_mode: state.answers.envelopeMode, + customer_data: state.order, + quote: state.quote, + timestamp: new Date().toISOString(), + finalize_result: finalizeResult, + }; + + const response = await fetch(webhookUrlBusiness, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(orderData), + }); + + if (!response.ok) { + alert( + "Bestellung wurde aufgenommen, aber es gab ein Problem bei der Übermittlung. Bitte kontaktieren Sie uns.", + ); + } + } catch (error) { + alert( + "Bestellung wurde aufgenommen, aber es gab ein Problem bei der Übermittlung. Bitte kontaktieren Sie uns.", + ); + } + } + + // Weiterleitung für Business-Kunden + if (redirectUrlBusiness) { + window.location.href = redirectUrlBusiness; + } else { + alert( + "Vielen Dank für Ihre Bestellung! Sie erhalten in Kürze eine Bestätigungs-E-Mail mit allen Details.", + ); + } + } else { + // Privatkunde: Ohne PayPal direkt zur Privat-URL weiterleiten + if (redirectUrlPrivate) { + window.location.href = redirectUrlPrivate; + } else { + alert( + "Weiterleitung zu PayPal folgt - Integration wird vorbereitet.\n\nNach erfolgreicher Zahlung wird der Webhook aufgerufen.", + ); + } + } +} + +// Hilfsfunktion: Gutschein validieren +function validateVoucherCode(voucherCode, dispatch) { + const code = voucherCode.trim(); + + if (!code) { + dispatch({ + type: "SET_ORDER", + patch: { voucherStatus: { valid: false, error: "Bitte Code eingeben" } }, + }); + return; + } + + const vouchers = window.SkriftConfigurator?.vouchers || {}; + const voucher = vouchers[code]; + + if (!voucher) { + dispatch({ + type: "SET_ORDER", + patch: { + voucherStatus: { valid: false, error: "Gutschein nicht gefunden" }, + }, + }); + return; + } + + if (voucher.expiry_date) { + // Vergleiche nur das Datum, nicht die Uhrzeit + const expiryDate = new Date(voucher.expiry_date); + const today = new Date(); + today.setHours(0, 0, 0, 0); + expiryDate.setHours(0, 0, 0, 0); + + if (expiryDate < today) { + dispatch({ + type: "SET_ORDER", + patch: { + voucherStatus: { valid: false, error: "Gutschein ist abgelaufen" }, + }, + }); + return; + } + } + + if (voucher.usage_limit > 0 && voucher.usage_count >= voucher.usage_limit) { + dispatch({ + type: "SET_ORDER", + patch: { + voucherStatus: { + valid: false, + error: "Gutschein wurde bereits zu oft eingelöst", + }, + }, + }); + return; + } + + dispatch({ + type: "SET_ORDER", + patch: { + voucherStatus: { + valid: true, + voucher: voucher, + message: + voucher.type === "percent" + ? `${voucher.value}% Rabatt` + : `${voucher.value.toFixed(2)}€ Rabatt`, + }, + }, + }); +} + +// Step 8: Review +function renderReviewStep(state, dispatch) { + const blocks = []; + + // Zusammenfassung + const summary = [ + [ + "Kundentyp", + state.answers.customerType === "business" + ? "Geschäftskunde" + : "Privatkunde", + ], + ["Produkt", state?.ctx?.product?.label || ""], + ["Menge", String(state.answers.quantity || "")], + [ + "Format", + (() => { + const fmt = state.answers.format; + if (fmt === "a6p") return "A6 Hochformat"; + if (fmt === "a6l") return "A6 Querformat"; + if (fmt === "a4") return "A4"; + return fmt?.toUpperCase() || ""; + })(), + ], + [ + "Versand", + state.answers.shippingMode === "direct" + ? "Einzeln an Empfänger" + : "Gesammelt", + ], + ["Umschlag", state.answers.envelope ? "Ja" : "Nein"], + [ + "Beschriftung", + state.answers.envelopeMode === "recipientData" + ? "Empfängerdaten" + : state.answers.envelopeMode === "customText" + ? "Individuell" + : "Keine", + ], + ]; + + const table = h("table", { class: "sk-table" }, []); + const tbody = h("tbody", {}, []); + for (const [k, v] of summary) { + tbody.appendChild( + h("tr", {}, [h("td", { text: k }), h("td", { text: v })]), + ); + } + table.appendChild(tbody); + + blocks.push(renderCard("Ihre Auswahl", null, [table])); + + // Kundendaten Übersicht (vor Preis!) + const b = state.order.billing; + const customerDataRows = [ + ["Vorname", b.firstName || ""], + ["Nachname", b.lastName || ""], + ]; + + if (state.answers.customerType === "business" && b.company) { + customerDataRows.push(["Unternehmen", b.company]); + } + + customerDataRows.push( + ["E-Mail", b.email || ""], + ["Telefon", b.phone || ""], + ["Straße und Hausnummer", b.street || ""], + ["PLZ", b.zip || ""], + ["Stadt", b.city || ""], + ["Land", b.country || "Deutschland"], + ); + + const customerDataTable = h("table", { class: "sk-table" }, []); + const customerDataBody = h("tbody", {}, []); + for (const [k, v] of customerDataRows) { + customerDataBody.appendChild( + h("tr", {}, [h("td", { text: k }), h("td", { text: v })]), + ); + } + customerDataTable.appendChild(customerDataBody); + + blocks.push(renderCard("Ihre Daten", null, [customerDataTable])); + + // Preis + const isB2B = state.answers?.customerType === "business"; + const isFU = isFollowups(state); + + if (isFU) { + // Follow-ups: Zeige Staffelung + Einrichtungskosten + const followupPricing = calculateFollowupPricing(state); + + if (followupPricing) { + // Staffelungs-Tabelle + const staffelTable = h("table", { class: "sk-table" }, []); + staffelTable.appendChild( + h("thead", {}, [ + h("tr", {}, [ + h("th", { text: "Menge/Monat" }), + h("th", { + text: isB2B + ? "Preis pro Schriftstück (netto)" + : "Preis pro Schriftstück (brutto)", + }), + ]), + ]), + ); + + // Grundpreis pro Stück (wie in Kopfzeile, OHNE Multiplikator) + const basePrice = calculateFollowupBasePricePerPiece(state); + + const staffelBody = h("tbody", {}, []); + for (const tier of followupPricing.tiers) { + // Preis pro Schriftstück = Grundpreis * Multiplikator + const pricePerItem = basePrice * tier.multiplier; + const formatted = formatPrice(pricePerItem, state); + const pricePerItemFormatted = formatted.display; + + staffelBody.appendChild( + h("tr", {}, [ + h("td", { text: tier.label }), + h("td", { text: pricePerItemFormatted }), + ]), + ); + } + staffelTable.appendChild(staffelBody); + + blocks.push(renderCard("Preisstaffelung", null, [staffelTable])); + + // Einrichtungskosten - detailliert aufschlüsseln + if (followupPricing.setup > 0) { + const setupItems = []; + const prices = window.SkriftConfigurator?.settings?.prices || {}; + + // API-Anbindung + if (state.answers.followupCreateMode === "auto") { + const apiPrice = prices.api_connection || 0; + if (apiPrice > 0) { + const formatted = formatPrice(apiPrice, state); + setupItems.push( + h( + "div", + { + style: + "display: flex; justify-content: space-between; padding: 8px 0;", + }, + [ + h("span", { text: "API-Anbindung" }), + h("span", { + style: "font-weight: 600", + text: formatted.display, + }), + ], + ), + ); + } + } + + // Motiv Upload + if (state.answers.motifSource === "upload") { + const motifPrice = prices.motif_upload || 0; + if (motifPrice > 0) { + const formatted = formatPrice(motifPrice, state); + setupItems.push( + h( + "div", + { + style: + "display: flex; justify-content: space-between; padding: 8px 0;", + }, + [ + h("span", { text: "Motiv Upload" }), + h("span", { + style: "font-weight: 600", + text: formatted.display, + }), + ], + ), + ); + } + } + + // Designservice + if (state.answers.motifSource === "design") { + const designPrice = prices.motif_design || 0; + if (designPrice > 0) { + const formatted = formatPrice(designPrice, state); + setupItems.push( + h( + "div", + { + style: + "display: flex; justify-content: space-between; padding: 8px 0;", + }, + [ + h("span", { text: "Designservice" }), + h("span", { + style: "font-weight: 600", + text: formatted.display, + }), + ], + ), + ); + } + } + + // Textservice + if (state.answers.contentCreateMode === "textservice") { + const textPrice = prices.textservice || 0; + if (textPrice > 0) { + const formatted = formatPrice(textPrice, state); + setupItems.push( + h( + "div", + { + style: + "display: flex; justify-content: space-between; padding: 8px 0;", + }, + [ + h("span", { text: "Textservice" }), + h("span", { + style: "font-weight: 600", + text: formatted.display, + }), + ], + ), + ); + } + } + + // Gesamt + const setupFormatted = formatPrice(followupPricing.setup, state); + setupItems.push( + h( + "div", + { + style: + "display: flex; justify-content: space-between; padding: 12px 0; border-top: 2px solid #ddd; margin-top: 8px; font-size: 18px; font-weight: 700", + }, + [ + h("span", { text: "Gesamt (einmalig)" }), + h("span", { text: setupFormatted.display }), + ], + ), + ); + + blocks.push(renderCard("Einrichtungskosten", null, setupItems)); + } + + // Versandhinweis + if (followupPricing.shippingNote) { + blocks.push( + h("div", { + class: "sk-alert sk-alert-info", + text: followupPricing.shippingNote, + }), + ); + } + } + } else { + // Standard-Produkte: Detaillierte Rechnung mit 4 Spalten + const lines = state.quote?.lines || []; + const priceTable = h("table", { class: "sk-table" }, []); + priceTable.appendChild( + h("thead", {}, [ + h("tr", {}, [ + h("th", { text: "Position" }), + h("th", { text: "Menge", style: "text-align: center;" }), + h("th", { text: "Preis pro Stück", style: "text-align: right;" }), + h("th", { text: "Gesamt", style: "text-align: right;" }), + ]), + ]), + ); + + const priceBody = h("tbody", {}, []); + if (lines.length === 0) { + priceBody.appendChild( + h("tr", {}, [ + h("td", { + colspan: "4", + text: "Preisberechnung folgt nach Bestellung", + }), + ]), + ); + } else { + // Preis pro Stück für Hauptprodukt mit dynamischer Formel berechnen + const quantity = state.answers.quantity || 1; + const pricePerPieceNet = calculatePricePerPiece(state); + const taxRate = + (window.SkriftConfigurator?.settings?.prices?.tax_rate || 19) / 100; + const pricePerPieceGross = pricePerPieceNet * (1 + taxRate); + + for (const ln of lines) { + const isSubItem = ln.isSubItem || false; + const isMainProduct = ln.isMainProduct || false; + // Direktversand-Zeile soll Menge und Gesamtpreis anzeigen (nicht wie normale Sub-Items) + const isShippingLine = + ln.isShippingLine || + ln.description?.includes("Direktversand") || + ln.description?.includes("Sammelversand"); + const rowStyle = isSubItem + ? "padding-left: 20px; font-size: 14px; color: #666;" + : ""; + + // Bei Privatkunden: Bruttopreise anzeigen, bei Business: Nettopreise + const unitPrice = isB2B ? ln.unitNet : ln.unitGross; + const totalPrice = isB2B ? ln.totalNet : ln.totalGross; + + // Position: Bei Add-Ons (SubItems) "inkl." voranstellen + const positionText = isSubItem + ? `inkl. ${ln.description}` + : ln.description; + + // Bei Hauptprodukt: Summierten Preis pro Stück und Gesamtpreis anzeigen + // Preise auf 2 Dezimalstellen runden für konsistente Anzeige + const pricePerPieceDisplay = isMainProduct + ? isB2B + ? Math.round(pricePerPieceNet * 100) / 100 + : Math.round(pricePerPieceGross * 100) / 100 + : Math.round(unitPrice * 100) / 100; + + // Gesamt = gerundeter Einzelpreis × Menge (damit Menge × Stückpreis = Gesamt stimmt) + // Sub-Items zeigen normalerweise keinen Gesamtpreis, AUSSER Versand-Zeilen + const showQuantityAndTotal = !isSubItem || isShippingLine; + const totalPriceDisplay = isMainProduct + ? Math.round(pricePerPieceDisplay * quantity * 100) / 100 + : showQuantityAndTotal + ? Math.round(totalPrice * 100) / 100 + : null; + + const row = h("tr", {}, [ + h("td", { text: positionText, style: rowStyle }), + h("td", { + text: + showQuantityAndTotal && ln.quantity > 0 + ? ln.quantity.toString() + : "—", + style: "text-align: center;" + (isSubItem ? " color: #666;" : ""), + }), + h("td", { + text: fmtEUR(pricePerPieceDisplay), + style: "text-align: right;" + (isSubItem ? " color: #666;" : ""), + }), + h("td", { + text: + showQuantityAndTotal && totalPriceDisplay !== null + ? fmtEUR(totalPriceDisplay) + : "—", + style: "text-align: right;" + (isSubItem ? " color: #666;" : ""), + }), + ]); + priceBody.appendChild(row); + + // Steuerhinweis bei Versand (0% MwSt Anteil) + if (ln.taxNote) { + priceBody.appendChild( + h("tr", {}, [ + h("td", { + colspan: "4", + style: "font-size: 12px; color: #999; padding-left: 40px;", + text: ln.taxNote, + }), + ]), + ); + } + } + } + + // Gutschein-Rabatt anzeigen (falls vorhanden) + const hasDiscount = state.quote?.discountAmount > 0; + + if (hasDiscount) { + // Zwischensumme VOR Rabatt + priceBody.appendChild( + h("tr", { style: "border-top: 2px solid #ddd;" }, [ + h("td", { text: "Zwischensumme", colspan: "3" }), + h("td", { + text: fmtEUR(state.quote?.totalBeforeDiscount || 0), + style: "text-align: right;", + }), + ]), + ); + + // Rabatt-Zeile + const voucherLabel = + state.quote?.voucher?.type === "percent" + ? `Gutschein (-${state.quote.voucher.value}%)` + : `Gutschein`; + + priceBody.appendChild( + h("tr", { style: "color: #2e7d32; font-weight: 600;" }, [ + h("td", { text: voucherLabel, colspan: "3" }), + h("td", { + text: "-" + fmtEUR(state.quote?.discountAmount || 0), + style: "text-align: right;", + }), + ]), + ); + } + + if (isB2B) { + // Business: Netto + MwSt getrennt + priceBody.appendChild( + h( + "tr", + { + style: + "font-weight: 700; border-top: " + + (hasDiscount ? "1px solid #ddd" : "2px solid #ddd") + + ";", + }, + [ + h("td", { text: "Gesamtpreis (zzgl. MwSt.)", colspan: "3" }), + h("td", { + text: fmtEUR(state.quote?.subtotalNet || 0), + style: "text-align: right;", + }), + ], + ), + ); + + priceBody.appendChild( + h("tr", { style: "font-weight: 700; font-size: 16px" }, [ + h("td", { text: "Gesamtpreis (inkl. MwSt.)", colspan: "3" }), + h("td", { + text: fmtEUR(state.quote?.totalGross || 0), + style: "text-align: right;", + }), + ]), + ); + + // MwSt. Ausweis unter Gesamtpreis (inkl. MwSt.) + priceBody.appendChild( + h("tr", { style: "font-size: 14px; color: #666;" }, [ + h("td", { text: "enthaltene MwSt. 19%", colspan: "3" }), + h("td", { + text: fmtEUR(state.quote?.vatAmount || 0), + style: "text-align: right;", + }), + ]), + ); + } else { + // Privatkunde: Gesamtpreis + MwSt. Ausweis darunter + priceBody.appendChild( + h( + "tr", + { + style: + "font-weight: 700; font-size: 16px; border-top: " + + (hasDiscount ? "1px solid #ddd" : "2px solid #ddd") + + ";", + }, + [ + h("td", { text: "Gesamtpreis (inkl. MwSt.)", colspan: "3" }), + h("td", { + text: fmtEUR(state.quote?.totalGross || 0), + style: "text-align: right;", + }), + ], + ), + ); + + // MwSt. Ausweis unter Gesamtpreis + priceBody.appendChild( + h("tr", { style: "font-size: 14px; color: #666;" }, [ + h("td", { text: "enthaltene MwSt. 19%", colspan: "3" }), + h("td", { + text: fmtEUR(state.quote?.vatAmount || 0), + style: "text-align: right;", + }), + ]), + ); + } + + priceTable.appendChild(priceBody); + + // Gutschein-Bereich + const voucherCode = state.order?.voucherCode || ""; + const voucherStatus = state.order?.voucherStatus || null; + const showVoucherInput = state.order?.showVoucherInput || false; + + const voucherSection = []; + + if (!showVoucherInput && !voucherStatus?.valid) { + // Zeige klickbaren Link + voucherSection.push( + h("a", { + style: + "margin-top: 15px; display: block; text-decoration: underline; color: #999; font-size: 13px; cursor: pointer;", + text: "Gutschein oder Aktionscode?", + onclick: (e) => { + e.preventDefault(); + dispatch({ + type: "SET_ORDER", + patch: { showVoucherInput: true }, + }); + }, + }), + ); + } else if (showVoucherInput || voucherStatus?.valid) { + // Zeige Eingabefeld + voucherSection.push( + h( + "div", + { + style: + "margin-top: 15px; padding-top: 15px; border-top: 1px solid #e0e0e0;", + }, + [ + h( + "div", + { + style: "display: flex; gap: 10px; align-items: flex-start;", + }, + [ + h("input", { + type: "text", + class: "sk-input", + style: "flex: 1; text-transform: uppercase;", + placeholder: "Gutschein- oder Aktionscode eingeben", + value: voucherCode, + disabled: voucherStatus?.valid, + "data-sk-focus": "voucher.code", + oninput: (e) => { + dispatch({ + type: "SET_ORDER", + patch: { + voucherCode: e.target.value.toUpperCase(), + voucherStatus: null, + }, + }); + }, + }), + voucherStatus?.valid + ? h("button", { + type: "button", + class: "sk-btn sk-btn-secondary", + text: "Entfernen", + onclick: () => { + dispatch({ + type: "SET_ORDER", + patch: { + voucherCode: "", + voucherStatus: null, + showVoucherInput: false, + }, + }); + }, + }) + : h("button", { + type: "button", + class: "sk-btn sk-btn-secondary", + text: "Einlösen", + onclick: () => validateVoucherCode(voucherCode, dispatch), + }), + ], + ), + voucherStatus + ? h("div", { + class: voucherStatus.valid ? "sk-help" : "sk-error-message", + style: `margin-top: 8px; color: ${ + voucherStatus.valid ? "#2e7d32" : "#d32f2f" + };`, + text: voucherStatus.valid + ? `✓ ${voucherStatus.message}` + : `✗ ${voucherStatus.error}`, + }) + : null, + ].filter(Boolean), + ), + ); + } + + const priceCardContent = [priceTable, ...voucherSection]; + + // Preis-Card mit spezieller Klasse für noPrice-Modus + const priceCard = renderCard("Preis", null, priceCardContent); + priceCard.classList.add("sk-price-card"); + blocks.push(priceCard); + } + + // Gutschein ist jetzt für alle Produkte in der Preis-Card integriert (siehe oben) + + // Alternative Lieferadresse (falls vorhanden) + if (state.order.shippingDifferent) { + const sh = state.order.shipping; + const shippingDataRows = [ + ["Vorname", sh.firstName || ""], + ["Nachname", sh.lastName || ""], + ]; + + if (state.answers.customerType === "business" && sh.company) { + shippingDataRows.push(["Unternehmen", sh.company]); + } + + shippingDataRows.push( + ["Straße und Hausnummer", sh.street || ""], + ["PLZ", sh.zip || ""], + ["Stadt", sh.city || ""], + ["Land", sh.country || "Deutschland"], + ); + + const shippingDataTable = h("table", { class: "sk-table" }, []); + const shippingDataBody = h("tbody", {}, []); + for (const [k, v] of shippingDataRows) { + shippingDataBody.appendChild( + h("tr", {}, [h("td", { text: k }), h("td", { text: v })]), + ); + } + shippingDataTable.appendChild(shippingDataBody); + + blocks.push(renderCard("Lieferadresse", null, [shippingDataTable])); + } + + // Rechtliches mit Bestellbutton + blocks.push( + renderCard("Rechtliches", null, [ + h( + "label", + { + class: "sk-option", + style: "margin-bottom: 12px", + onclick: (e) => e.stopPropagation(), + }, + [ + h("input", { + type: "checkbox", + id: "sk-agb-checkbox", + checked: !!state.order.acceptedAgb, + onclick: (e) => e.stopPropagation(), + onchange: (e) => { + e.stopPropagation(); + dispatch({ + type: "SET_ORDER", + patch: { acceptedAgb: e.target.checked }, + }); + }, + }), + h("div", { class: "sk-option-content" }, [ + h("div", { class: "sk-option-label" }, [ + document.createTextNode("Ich habe die "), + h("a", { + href: "https://skrift.de/agb/", + target: "_blank", + rel: "noopener noreferrer", + text: "Allgemeinen Geschäftsbedingungen", + onclick: (e) => e.stopPropagation(), + }), + document.createTextNode( + " gelesen und akzeptiere deren Geltung. Ein Widerrufsrecht besteht gemäß § 312g Abs. 2 Nr. 1 BGB nicht, da Skrift ausschließlich Waren anbietet, die nicht vorgefertigt sind und für deren Herstellung eine individuelle Auswahl oder Bestimmung durch den Kunden maßgeblich ist oder die eindeutig auf die persönlichen Bedürfnisse des Kunden zugeschnitten sind.", + ), + ]), + ]), + ], + ), + h("label", { class: "sk-option", onclick: (e) => e.stopPropagation() }, [ + h("input", { + type: "checkbox", + id: "sk-privacy-checkbox", + checked: !!state.order.acceptedPrivacy, + onclick: (e) => e.stopPropagation(), + onchange: (e) => { + e.stopPropagation(); + dispatch({ + type: "SET_ORDER", + patch: { acceptedPrivacy: e.target.checked }, + }); + }, + }), + h("div", { class: "sk-option-content" }, [ + h("div", { class: "sk-option-label" }, [ + document.createTextNode("Ja, ich habe die "), + h("a", { + href: "https://skrift.de/datenschutz/", + target: "_blank", + rel: "noopener noreferrer", + text: "Datenschutzerklärung", + onclick: (e) => e.stopPropagation(), + }), + document.createTextNode( + " zur Kenntnis genommen und bin damit einverstanden, dass die von mir angegebenen Daten elektronisch erhoben und gespeichert werden. Meine Daten werden dabei nur streng zweckgebunden zur Bearbeitung und Beantwortung meiner Anfrage benutzt. Mit dem Absenden des Kontaktformulars erkläre ich mich mit der Verarbeitung einverstanden. Die Daten werden im Falle eines ausbleibenden Auftrags gelöscht.", + ), + ]), + ]), + ]), + // Bestellbutton - für Privatkunden mit PayPal oder normaler Button + renderOrderButton(state), + ]), + ); + + return h("div", { class: "sk-stack" }, blocks); +} + +/** + * Rendert den Bestellbutton basierend auf Kundentyp und PayPal-Konfiguration + */ +function renderOrderButton(state) { + const isB2B = state.answers?.customerType === "business"; + const paypalConfig = window.SkriftConfigurator?.paypal || {}; + const isPayPalEnabled = paypalConfig.enabled && paypalConfig.client_id; + const isNoPrice = state.noPrice || false; + + // Geschäftskunden, PayPal nicht aktiviert, oder noPrice-Modus: Normaler Button + if (isB2B || !isPayPalEnabled || isNoPrice) { + return h("button", { + type: "button", + class: "sk-btn sk-btn-primary sk-btn-large", + style: "width: 100%; margin-top: 20px;", + disabled: !validateStep(state), + onclick: () => handleOrderSubmit(state), + text: "Jetzt kostenpflichtig bestellen", + }); + } + + // Prüfen ob AGB und Datenschutz akzeptiert wurden + const agbAccepted = state.order?.acceptedAgb || false; + const privacyAccepted = state.order?.acceptedPrivacy || false; + const isValid = agbAccepted && privacyAccepted; + + // Privatkunden mit PayPal: PayPal-Button-Container + const container = h( + "div", + { + style: "margin-top: 20px; position: relative;", + }, + [ + h("div", { + id: "paypal-button-container", + style: `min-height: 50px; ${ + !isValid ? "opacity: 0.5; pointer-events: none;" : "" + }`, + }), + h("div", { + class: "sk-paypal-loading", + style: "text-align: center; padding: 15px; color: #666;", + text: "PayPal wird geladen...", + }), + // Hinweis wenn Checkboxen nicht aktiviert + !isValid + ? h("div", { + style: + "text-align: center; padding: 10px; color: #dc2626; font-size: 14px; margin-top: 5px;", + text: "Bitte akzeptieren Sie die AGB und Datenschutzerklärung, um fortzufahren.", + }) + : null, + ].filter(Boolean), + ); + + // PayPal SDK laden und Button initialisieren + loadPayPalSDK(paypalConfig.client_id) + .then(() => { + initPayPalButton(state); + }) + .catch((err) => { + console.error("[PayPal] SDK load failed:", err); + const loadingEl = document.querySelector(".sk-paypal-loading"); + if (loadingEl) { + loadingEl.innerHTML = + "PayPal konnte nicht geladen werden. Bitte versuchen Sie es später erneut."; + loadingEl.style.color = "#dc2626"; + } + }); + + return container; +} + +/** + * Lädt die PayPal SDK dynamisch + */ +let paypalSDKLoaded = false; +let paypalSDKPromise = null; + +function loadPayPalSDK(clientId) { + if (paypalSDKLoaded) { + return Promise.resolve(); + } + + if (paypalSDKPromise) { + return paypalSDKPromise; + } + + paypalSDKPromise = new Promise((resolve, reject) => { + const script = document.createElement("script"); + // SDK URL - einfache Version ohne buyer-country (das verursacht 400 Fehler) + script.src = `https://www.paypal.com/sdk/js?client-id=${clientId}¤cy=EUR&intent=capture&disable-funding=venmo,paylater,credit`; + script.async = true; + + script.onload = () => { + paypalSDKLoaded = true; + resolve(); + }; + + script.onerror = () => { + paypalSDKPromise = null; + reject(new Error("Failed to load PayPal SDK")); + }; + + document.head.appendChild(script); + }); + + return paypalSDKPromise; +} + +/** + * Initialisiert den PayPal-Button mit Client-Side Integration + * Keine Backend-Aufrufe nötig - alles läuft direkt über PayPal API + */ +function initPayPalButton(state) { + const container = document.getElementById("paypal-button-container"); + const loadingEl = document.querySelector(".sk-paypal-loading"); + + if (!container) { + console.error("[PayPal] Button container not found"); + return; + } + + // Loading-Text entfernen + if (loadingEl) { + loadingEl.style.display = "none"; + } + + // Prüfen ob Button bereits existiert + if (container.hasChildNodes()) { + return; + } + + if (!window.paypal) { + console.error("[PayPal] SDK not available"); + return; + } + + window.paypal + .Buttons({ + style: { + shape: "rect", + layout: "vertical", + color: "blue", + label: "paypal", + }, + + // Bestellung direkt über PayPal API erstellen (Client-Side) + createOrder(data, actions) { + const currentState = window.currentGlobalState; + + // State muss vorhanden sein + if (!currentState) { + alert( + "Fehler: State nicht verfügbar. Bitte laden Sie die Seite neu.", + ); + return Promise.reject(new Error("State nicht verfügbar")); + } + + // Checkbox-Werte direkt aus dem DOM lesen als Fallback + const agbCheckbox = document.getElementById("sk-agb-checkbox"); + const privacyCheckbox = document.getElementById("sk-privacy-checkbox"); + + const agbAccepted = + currentState.order?.acceptedAgb || agbCheckbox?.checked; + const privacyAccepted = + currentState.order?.acceptedPrivacy || privacyCheckbox?.checked; + + // Validierung prüfen - spezifische Fehlermeldung + if (!agbAccepted) { + alert("Bitte akzeptieren Sie die Allgemeinen Geschäftsbedingungen."); + return Promise.reject(new Error("AGB nicht akzeptiert")); + } + if (!privacyAccepted) { + alert("Bitte akzeptieren Sie die Datenschutzerklärung."); + return Promise.reject(new Error("Datenschutz nicht akzeptiert")); + } + + const quote = currentState.quote || {}; + const total = quote.totalGross || 0; + + if (total <= 0) { + alert( + "Ungültiger Bestellbetrag. Bitte konfigurieren Sie Ihre Bestellung.", + ); + return Promise.reject(new Error("Ungültiger Bestellbetrag")); + } + + // Betrag auf 2 Dezimalstellen formatieren + const formattedTotal = parseFloat(total).toFixed(2); + + // Produktname für PayPal + const productName = + currentState.ctx?.product?.label || "Skrift Handschriftservice"; + const quantity = currentState.answers?.quantity || 1; + + return actions.order.create({ + intent: "CAPTURE", + purchase_units: [ + { + amount: { + currency_code: "EUR", + value: formattedTotal, + }, + description: `${quantity}x ${productName}`, + }, + ], + application_context: { + brand_name: "Skrift", + locale: "de-DE", + shipping_preference: "NO_SHIPPING", + user_action: "PAY_NOW", + }, + }); + }, + + // Zahlung direkt über PayPal API erfassen (Client-Side) + onApprove(data, actions) { + return actions.order.capture().then(function (orderData) { + // Erfolgreiche Transaktion + const transaction = + orderData?.purchase_units?.[0]?.payments?.captures?.[0]; + + if (transaction?.status === "COMPLETED") { + // Webhook aufrufen und weiterleiten + handlePayPalSuccess(window.currentGlobalState || state, orderData); + } else { + alert( + "Zahlung konnte nicht abgeschlossen werden. Bitte versuchen Sie es erneut.", + ); + } + }); + }, + + // Zahlung abgebrochen + onCancel() { + alert("Zahlung wurde abgebrochen."); + }, + + // Fehler + onError(err) { + console.error("[PayPal] Error:", err); + alert( + "Bei der Zahlung ist ein Fehler aufgetreten. Bitte versuchen Sie es erneut.", + ); + }, + }) + .render("#paypal-button-container"); +} + +/** + * Behandelt erfolgreiche PayPal-Zahlung + */ +async function handlePayPalSuccess(state, paypalDetails) { + const backend = window.SkriftConfigurator?.settings?.backend_connection || {}; + const webhookUrlPrivate = backend.webhook_url_private; + const redirectUrlPrivate = backend.redirect_url_private; + + // Bestellnummer generieren (fortlaufend vom WP-Backend) + const api = window.SkriftBackendAPI; + const orderNumber = api ? await api.generateOrderNumber() : `S-${Date.now()}`; + + // Motiv hochladen wenn vorhanden + const motifUrl = await uploadMotifIfNeeded(state, orderNumber); + if (motifUrl) { + state.answers.motifFileUrl = motifUrl; + } + + // Gutschein als verwendet markieren (falls vorhanden) + const voucherCode = state.order?.voucherStatus?.valid + ? state.order.voucherCode + : null; + if (voucherCode) { + try { + const restUrl = window.SkriftConfigurator?.restUrl || "/wp-json/"; + await fetch(restUrl + "skrift/v1/voucher/use", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ code: voucherCode }), + }); + } catch (error) { + // Fehler beim Markieren des Gutscheins - trotzdem fortfahren + } + } + + // Bestellung finalisieren: Preview-Dateien aus Cache in Output-Ordner kopieren + let finalizeResult = null; + if (api && api.sessionId) { + try { + finalizeResult = await api.finalizeOrder(api.sessionId, orderNumber, { + customer: state.order, + payment: { + method: "paypal", + transaction_id: paypalDetails.id, + payer_email: paypalDetails.payer?.email_address, + status: paypalDetails.status, + }, + quote: state.quote, + letterFormat: { a4: "A4", a6p: "A6 Hochformat", a6l: "A6 Querformat" }[state.answers.format] || state.answers.format || null, + envelopeFormat: state.answers.format === "a4" ? "DIN Lang" : (state.answers.format === "a6p" || state.answers.format === "a6l") ? "C6" : null, + envelopeBeschriftung: { recipientData: "Empfängeradresse", customText: "Individueller Text", none: "Keine Beschriftung" }[state.answers.envelopeMode] || state.answers.envelopeMode || null, + }); + console.log("[PayPal] Finalize result:", finalizeResult); + } catch (error) { + console.error("[PayPal] Finalize error:", error); + } + } else { + console.warn("[PayPal] Cannot finalize - no API or session available"); + } + + // Webhook für Privatkunden mit PayPal-Details aufrufen + if (webhookUrlPrivate) { + try { + const orderData = { + order_number: orderNumber, + customer_type: state.answers.customerType, + product: state.ctx?.product?.key, + quantity: state.answers.quantity, + format: state.answers.format, + shipping_mode: state.answers.shippingMode, + envelope: state.answers.envelope, + envelope_mode: state.answers.envelopeMode, + customer_data: state.order, + quote: state.quote, + timestamp: new Date().toISOString(), + payment: { + method: "paypal", + transaction_id: paypalDetails.id, + payer_email: paypalDetails.payer?.email_address, + status: paypalDetails.status, + }, + finalize_result: finalizeResult, + }; + + const response = await fetch(webhookUrlPrivate, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(orderData), + }); + + if (!response.ok) { + console.error("[PayPal] Webhook failed:", response.status); + } + } catch (error) { + console.error("[PayPal] Webhook error:", error); + } + } + + // Weiterleitung für Privatkunden + if (redirectUrlPrivate) { + window.location.href = redirectUrlPrivate; + } else { + alert( + "Vielen Dank für Ihre Zahlung! Ihre Bestellung wurde erfolgreich aufgenommen. Sie erhalten in Kürze eine Bestätigungs-E-Mail.", + ); + } +} + +// Helper: CSV +function parseCsv(text) { + const raw = String(text || "").trim(); + if (!raw) return []; + const lines = raw.split(/\r?\n/).filter((l) => l.trim().length > 0); + const sample = lines[0]; + const delim = sample.includes(";") ? ";" : ","; + + return lines.map((ln) => { + const cells = ln.split(delim).map((c) => { + let value = c.trim(); + + // CSV Injection Prevention: Entferne gefährliche Zeichen am Anfang + // Dies verhindert Formula Injection in Excel/LibreOffice + if (value.length > 0 && /^[=+\-@]/.test(value)) { + value = "'" + value; // Prefix mit ' um als Text zu markieren + } + + return value; + }); + return cells; + }); +} + +function escapeCsvValue(value) { + let str = String(value || ""); + + // CSV Injection Prevention: Entferne gefährliche Zeichen am Anfang + // Dies verhindert Formula Injection in Excel/LibreOffice + if (str.length > 0 && /^[=+\-@]/.test(str)) { + str = "'" + str; // Prefix mit ' um als Text zu markieren + } + + // Semikolon und Anführungszeichen escapen + if (str.includes(";") || str.includes('"') || str.includes("\n")) { + return '"' + str.replaceAll('"', '""') + '"'; + } + return str; +} + +function rowsToCsv(rows) { + const header = [ + "vorname", + "nachname", + "straße", + "hausnummer", + "plz", + "stadt", + "land", + ]; + const lines = [header.join(";")]; + for (const r of rows) { + lines.push( + [ + r.firstName || "", + r.lastName || "", + r.street || "", + r.houseNumber || "", + r.zip || "", + r.city || "", + r.country || "", + ] + .map((v) => escapeCsvValue(v)) + .join(";"), + ); + } + return lines.join("\n"); +} + +function downloadText(filename, text) { + const blob = new Blob([text], { type: "text/plain;charset=utf-8" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); +} + +// Generische Tabellen-Modal Funktion +// Diese Funktion erstellt ein Modal mit einer Tabelle, die Import/Export unterstützt +// und sich automatisch nach einem Import aktualisiert. +// +// config Parameter: +// - title: Titel des Modals +// - columns: Array von {key, label} Objekten +// - getData: Funktion die den aktuellen State nimmt und die Daten zurückgibt (Array von Objekten) +// - setData: Funktion die dispatch und neue Daten nimmt und den State aktualisiert +// - rowCount: Anzahl der Zeilen +// - exportFilename: Dateiname für CSV Export +// - helpText: Optionaler Hilfetext unter der Tabelle +// - getStateFunc: Funktion die den aktuellen State zurückgibt (wichtig für Re-Rendering!) +function createTableModal(dispatch, config) { + let tableWrapper; + let currentState = config.getStateFunc(); + let isPasting = false; // Flag um Paste-Vorgang zu markieren + + // State-Referenz aktualisieren (wird nach jedem Dispatch aufgerufen) + const updateState = () => { + currentState = config.getStateFunc(); + }; + + // Render-Funktion die die Tabelle neu zeichnet (muss vor buildTable definiert werden) + const renderTable = () => { + requestAnimationFrame(() => { + const oldTable = tableWrapper.querySelector("table"); + if (oldTable) oldTable.remove(); + tableWrapper.appendChild(buildTable()); + }); + }; + + // Baut die Tabelle mit den aktuellen Daten + function buildTable() { + updateState(); // State aktualisieren vor dem Rendern + const data = config.getData(currentState); + const rowCount = config.rowCount(currentState); + + const table = h( + "table", + { class: "sk-table sk-table-pasteable", tabindex: "0" }, + [], + ); + + // Header (Fragment verwenden für bessere Performance) + const headerCells = [ + h("th", { text: "Nr.", class: "sk-table-row-number" }), + ]; + config.columns.forEach((col) => { + const attrs = { text: col.label }; + if (col.readOnly) { + attrs.class = "sk-table-readonly-header"; + } + headerCells.push(h("th", attrs)); + }); + table.appendChild(h("thead", {}, [h("tr", {}, headerCells)])); + + // Body - Batch DOM updates + const tbody = h("tbody", {}, []); + const rows = []; + + for (let rowIdx = 0; rowIdx < rowCount; rowIdx++) { + const rowData = data[rowIdx] || {}; + const cells = [ + h("td", { text: String(rowIdx + 1), class: "sk-table-row-number" }), + ]; + + config.columns.forEach((col, colIdx) => { + const value = rowData[col.key] || ""; + const cellAttrs = {}; + if (col.readOnly) { + cellAttrs.class = "sk-table-readonly-cell"; + } + + // Optimierung: Input Handler mit direktem Dispatch ohne komplette Daten-Kopie + cells.push( + h("td", cellAttrs, [ + h("input", { + class: "sk-input", + type: "text", + value: value, + placeholder: col.placeholder || "", + readonly: col.readOnly || false, + "data-row": String(rowIdx), + "data-col": String(colIdx), + "data-key": col.key, + "data-sk-focus": `modal.${col.key}.${rowIdx}`, + oninput: col.readOnly + ? null + : (e) => { + // Während Paste-Vorgang ignorieren - Paste-Handler übernimmt + if (isPasting) return; + + // Scroll-Position VOR dem dispatch speichern + const modalContent = e.target.closest(".sk-modal-content"); + const scrollTop = modalContent ? modalContent.scrollTop : 0; + const cursorPos = e.target.selectionStart; + const inputValue = e.target.value; + + updateState(); + const newData = config + .getData(currentState) + .map((r) => ({ ...r })); + if (!newData[rowIdx]) newData[rowIdx] = {}; + newData[rowIdx][col.key] = inputValue; + config.setData(dispatch, newData); + + // Fokus und Scroll-Position nach Render wiederherstellen + setTimeout(() => { + const input = document.querySelector( + `input[data-row="${rowIdx}"][data-key="${col.key}"]`, + ); + if (input) { + input.focus({ preventScroll: true }); + input.setSelectionRange(cursorPos, cursorPos); + + // Modal Scroll-Position wiederherstellen + const modal = input.closest(".sk-modal-content"); + if (modal) { + modal.scrollTop = scrollTop; + } + } + }, 0); + }, + }), + ]), + ); + }); + + rows.push(h("tr", {}, cells)); + } + + // Batch append mit DocumentFragment für maximale Performance + const fragment = document.createDocumentFragment(); + rows.forEach((row) => fragment.appendChild(row)); + tbody.appendChild(fragment); + table.appendChild(tbody); + + // Excel Paste Handler mit automatischer Tabellen-Aktualisierung + table.addEventListener("paste", (e) => { + e.preventDefault(); + isPasting = true; // Flag setzen um oninput zu blockieren + + const text = e.clipboardData.getData("text/plain"); + + if (!text || !text.trim()) { + isPasting = false; + return; + } + + const rows = text.split(/\r?\n/).filter((r) => r.trim()); + if (rows.length === 0) { + isPasting = false; + return; + } + + const activeInput = document.activeElement; + if (!activeInput || !activeInput.classList.contains("sk-input")) { + isPasting = false; + return; + } + + const startRow = parseInt(activeInput.dataset.row || "0"); + const startCol = parseInt(activeInput.dataset.col || "0"); + + updateState(); + const currentData = config.getData(currentState); + const currentRowCount = config.rowCount(currentState); + const newData = currentData.map((r) => ({ ...r })); + + // Sicherstellen dass genug Zeilen existieren + while (newData.length < currentRowCount) { + newData.push({}); + } + + // Werte in lokale Daten UND direkt ins DOM schreiben (kein Re-Render) + let pastedCells = 0; + rows.forEach((rowText, rowOffset) => { + const cells = rowText.split("\t"); + cells.forEach((cellValue, colOffset) => { + const targetRow = startRow + rowOffset; + const targetCol = startCol + colOffset; + + if ( + targetRow < currentRowCount && + targetCol < config.columns.length + ) { + const col = config.columns[targetCol]; + if (!col.readOnly) { + if (!newData[targetRow]) newData[targetRow] = {}; + newData[targetRow][col.key] = cellValue.trim(); + + // DOM direkt aktualisieren (kein Re-Render nötig) + const input = table.querySelector( + `input[data-row="${targetRow}"][data-key="${col.key}"]`, + ); + if (input) input.value = cellValue.trim(); + + pastedCells++; + } + } + }); + }); + + // Daten direkt in globalen State schreiben (OHNE Re-Render) + if (pastedCells > 0) { + const globalState = window.currentGlobalState; + if (globalState && config.stateKey) { + globalState[config.stateKey] = newData; + } + markPersistDirty(); + } + + // Flag zurücksetzen + isPasting = false; + }); + + return table; + } + + // CSV Export + function exportCsv() { + updateState(); + const data = config.getData(currentState); + const headers = config.columns.map((col) => col.label).join(";"); + const rows = data.map((row) => + config.columns.map((col) => escapeCsvValue(row[col.key] || "")).join(";"), + ); + const csv = [headers, ...rows].join("\n"); + downloadText(config.exportFilename, csv); + } + + // CSV Import + async function handleImport(file) { + if (!file) return; + + const text = await file.text(); + const parsed = parseCsv(text); + + if (parsed.length === 0) { + alert("Die CSV-Datei ist leer."); + return; + } + + // Strikte Header-Validierung + let startRow = 0; + let headerValid = false; + + if (parsed.length > 0 && parsed[0].length > 0) { + const firstRow = parsed[0]; + + // Prüfe ob erste Zeile wie eine Header-Zeile aussieht + const commonHeaders = [ + "vorname", + "nachname", + "name", + "straße", + "strasse", + "plz", + "stadt", + "land", + "nr", + "ort", + ]; + const hasCommonHeaders = firstRow.some((cell) => { + const lower = cell.toLowerCase().trim(); + return commonHeaders.some((header) => lower.includes(header)); + }); + + const hasPlaceholderSyntax = firstRow.some( + (cell) => cell.includes("[[") && cell.includes("]]"), + ); + + const looksLikeHeader = hasCommonHeaders || hasPlaceholderSyntax; + + if (looksLikeHeader) { + // Validiere die Spaltenüberschriften + const editableColumns = config.columns.filter((col) => !col.readOnly); + + // Prüfe ob alle erwarteten Spalten vorhanden sind + let matchCount = 0; + const expectedHeaders = editableColumns.map((col) => { + const labelCleaned = col.label + .replace(/\[\[|\]\]/g, "") + .toLowerCase() + .trim(); + return { + key: col.key.toLowerCase(), + label: labelCleaned, + original: col.label, + }; + }); + + for ( + let i = 0; + i < Math.min(firstRow.length, config.columns.length); + i++ + ) { + const cellLower = firstRow[i].toLowerCase().trim(); + const col = config.columns[i]; + + if (col.readOnly) continue; // Überspringe read-only Spalten + + const labelCleaned = col.label + .replace(/\[\[|\]\]/g, "") + .toLowerCase() + .trim(); + const keyLower = col.key.toLowerCase(); + + // Exakte oder ähnliche Übereinstimmung + if ( + cellLower === keyLower || + cellLower === labelCleaned || + cellLower.includes(labelCleaned) || + labelCleaned.includes(cellLower) + ) { + matchCount++; + } + } + + // Header ist gültig wenn mindestens 50% der editierbaren Spalten übereinstimmen + const requiredMatches = Math.ceil(editableColumns.length * 0.5); + headerValid = matchCount >= requiredMatches; + + if (headerValid) { + startRow = 1; + } else { + // Header-Zeile erkannt aber ungültig + const expectedHeaderNames = expectedHeaders + .map((h) => h.original) + .join(", "); + const actualHeaderNames = firstRow.join(", "); + + alert( + `Die Spaltenüberschriften stimmen nicht überein.\n\n` + + `Erwartet: ${expectedHeaderNames}\n\n` + + `Gefunden: ${actualHeaderNames}\n\n` + + `Bitte stellen Sie sicher, dass die CSV-Datei die richtigen Spaltenüberschriften hat.`, + ); + return; + } + } + } + + // Prüfe ob genug Datenzeilen vorhanden sind + if (parsed.length <= startRow) { + alert("Die CSV-Datei enthält keine Datenzeilen."); + return; + } + + updateState(); + const rowCount = config.rowCount(currentState); + const newData = []; + + for (let i = 0; i < rowCount; i++) { + const row = {}; + const sourceRow = parsed[startRow + i]; + if (sourceRow) { + config.columns.forEach((col, colIdx) => { + if (!col.readOnly) { + row[col.key] = sourceRow[colIdx] || ""; + } + }); + } + newData.push(row); + } + + config.setData(dispatch, newData); + + // Tabelle neu rendern nach Import (requestAnimationFrame für bessere Performance) + requestAnimationFrame(() => renderTable()); + } + + const fileInput = h("input", { + type: "file", + accept: ".csv,text/csv", + style: "display: none;", + onchange: async (e) => { + const f = e.target.files && e.target.files[0]; + await handleImport(f); + e.target.value = ""; + }, + }); + + const actions = h( + "div", + { class: "sk-inline", style: "margin-bottom: 16px;" }, + [ + fileInput, + h("button", { + type: "button", + class: "sk-btn", + text: "CSV importieren", + onclick: () => fileInput.click(), + }), + h("button", { + type: "button", + class: "sk-btn", + text: "CSV exportieren", + onclick: exportCsv, + }), + ], + ); + + tableWrapper = h("div", { class: "sk-table-wrapper-modal" }, [buildTable()]); + + const modalContent = h("div", { class: "sk-modal-content" }, [ + h("div", { class: "sk-modal-header" }, [ + h("h3", { text: config.title }), + h("button", { + type: "button", + class: "sk-modal-close", + text: "×", + onclick: () => modal.remove(), + }), + ]), + h( + "div", + { class: "sk-modal-body" }, + [ + config.infoText + ? h("div", { + class: "sk-alert sk-alert-info", + style: "margin-bottom: 16px; font-size: 13px;", + text: config.infoText, + }) + : null, + actions, + tableWrapper, + config.helpText + ? h("div", { + class: "sk-help", + style: "margin-top: 12px;", + text: config.helpText, + }) + : null, + ].filter((x) => x !== null), + ), + ]); + + const modal = h( + "div", + { + class: "sk-modal", + onclick: (e) => { + if (e.target.classList.contains("sk-modal")) { + modal.remove(); + } + }, + }, + [modalContent], + ); + + const configuratorElement = + document.querySelector(".sk-configurator") || document.body; + configuratorElement.appendChild(modal); + + // Focus auf erste editierbare Zelle + setTimeout(() => { + const firstInput = tableWrapper.querySelector("input:not([readonly])"); + if (firstInput) firstInput.focus(); + }, 100); +} + +// Helper: Empfänger Tabelle +function renderRecipientsTable(state, dispatch) { + const addressMode = state.addressMode || "classic"; + const qty = Number(state.answers.quantity || 0); + const want = qty > 0 ? qty : 0; + const isDirectShipping = state.answers.shippingMode === "direct"; + + // Klassische Adresstabelle + function buildClassicTable(currentState) { + const rows = Array.isArray(currentState.recipientRows) + ? currentState.recipientRows + : []; + + const table = h( + "table", + { class: "sk-table sk-table-pasteable", tabindex: "0" }, + [], + ); + // Rows-Referenz am Table speichern für Event-Handler + table._rows = rows; + + table.appendChild( + h("thead", {}, [ + h("tr", {}, [ + h("th", { text: "Nr.", class: "sk-table-row-number" }), + h("th", { text: "Vorname" }), + h("th", { text: "Nachname" }), + h("th", { text: "Straße" }), + h("th", { text: "Nr." }), + h("th", { text: "PLZ" }), + h("th", { text: "Stadt" }), + h("th", { text: "Land" }), + ]), + ]), + ); + + const tbody = h("tbody", {}, []); + for (let idx = 0; idx < want; idx++) { + const r = rows[idx] || {}; + const cell = (key, placeholder = "") => + h("td", {}, [ + h("input", { + class: "sk-input", + type: "text", + value: r[key] || "", + placeholder, + "data-row": idx, + "data-col": key, + "data-sk-focus": `recipient.${key}.${idx}`, + oninput: (e) => { + const currentRows = table._rows; + if (!currentRows[idx]) { + currentRows[idx] = { + firstName: "", + lastName: "", + street: "", + houseNumber: "", + zip: "", + city: "", + country: isDirectShipping ? "Deutschland" : "", + }; + } + currentRows[idx][key] = e.target.value; + + // Auch globalen State aktualisieren für SYNC_GLOBAL_STATE + const globalState = window.currentGlobalState; + if (globalState) { + globalState.recipientRows = currentRows; + } + }, + }), + ]); + + const countryCell = h("td", {}, [ + h("input", { + class: "sk-input", + type: "text", + value: r.country || (isDirectShipping ? "Deutschland" : ""), + placeholder: isDirectShipping ? "Deutschland" : "", + "data-row": idx, + "data-col": "country", + "data-sk-focus": `recipient.country.${idx}`, + oninput: (e) => { + const currentRows = table._rows; + if (!currentRows[idx]) { + currentRows[idx] = { + firstName: "", + lastName: "", + street: "", + houseNumber: "", + zip: "", + city: "", + country: isDirectShipping ? "Deutschland" : "", + }; + } + currentRows[idx].country = e.target.value; + + // Auch globalen State aktualisieren für SYNC_GLOBAL_STATE + const globalState = window.currentGlobalState; + if (globalState) { + globalState.recipientRows = currentRows; + } + }, + }), + ]); + + tbody.appendChild( + h("tr", {}, [ + h("td", { text: String(idx + 1), class: "sk-table-row-number" }), + cell("firstName"), + cell("lastName"), + cell("street"), + cell("houseNumber"), + cell("zip"), + cell("city"), + countryCell, + ]), + ); + } + table.appendChild(tbody); + + // Save-Funktion für Registry (wird bei Navigation und periodisch aufgerufen) + let hasLocalChanges = false; + const saveLocalChanges = () => { + if (hasLocalChanges) { + dispatch({ type: "SET_RECIPIENT_ROWS", rows: [...table._rows] }); + hasLocalChanges = false; + } + }; + + // Bei globaler Registry registrieren + const unregisterSave = registerTableSave(saveLocalChanges); + + // Auto-Save alle 60 Sekunden + const autoSaveInterval = setInterval(saveLocalChanges, 60000); + + // Cleanup-Funktion + table._cleanup = () => { + if (autoSaveInterval) clearInterval(autoSaveInterval); + unregisterSave(); + }; + + // Änderungen tracken bei Input + table.addEventListener("input", () => { + hasLocalChanges = true; + }); + + // Paste-Handler - Daten direkt in globalen State schreiben (OHNE Re-Render) + table.addEventListener("paste", (e) => { + e.preventDefault(); + const text = e.clipboardData.getData("text/plain"); + if (!text) return; + + const pasteRows = text.split(/\r?\n/).filter((r) => r.trim()); + if (pasteRows.length === 0) return; + + const activeInput = document.activeElement; + let startRow = 0; + let startCol = 0; + + if (activeInput && activeInput.tagName === "INPUT") { + const td = activeInput.closest("td"); + const tr = td?.closest("tr"); + if (tr) { + const allRows = Array.from(tbody.children); + startRow = allRows.indexOf(tr); + if (startRow >= 0) { + const allCells = Array.from(tr.children); + const cellIdx = allCells.indexOf(td); + startCol = cellIdx > 0 ? cellIdx - 1 : 0; + } + } + } + + const keys = [ + "firstName", + "lastName", + "street", + "houseNumber", + "zip", + "city", + "country", + ]; + + // Aktuellen globalen State holen + const globalState = window.currentGlobalState; + if (!globalState) return; + + // Kopie der aktuellen Rows erstellen + const currentRows = Array.isArray(globalState.recipientRows) + ? globalState.recipientRows.map((r) => ({ ...r })) + : []; + + // Sicherstellen dass genug Zeilen existieren + while (currentRows.length < want) { + currentRows.push({ + firstName: "", + lastName: "", + street: "", + houseNumber: "", + zip: "", + city: "", + country: isDirectShipping ? "Deutschland" : "", + }); + } + + // Werte in Daten UND direkt ins DOM schreiben + pasteRows.forEach((rowText, rowOffset) => { + const targetRow = startRow + rowOffset; + if (targetRow >= want) return; + + const cells = rowText.split("\t"); + cells.forEach((cellValue, colOffset) => { + const targetCol = startCol + colOffset; + if (targetCol >= keys.length) return; + const key = keys[targetCol]; + + // Daten aktualisieren + if (!currentRows[targetRow]) { + currentRows[targetRow] = { + firstName: "", + lastName: "", + street: "", + houseNumber: "", + zip: "", + city: "", + country: isDirectShipping ? "Deutschland" : "", + }; + } + currentRows[targetRow][key] = cellValue; + + // DOM direkt aktualisieren (kein Re-Render nötig) + const input = table.querySelector( + `input[data-row="${targetRow}"][data-col="${key}"]`, + ); + if (input) input.value = cellValue; + }); + }); + + // Globalen State DIREKT aktualisieren (OHNE dispatch/render) + globalState.recipientRows = currentRows; + table._rows = currentRows; + + // Für Persistenz markieren + markPersistDirty(); + }); + + return table; + } + + // Freie Adresstabelle (5 Zeilen) + function buildFreeTable(currentState) { + const rows = Array.isArray(currentState.freeAddressRows) + ? currentState.freeAddressRows + : []; + + const table = h( + "table", + { class: "sk-table sk-table-pasteable", tabindex: "0" }, + [], + ); + // Rows-Referenz am Table speichern für Event-Handler + table._rows = rows; + + table.appendChild( + h("thead", {}, [ + h("tr", {}, [ + h("th", { text: "Nr.", class: "sk-table-row-number" }), + h("th", { text: "Zeile 1" }), + h("th", { text: "Zeile 2" }), + h("th", { text: "Zeile 3" }), + h("th", { text: "Zeile 4" }), + h("th", { text: "Land" }), + ]), + ]), + ); + + const tbody = h("tbody", {}, []); + for (let idx = 0; idx < want; idx++) { + const r = rows[idx] || {}; + const cell = (key) => + h("td", {}, [ + h("input", { + class: "sk-input", + type: "text", + value: r[key] || "", + "data-row": idx, + "data-col": key, + "data-sk-focus": `freeaddr.${key}.${idx}`, + oninput: (e) => { + const currentRows = table._rows; + if (!currentRows[idx]) { + currentRows[idx] = { + line1: "", + line2: "", + line3: "", + line4: "", + line5: "", + }; + } + currentRows[idx][key] = e.target.value; + + // Auch globalen State aktualisieren für SYNC_GLOBAL_STATE + const globalState = window.currentGlobalState; + if (globalState) { + globalState.freeAddressRows = currentRows; + } + }, + }), + ]); + + tbody.appendChild( + h("tr", {}, [ + h("td", { text: String(idx + 1), class: "sk-table-row-number" }), + cell("line1"), + cell("line2"), + cell("line3"), + cell("line4"), + cell("line5"), + ]), + ); + } + table.appendChild(tbody); + + // Save-Funktion für Registry (wird bei Navigation und periodisch aufgerufen) + let hasLocalChanges = false; + const saveLocalChanges = () => { + if (hasLocalChanges) { + dispatch({ type: "SET_FREE_ADDRESS_ROWS", rows: [...table._rows] }); + hasLocalChanges = false; + } + }; + + // Bei globaler Registry registrieren + const unregisterSave = registerTableSave(saveLocalChanges); + + // Auto-Save alle 60 Sekunden + const autoSaveInterval = setInterval(saveLocalChanges, 60000); + + // Cleanup-Funktion + table._cleanup = () => { + if (autoSaveInterval) clearInterval(autoSaveInterval); + unregisterSave(); + }; + + // Änderungen tracken bei Input + table.addEventListener("input", () => { + hasLocalChanges = true; + }); + + // Paste-Handler - Daten direkt in globalen State schreiben (OHNE Re-Render) + table.addEventListener("paste", (e) => { + e.preventDefault(); + const text = e.clipboardData.getData("text/plain"); + if (!text) return; + + const pasteRows = text.split(/\r?\n/).filter((r) => r.trim()); + if (pasteRows.length === 0) return; + + const activeInput = document.activeElement; + let startRow = 0; + let startCol = 0; + + if (activeInput && activeInput.tagName === "INPUT") { + const td = activeInput.closest("td"); + const tr = td?.closest("tr"); + if (tr) { + const allRows = Array.from(tbody.children); + startRow = allRows.indexOf(tr); + if (startRow >= 0) { + const allCells = Array.from(tr.children); + const cellIdx = allCells.indexOf(td); + startCol = cellIdx > 0 ? cellIdx - 1 : 0; + } + } + } + + const keys = ["line1", "line2", "line3", "line4", "line5"]; + + // Aktuellen globalen State holen + const globalState = window.currentGlobalState; + if (!globalState) return; + + // Kopie der aktuellen Rows erstellen + const currentRows = Array.isArray(globalState.freeAddressRows) + ? globalState.freeAddressRows.map((r) => ({ ...r })) + : []; + + // Sicherstellen dass genug Zeilen existieren + while (currentRows.length < want) { + currentRows.push({ + line1: "", + line2: "", + line3: "", + line4: "", + line5: "", + }); + } + + // Werte in Daten UND direkt ins DOM schreiben + pasteRows.forEach((rowText, rowOffset) => { + const targetRow = startRow + rowOffset; + if (targetRow >= want) return; + + const cells = rowText.split("\t"); + cells.forEach((cellValue, colOffset) => { + const targetCol = startCol + colOffset; + if (targetCol >= keys.length) return; + const key = keys[targetCol]; + + // Daten aktualisieren + if (!currentRows[targetRow]) { + currentRows[targetRow] = { + line1: "", + line2: "", + line3: "", + line4: "", + line5: "", + }; + } + currentRows[targetRow][key] = cellValue; + + // DOM direkt aktualisieren (kein Re-Render nötig) + const input = table.querySelector( + `input[data-row="${targetRow}"][data-col="${key}"]`, + ); + if (input) input.value = cellValue; + }); + }); + + // Globalen State DIREKT aktualisieren (OHNE dispatch/render) + globalState.freeAddressRows = currentRows; + table._rows = currentRows; + + // Für Persistenz markieren + markPersistDirty(); + }); + + return table; + } + + // Modal öffnen + function openRecipientsModal() { + if (addressMode === "free") { + createTableModal(dispatch, { + title: "Freie Adresse", + columns: [ + { key: "line1", label: "Zeile 1" }, + { key: "line2", label: "Zeile 2" }, + { key: "line3", label: "Zeile 3" }, + { key: "line4", label: "Zeile 4" }, + { key: "line5", label: "Land" }, + ], + getData: (state) => { + const rows = Array.isArray(state.freeAddressRows) + ? state.freeAddressRows + : []; + const normalized = []; + for (let i = 0; i < want; i++) { + normalized.push( + rows[i] || { + line1: "", + line2: "", + line3: "", + line4: "", + line5: "", + }, + ); + } + return normalized; + }, + setData: (dispatch, data) => { + dispatch({ type: "SET_FREE_ADDRESS_ROWS", rows: data }); + }, + stateKey: "freeAddressRows", + rowCount: () => want, + exportFilename: "freie_adressen.csv", + helpText: "Geben Sie bis zu 5 Zeilen pro Empfänger ein.", + getStateFunc: () => currentGlobalState, + }); + } else { + createTableModal(dispatch, { + title: "Empfänger-Daten", + columns: [ + { key: "firstName", label: "Vorname" }, + { key: "lastName", label: "Nachname" }, + { key: "street", label: "Straße" }, + { key: "houseNumber", label: "Nr." }, + { key: "zip", label: "PLZ" }, + { key: "city", label: "Stadt" }, + { + key: "country", + label: "Land", + placeholder: isDirectShipping ? "Deutschland" : "", + }, + ], + getData: (state) => { + const rows = Array.isArray(state.recipientRows) + ? state.recipientRows + : []; + const normalized = []; + for (let i = 0; i < want; i++) { + normalized.push( + rows[i] || { + firstName: "", + lastName: "", + street: "", + houseNumber: "", + zip: "", + city: "", + country: isDirectShipping ? "Deutschland" : "", + }, + ); + } + return normalized; + }, + setData: (dispatch, data) => { + dispatch({ type: "SET_RECIPIENT_ROWS", rows: data }); + }, + stateKey: "recipientRows", + rowCount: () => want, + exportFilename: "empfaenger.csv", + helpText: + "Importieren Sie eine CSV-Datei, fügen Sie Daten aus Excel ein (Strg+V) oder füllen Sie die Tabelle manuell aus.", + getStateFunc: () => currentGlobalState, + }); + } + } + + // Switch-Element für Adressmodus + const addressModeSwitch = h( + "div", + { + class: "sk-address-mode-switch", + style: "display: flex; gap: 8px; margin-bottom: 12px;", + }, + [ + h("button", { + type: "button", + class: + addressMode === "classic" + ? "sk-btn sk-btn-primary" + : "sk-btn sk-btn-outline", + text: "Klassische Adresse", + onclick: () => dispatch({ type: "SET_ADDRESS_MODE", mode: "classic" }), + }), + h("button", { + type: "button", + class: + addressMode === "free" + ? "sk-btn sk-btn-primary" + : "sk-btn sk-btn-outline", + text: "Freie Adresse (bis zu 5 Zeilen)", + onclick: () => dispatch({ type: "SET_ADDRESS_MODE", mode: "free" }), + }), + ], + ); + + const tableContent = + addressMode === "free" ? buildFreeTable(state) : buildClassicTable(state); + + // Porto-Hinweis entfernt + const shippingNotice = null; + + const cardContent = [ + addressModeSwitch, + h("div", { + class: "sk-help", + text: "Importieren Sie eine CSV-Datei, fügen Sie Daten aus Excel ein (Strg+V) oder füllen Sie die Tabelle manuell aus.", + }), + h("button", { + type: "button", + class: "sk-btn", + text: "Tabelle maximieren", + onclick: openRecipientsModal, + }), + h("div", { class: "sk-table-wrapper" }, [tableContent]), + ]; + + if (shippingNotice) { + cardContent.push(shippingNotice); + } + + return renderCard( + "Empfänger-Daten", + addressMode === "free" + ? "Geben Sie bis zu 5 Zeilen pro Empfänger ein" + : "Pflicht: Alle Felder müssen ausgefüllt werden", + cardContent, + ); +} + +// Helper: Kombinierte Platzhalter Tabelle (read-only + editierbar) +function renderCombinedPlaceholderTable( + state, + dispatch, + readOnlyPlaceholders, + editablePlaceholders, + title, +) { + const qty = Number(state.answers.quantity || 0); + const want = qty > 0 ? qty : 0; + + const cleanReadOnly = readOnlyPlaceholders + .map((p) => normalizePlaceholderName(p)) + .filter((p) => !!p); + + const cleanEditable = editablePlaceholders + .map((p) => normalizePlaceholderName(p)) + .filter((p) => !!p); + + const allPlaceholders = [...cleanReadOnly, ...cleanEditable]; + if (allPlaceholders.length === 0) return null; + + // Vorschau-Tabelle bauen + function buildPreviewTable(currentState) { + const table = h( + "table", + { + class: "sk-table sk-table-pasteable", + tabindex: "0", + }, + [], + ); + + // Lokaler Cache für Platzhalter-Werte (kein Re-Render bei Eingabe) + const localValues = {}; + for (const name of cleanEditable) { + localValues[name] = [...(currentState.placeholderValues?.[name] || [])]; + while (localValues[name].length < want) { + localValues[name].push(""); + } + } + + let hasLocalChanges = false; + let autoSaveInterval = null; + + // Funktion zum Speichern der lokalen Änderungen + const saveLocalChanges = () => { + if (hasLocalChanges) { + dispatch({ type: "SET_PLACEHOLDER_VALUES", values: localValues }); + hasLocalChanges = false; + } + }; + + // Bei globaler Registry registrieren (für flush vor Navigation) + const unregisterSave = registerTableSave(saveLocalChanges); + + // Auto-Save alle 60 Sekunden + autoSaveInterval = setInterval(saveLocalChanges, 60000); + + // Cleanup-Funktion für Interval und Registry + table._cleanup = () => { + if (autoSaveInterval) clearInterval(autoSaveInterval); + unregisterSave(); + }; + + // Header + table.appendChild( + h("thead", {}, [ + h("tr", {}, [ + h("th", { text: "Nr.", class: "sk-table-row-number" }), + ...allPlaceholders.map((p) => { + const isReadOnly = cleanReadOnly.includes(p); + return h("th", { + text: `[[${p}]]`, + class: isReadOnly ? "sk-table-readonly-header" : "", + }); + }), + ]), + ]), + ); + + // Body + const tbody = h("tbody", {}, []); + for (let row = 0; row < want; row++) { + const tr = h("tr", {}, [ + h("td", { text: String(row + 1), class: "sk-table-row-number" }), + ]); + + for (const name of allPlaceholders) { + const isReadOnly = cleanReadOnly.includes(name); + + let val = ""; + // Bei Empfängerdaten: vorname, name, ort aus recipientRows holen + if ( + currentState.answers.envelopeMode === "recipientData" && + (name === "vorname" || name === "name" || name === "ort") + ) { + const recipients = currentState.recipientRows || []; + const recipient = recipients[row] || {}; + if (name === "vorname") val = recipient.firstName || ""; + else if (name === "name") val = recipient.lastName || ""; + else if (name === "ort") val = recipient.city || ""; + } else if (isReadOnly) { + val = (currentState.placeholderValues?.[name] || [])[row] || ""; + } else { + val = localValues[name][row] || ""; + } + + tr.appendChild( + h("td", { class: isReadOnly ? "sk-table-readonly-cell" : "" }, [ + h("input", { + class: "sk-input", + type: "text", + value: val, + readonly: isReadOnly, + "data-row": row, + "data-col": name, + "data-sk-focus": `placeholder.${name}.${row}`, + oninput: (e) => { + if (!isReadOnly) { + // Nur lokal speichern - kein Re-Render! + localValues[name][row] = e.target.value; + hasLocalChanges = true; + + // Auch globalen State aktualisieren für SYNC_GLOBAL_STATE + const globalState = window.currentGlobalState; + if (globalState) { + if (!globalState.placeholderValues) { + globalState.placeholderValues = {}; + } + if (!globalState.placeholderValues[name]) { + globalState.placeholderValues[name] = []; + } + globalState.placeholderValues[name][row] = e.target.value; + // Array auf requiredRowCount-Länge bringen (für validateStep) + while (globalState.placeholderValues[name].length < want) { + globalState.placeholderValues[name].push(""); + } + } + } + }, + }), + ]), + ); + } + tbody.appendChild(tr); + } + table.appendChild(tbody); + + // Paste-Handler für Excel-Daten + table.addEventListener("paste", (e) => { + e.preventDefault(); + const text = e.clipboardData.getData("text/plain"); + if (!text) return; + + const pasteRows = text.split(/\r?\n/).filter((r) => r.trim()); + if (pasteRows.length === 0) return; + + // Startposition finden + const activeInput = document.activeElement; + let startRow = 0; + let startCol = 0; + + if (activeInput && activeInput.tagName === "INPUT") { + const td = activeInput.closest("td"); + const tr = td?.closest("tr"); + if (tr) { + const allRows = Array.from(tbody.children); + startRow = allRows.indexOf(tr); + if (startRow >= 0) { + const allCells = Array.from(tr.children); + const cellIdx = allCells.indexOf(td); + // cellIdx 0 ist die Nummer-Spalte, daher -1 für die Daten-Spalten + startCol = cellIdx > 0 ? cellIdx - 1 : 0; + } + } + } + + // Aktuellen globalen State holen + const globalState = window.currentGlobalState; + if (!globalState) return; + + // Kopie der aktuellen Platzhalter-Werte erstellen + const currentValues = globalState.placeholderValues + ? JSON.parse(JSON.stringify(globalState.placeholderValues)) + : {}; + + // Alle Änderungen in currentValues sammeln und Inputs aktualisieren + let pastedCells = 0; + pasteRows.forEach((rowText, rowOffset) => { + const targetRow = startRow + rowOffset; + if (targetRow >= want) return; + + const cells = rowText.split("\t"); + cells.forEach((cellValue, colOffset) => { + const targetCol = startCol + colOffset; + if (targetCol >= allPlaceholders.length) return; + + const name = allPlaceholders[targetCol]; + const isReadOnly = cleanReadOnly.includes(name); + + // Nur editierbare Spalten aktualisieren + if (!isReadOnly) { + // Daten aktualisieren + if (!currentValues[name]) currentValues[name] = []; + currentValues[name][targetRow] = cellValue; + localValues[name][targetRow] = cellValue; + + // Input-Element direkt aktualisieren + const input = table.querySelector( + `input[data-row="${targetRow}"][data-col="${name}"]`, + ); + if (input) input.value = cellValue; + pastedCells++; + } + }); + }); + + // Globalen State DIREKT aktualisieren (OHNE dispatch/render) + if (pastedCells > 0) { + // Arrays auf want-Länge normalisieren (für validateStep) + for (const name of Object.keys(currentValues)) { + while (currentValues[name].length < want) + currentValues[name].push(""); + } + globalState.placeholderValues = currentValues; + markPersistDirty(); + } + }); + + return table; + } + + // Modal mit generischer Funktion öffnen + function openCombinedModal() { + createTableModal(dispatch, { + title: title, + columns: allPlaceholders.map((name) => ({ + key: name, + label: `[[${name}]]`, + readOnly: cleanReadOnly.includes(name), + })), + getData: (state) => { + const qty = Number(state.answers.quantity || 0); + const want = qty > 0 ? qty : 0; + const result = []; + for (let row = 0; row < want; row++) { + const rowData = {}; + for (const name of allPlaceholders) { + // Bei Empfängerdaten: vorname, name, ort aus recipientRows holen + if ( + state.answers.envelopeMode === "recipientData" && + (name === "vorname" || name === "name" || name === "ort") + ) { + const recipients = state.recipientRows || []; + const recipient = recipients[row] || {}; + if (name === "vorname") rowData[name] = recipient.firstName || ""; + else if (name === "name") + rowData[name] = recipient.lastName || ""; + else if (name === "ort") rowData[name] = recipient.city || ""; + } else { + rowData[name] = + (state.placeholderValues?.[name] || [])[row] || ""; + } + } + result.push(rowData); + } + return result; + }, + setData: (dispatch, data) => { + // Nur editierbare Felder updaten + data.forEach((rowData, rowIdx) => { + cleanEditable.forEach((name) => { + dispatch({ + type: "SET_PLACEHOLDER_VALUE", + name, + row: rowIdx, + value: rowData[name] || "", + }); + }); + }); + }, + rowCount: (state) => { + const qty = Number(state.answers.quantity || 0); + return qty > 0 ? qty : 0; + }, + exportFilename: "platzhalter.csv", + infoText: + "Bitte erstellen Sie neue Platzhalter über die Weboberfläche. Anschließend können Sie auch diese importieren.", + helpText: + "Nutzen Sie [[platzhalter]] zur Individualisierung im Text. Vorhandene Platzhalter (grau hinterlegt) kommen aus dem Schritt Umschlag, neue können Sie selbst definieren.", + getStateFunc: () => currentGlobalState, + }); + } + + return renderCard(title, null, [ + h("button", { + type: "button", + class: "sk-btn", + text: "Tabelle maximieren", + onclick: openCombinedModal, + }), + h("div", { class: "sk-table-wrapper" }, [buildPreviewTable(state)]), + (() => { + const helpUrl = + window.SkriftConfigurator?.settings?.font_sample + ?.placeholder_help_url || ""; + const elements = [ + "Nutzen Sie ", + h("strong", { text: "[[platzhalter]]" }), + " zur Individualisierung im Text. Vorhandene Platzhalter (grau hinterlegt) kommen aus dem Schritt Umschlag, neue können Sie selbst definieren.", + ]; + if (helpUrl) { + elements.push(" "); + elements.push( + h("a", { + href: helpUrl, + target: "_blank", + rel: "noopener noreferrer", + style: "color: #0073aa; text-decoration: underline;", + text: "Mehr erfahren →", + }), + ); + } + return h( + "div", + { class: "sk-help", style: "margin-top: 12px;" }, + elements, + ); + })(), + ]); +} + +// Helper: Platzhalter Tabelle (mit Maximieren-Button und Modal) +function renderPlaceholderTable( + state, + dispatch, + placeholderNames, + title, + readOnly = false, + helpText = null, +) { + const qty = Number(state.answers.quantity || 0); + const want = qty > 0 ? qty : 0; + + const clean = placeholderNames + .map((p) => normalizePlaceholderName(p)) + .filter((p) => !!p); + if (clean.length === 0) return null; + + // Vorschau-Tabelle bauen + function buildPreviewTable(currentState) { + const table = h( + "table", + { + class: "sk-table sk-table-pasteable", + tabindex: "0", + }, + [], + ); + + table.appendChild( + h("thead", {}, [ + h("tr", {}, [ + h("th", { text: "Nr.", class: "sk-table-row-number" }), + ...clean.map((p) => h("th", { text: `[[${p}]]` })), + ]), + ]), + ); + + // Lokaler Cache für Platzhalter-Werte (wird bei focusout synchronisiert) + const localValues = {}; + for (const name of clean) { + localValues[name] = [...(currentState.placeholderValues?.[name] || [])]; + // Array auf richtige Länge bringen + while (localValues[name].length < want) { + localValues[name].push(""); + } + } + + const tbody = h("tbody", {}, []); + for (let row = 0; row < want; row++) { + const tr = h("tr", {}, [ + h("td", { text: String(row + 1), class: "sk-table-row-number" }), + ]); + for (const name of clean) { + const val = localValues[name][row] || ""; + tr.appendChild( + h("td", {}, [ + h("input", { + class: "sk-input", + type: "text", + value: val, + "data-row": row, + "data-col": name, + "data-sk-focus": `placeholder.${name}.${row}`, + oninput: (e) => { + // Wert nur lokal speichern (kein Re-Render) + localValues[name][row] = e.target.value; + + // Auch globalen State aktualisieren für SYNC_GLOBAL_STATE + const globalState = window.currentGlobalState; + if (globalState) { + if (!globalState.placeholderValues) { + globalState.placeholderValues = {}; + } + if (!globalState.placeholderValues[name]) { + globalState.placeholderValues[name] = []; + } + globalState.placeholderValues[name][row] = e.target.value; + // Array auf requiredRowCount-Länge bringen (für validateStep) + while (globalState.placeholderValues[name].length < want) { + globalState.placeholderValues[name].push(""); + } + } + }, + }), + ]), + ); + } + tbody.appendChild(tr); + } + table.appendChild(tbody); + + // Save-Funktion für Registry (wird bei Navigation und periodisch aufgerufen) + let hasLocalChanges = false; + const saveLocalChanges = () => { + if (hasLocalChanges) { + dispatch({ type: "SET_PLACEHOLDER_VALUES", values: localValues }); + hasLocalChanges = false; + } + }; + + // Bei globaler Registry registrieren + const unregisterSave = registerTableSave(saveLocalChanges); + + // Auto-Save alle 60 Sekunden + const autoSaveInterval = setInterval(saveLocalChanges, 60000); + + // Cleanup-Funktion + table._cleanup = () => { + if (autoSaveInterval) clearInterval(autoSaveInterval); + unregisterSave(); + }; + + // Änderungen tracken bei Input + table.addEventListener("input", () => { + hasLocalChanges = true; + }); + + // Paste-Handler für Excel-Daten - analog zur Empfängertabelle + table.addEventListener("paste", (e) => { + e.preventDefault(); + const text = e.clipboardData.getData("text/plain"); + if (!text) return; + + const pasteRows = text.split(/\r?\n/).filter((r) => r.trim()); + if (pasteRows.length === 0) return; + + // Startposition finden + const activeInput = document.activeElement; + let startRow = 0; + let startCol = 0; + + if (activeInput && activeInput.tagName === "INPUT") { + const td = activeInput.closest("td"); + const tr = td?.closest("tr"); + if (tr) { + const allRows = Array.from(tbody.children); + startRow = allRows.indexOf(tr); + if (startRow >= 0) { + const allCells = Array.from(tr.children); + const cellIdx = allCells.indexOf(td); + startCol = cellIdx > 0 ? cellIdx - 1 : 0; + } + } + } + + // Aktuellen globalen State holen + const globalState = window.currentGlobalState; + if (!globalState) return; + + // Kopie der aktuellen Platzhalter-Werte erstellen + const currentValues = globalState.placeholderValues + ? JSON.parse(JSON.stringify(globalState.placeholderValues)) + : {}; + + // Alle Änderungen sammeln UND direkt ins DOM schreiben + let pastedCells = 0; + pasteRows.forEach((rowText, rowOffset) => { + const targetRow = startRow + rowOffset; + if (targetRow >= want) return; + + const cells = rowText.split("\t"); + cells.forEach((cellValue, colOffset) => { + const targetCol = startCol + colOffset; + if (targetCol >= clean.length) return; + + const name = clean[targetCol]; + + // Daten aktualisieren + if (!currentValues[name]) currentValues[name] = []; + currentValues[name][targetRow] = cellValue; + localValues[name][targetRow] = cellValue; + + // DOM direkt aktualisieren (kein Re-Render nötig) + const input = table.querySelector( + `input[data-row="${targetRow}"][data-col="${name}"]`, + ); + if (input) input.value = cellValue; + + pastedCells++; + }); + }); + + // Globalen State DIREKT aktualisieren (OHNE dispatch/render) + if (pastedCells > 0) { + // Arrays auf want-Länge normalisieren (für validateStep) + for (const name of Object.keys(currentValues)) { + while (currentValues[name].length < want) + currentValues[name].push(""); + } + globalState.placeholderValues = currentValues; + markPersistDirty(); + } + }); + + return table; + } + + // Modal mit generischer Funktion öffnen + function openPlaceholderModal() { + createTableModal(dispatch, { + title: title, + columns: clean.map((name) => ({ + key: name, + label: `[[${name}]]`, + })), + getData: (state) => { + const qty = Number(state.answers.quantity || 0); + const want = qty > 0 ? qty : 0; + const result = []; + for (let row = 0; row < want; row++) { + const rowData = {}; + clean.forEach((name) => { + rowData[name] = (state.placeholderValues?.[name] || [])[row] || ""; + }); + result.push(rowData); + } + return result; + }, + setData: (dispatch, data) => { + // Daten in einzelne SET_PLACEHOLDER_VALUE Aktionen umwandeln + data.forEach((rowData, rowIdx) => { + clean.forEach((name) => { + dispatch({ + type: "SET_PLACEHOLDER_VALUE", + name, + row: rowIdx, + value: rowData[name] || "", + }); + }); + }); + }, + rowCount: (state) => { + const qty = Number(state.answers.quantity || 0); + return qty > 0 ? qty : 0; + }, + exportFilename: "platzhalter.csv", + infoText: + "Bitte erstellen Sie neue Platzhalter über die Weboberfläche. Anschließend können Sie auch diese importieren.", + helpText: + "Importieren Sie eine CSV-Datei, fügen Sie Daten aus Excel ein (Strg+V) oder füllen Sie die Tabelle manuell aus.", + getStateFunc: () => currentGlobalState, + }); + } + + const defaultHelpText = + "Importieren Sie eine CSV-Datei, fügen Sie Daten aus Excel ein (Strg+V) oder füllen Sie die Tabelle manuell aus."; + + return renderCard(title, null, [ + h("button", { + type: "button", + class: "sk-btn", + text: "Tabelle maximieren", + onclick: openPlaceholderModal, + }), + h("div", { class: "sk-table-wrapper" }, [buildPreviewTable(state)]), + h("div", { class: "sk-help", style: "margin-top: 12px;" }, [ + helpText || defaultHelpText, + ]), + ]); +} + +// Preview Renderer +function renderPreview(dom, state, dispatch) { + // Prüfe ob wir eine existierende Vorschau-Box haben + const existingPreviewCard = dom.preview.querySelector(".sk-preview-card"); + const existingPreviewBox = + existingPreviewCard?.querySelector(".sk-preview-box"); + const hasActivePreview = + existingPreviewBox && + !existingPreviewBox.querySelector(".sk-preview-placeholder"); + + // Prüfe ob Step gespeichert ist und ob er sich geändert hat + const lastStep = dom.preview.dataset.lastStep; + const currentStep = state.step; + const stepChanged = lastStep && lastStep !== String(currentStep); + + // Bei Step-Wechsel: Immer clearen und Preview-Manager clearen + if (stepChanged) { + clear(dom.preview); + // Preview Manager resetten + if (window.envelopePreviewManager) { + window.envelopePreviewManager.currentBatchPreviews = []; + window.envelopePreviewManager.currentBatchIndex = 0; + window.envelopePreviewManager.previewCount = 0; + } + if (window.contentPreviewManager) { + window.contentPreviewManager.currentBatchPreviews = []; + window.contentPreviewManager.currentBatchIndex = 0; + window.contentPreviewManager.previewCount = 0; + } + } + // Sonst: Nur clearen wenn keine Preview geladen ist + else { + const hasEnvelopePreview = + window.envelopePreviewManager?.currentBatchPreviews?.length > 0; + const hasContentPreview = + window.contentPreviewManager?.currentBatchPreviews?.length > 0; + + if (!hasEnvelopePreview && !hasContentPreview) { + clear(dom.preview); + } + } + + // Step speichern für nächstes Mal + dom.preview.dataset.lastStep = String(currentStep); + + // Show contact card on product, quantity, customer data and review step + // (statt Vorschau bei Produkt und Allgemein) + if ( + state.step === STEPS.PRODUCT || + state.step === STEPS.QUANTITY || + state.step === STEPS.CUSTOMER_DATA || + state.step === STEPS.REVIEW + ) { + clear(dom.preview); // Hier immer clearen, da Contact Card + const contactCard = renderCard("Noch Fragen?", null, [ + h("p", { + style: + "margin: 0 0 20px; font-size: 14px; color: #666; line-height: 1.6;", + text: "Sie haben Fragen zu Ihrer Bestellung oder möchten eine individuelle Beratung? Wir sind gerne für Sie da und helfen Ihnen weiter!", + }), + h( + "div", + { + style: + "display: flex; align-items: center; gap: 12px; margin-bottom: 12px;", + }, + [ + h("svg", { + xmlns: "http://www.w3.org/2000/svg", + width: "20", + height: "20", + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + "stroke-width": "2", + "stroke-linecap": "round", + "stroke-linejoin": "round", + style: "flex-shrink: 0; color: #0073aa;", + innerHTML: + '', + }), + h("a", { + href: "tel:+4968722153", + style: + "font-size: 15px; color: #333; text-decoration: none; font-weight: 500;", + text: "+49 (0)6872 2153", + }), + ], + ), + h("div", { style: "display: flex; align-items: center; gap: 12px;" }, [ + h("svg", { + xmlns: "http://www.w3.org/2000/svg", + width: "20", + height: "20", + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + "stroke-width": "2", + "stroke-linecap": "round", + "stroke-linejoin": "round", + style: "flex-shrink: 0; color: #0073aa;", + innerHTML: + '', + }), + h("a", { + href: "mailto:hello@skrift.de", + style: + "font-size: 15px; color: #333; text-decoration: none; font-weight: 500;", + text: "hello@skrift.de", + }), + ]), + ]); + + dom.preview.appendChild(contactCard); + // Auch für Contact Card mobile synchronisieren + syncMobilePreview(dom); + return; + } + + const envType = calcEffectiveEnvelopeType(state); + const previewTitle = state?.ctx?.product?.label + ? `Vorschau: ${state.ctx.product.label}` + : "Vorschau"; + + // Format-Informationen ermitteln + const fmt = state.answers.format; + const productFormat = + fmt === "a6p" + ? "A6 Hochformat" + : fmt === "a6l" + ? "A6 Querformat" + : fmt === "a4" + ? "A4" + : fmt?.toUpperCase() || "A4"; + const envelopeFormat = + envType === "dinlang" ? "DIN lang" : envType === "c6" ? "C6" : ""; + + // Formatinformationen für Umschlag-Step + let formatInfo = ""; + if (state.step === STEPS.ENVELOPE && envelopeFormat) { + formatInfo = `Umschlagformat: ${envelopeFormat}`; + } + // Formatinformationen für Content-Step + else if (state.step === STEPS.CONTENT) { + formatInfo = `Schriftstückformat: ${productFormat}`; + } + + const content = [ + // Header mit Titel und Navigation + h( + "div", + { + class: "sk-preview-header", + style: + "display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px;", + }, + [ + h( + "div", + { style: "flex: 1;" }, + [ + h("div", { class: "sk-preview-title", text: previewTitle }), + formatInfo + ? h("div", { + class: "sk-preview-sub", + text: formatInfo, + }) + : null, + ].filter(Boolean), + ), + h("div", { class: "sk-preview-navigation-container" }), + ], + ), + h("div", { class: "sk-preview-box" }, [ + h("div", { + class: "sk-preview-placeholder", + text: "Hier können Sie sich die Vorschau Ihres Produktes anzeigen lassen.", + }), + ]), + // Status Anzeige direkt unter dem Dokument + h("div", { + class: "sk-preview-status", + style: + "margin-top: 10px; font-size: 14px; color: #666; text-align: center;", + }), + ].filter(Boolean); + + // Vorschau-Button für Umschlag (nur im Envelope-Step) + if (state.step === STEPS.ENVELOPE) { + const canGenerateEnvelopePreview = + state.answers.shippingMode === "direct" || + (state.answers.shippingMode === "bulk" && + state.answers.envelope === true && + (state.answers.envelopeMode === "recipientData" || + state.answers.envelopeMode === "customText")); + + if (canGenerateEnvelopePreview) { + const manager = window.envelopePreviewManager; + const requestsRemaining = manager?.requestsRemaining || 10; + const maxRequests = manager?.maxRequests || 10; + + // Request Counter + content.push( + h("div", { + class: "sk-preview-request-counter", + style: + "margin-top: 10px; padding: 8px; background: #e3f2fd; border: 1px solid #2196f3; border-radius: 4px; color: #1565c0; font-size: 14px; text-align: center; font-weight: 500;", + text: `Verbleibende Vorschau-Anfragen: ${requestsRemaining}/${maxRequests}`, + }), + ); + + // Button + content.push( + h("button", { + type: "button", + class: "sk-btn sk-btn-secondary sk-preview-generate-btn", + style: "width: 100%; margin-top: 10px;", + text: "Umschlag Vorschau generieren", + onclick: async () => { + if (window.envelopePreviewManager) { + // WICHTIG: Aktuellen State verwenden, nicht den gecachten aus dem Closure + const currentState = window.currentGlobalState || state; + await window.envelopePreviewManager.generatePreviews( + currentState, + dom.preview, + true, + ); + } + }, + }), + ); + } + } + + // Vorschau-Button für Schriftstück (nur im Content-Step) + if (state.step === STEPS.CONTENT) { + const canGenerateContentPreview = + state.answers.contentCreateMode === "self"; + + if (canGenerateContentPreview) { + const manager = window.contentPreviewManager; + const requestsRemaining = manager?.requestsRemaining || 10; + const maxRequests = manager?.maxRequests || 10; + + // Request Counter + content.push( + h("div", { + class: "sk-preview-request-counter", + style: + "margin-top: 10px; padding: 8px; background: #e3f2fd; border: 1px solid #2196f3; border-radius: 4px; color: #1565c0; font-size: 14px; text-align: center; font-weight: 500;", + text: `Verbleibende Vorschau-Anfragen: ${requestsRemaining}/${maxRequests}`, + }), + ); + + // Button + content.push( + h("button", { + type: "button", + class: "sk-btn sk-btn-secondary sk-preview-generate-btn", + style: "width: 100%; margin-top: 10px;", + text: "Vorschau generieren", + onclick: async () => { + if (window.contentPreviewManager) { + // WICHTIG: Aktuellen State verwenden, nicht den gecachten aus dem Closure + const currentState = window.currentGlobalState || state; + const result = + await window.contentPreviewManager.generatePreviews( + currentState, + dom.preview, + false, + ); + + // Overflow-Warnung anzeigen wenn nötig + const formContainer = document.getElementById("sk-form"); + if (result && result.hasOverflow && formContainer) { + showOverflowWarning(result.overflowFiles, formContainer); + } else if (formContainer) { + hideOverflowWarning(formContainer); + } + } + }, + }), + ); + } + } + + // Nur hinzufügen wenn keine Preview-Card existiert + const existingCard = dom.preview.querySelector(".sk-preview-card"); + if (!existingCard) { + dom.preview.appendChild(h("div", { class: "sk-preview-card" }, content)); + } else { + // Preview-Card existiert bereits - nur Button und Counter aktualisieren + const existingCounter = existingCard.querySelector( + ".sk-preview-request-counter", + ); + const newCounter = content.find( + (el) => el && el.class === "sk-preview-request-counter", + ); + if (existingCounter && newCounter) { + existingCounter.textContent = newCounter.text; + } + } + + // Mobile Preview synchronisieren + syncMobilePreview(dom); +} + +/** + * Synchronisiert den Mobile Preview Container mit dem Desktop Preview + * Klont den Inhalt und passt Event-Handler für die Buttons an + * + * Mobile Layout: + * - previewMobile: Nur echte Vorschau-Inhalte (vor dem Weiter-Button) + * - contactMobile: Nur Contact Card "Noch Fragen?" (nach dem Weiter-Button) + */ +function syncMobilePreview(dom) { + // Prüfe ob Contact Card im Desktop-Preview ist + const hasContactCard = + dom.preview.querySelector(".sk-card-head h3")?.textContent === + "Noch Fragen?"; + + // Contact Card Mobile synchronisieren + if (dom.contactMobile) { + clear(dom.contactMobile); + if (hasContactCard) { + // Contact Card in den Mobile-Contact-Container klonen + dom.contactMobile.innerHTML = dom.preview.innerHTML; + } + } + + // Preview Mobile synchronisieren - nur wenn KEINE Contact Card + if (dom.previewMobile) { + clear(dom.previewMobile); + + // Nur echte Vorschau-Inhalte klonen (nicht Contact Card) + if (!hasContactCard) { + const desktopContent = dom.preview.innerHTML; + if (desktopContent) { + dom.previewMobile.innerHTML = desktopContent; + + // Event-Handler für Buttons im Mobile-Container neu binden + bindMobilePreviewHandlers(dom); + } + } + } +} + +/** + * Bindet Event-Handler für den Mobile Preview Container + */ +function bindMobilePreviewHandlers(dom) { + if (!dom.previewMobile) return; + + const mobileGenerateBtn = dom.previewMobile.querySelector( + ".sk-preview-generate-btn", + ); + if (mobileGenerateBtn) { + mobileGenerateBtn.onclick = async () => { + const currentState = window.currentGlobalState; + if (!currentState) return; + + // Bestimme welcher Manager verwendet werden soll basierend auf dem aktuellen Step + const isEnvelope = currentState.step === 2; // STEPS.ENVELOPE + const manager = isEnvelope + ? window.envelopePreviewManager + : window.contentPreviewManager; + + if (manager) { + // Preview in BEIDEN Containern aktualisieren + const result = await manager.generatePreviews( + currentState, + dom.preview, + isEnvelope, + ); + // Nach Generierung nochmal synchronisieren + syncMobilePreview(dom); + + // Overflow-Warnung bei Briefen (nicht Umschläge) + if (!isEnvelope) { + const formContainer = document.getElementById("sk-form"); + if (result && result.hasOverflow && formContainer) { + showOverflowWarning(result.overflowFiles, formContainer); + } else if (formContainer) { + hideOverflowWarning(formContainer); + } + } + } + }; + } + + // Navigation Buttons im Mobile-Container + const mobilePrevBtn = dom.previewMobile.querySelector(".sk-preview-nav-prev"); + const mobileNextBtn = dom.previewMobile.querySelector(".sk-preview-nav-next"); + + if (mobilePrevBtn) { + mobilePrevBtn.onclick = () => { + // Finde und klicke den Desktop-Button + const desktopBtn = dom.preview.querySelector(".sk-preview-nav-prev"); + if (desktopBtn) desktopBtn.click(); + // Nach Navigation synchronisieren + setTimeout(() => syncMobilePreview(dom), 50); + }; + } + + if (mobileNextBtn) { + mobileNextBtn.onclick = () => { + // Finde und klicke den Desktop-Button + const desktopBtn = dom.preview.querySelector(".sk-preview-nav-next"); + if (desktopBtn) desktopBtn.click(); + // Nach Navigation synchronisieren + setTimeout(() => syncMobilePreview(dom), 50); + }; + } + + // Klick auf Preview-Box für Modal + const mobilePreviewBox = dom.previewMobile.querySelector(".sk-preview-box"); + if (mobilePreviewBox) { + mobilePreviewBox.onclick = () => { + // Desktop-Preview-Box klicken, um Modal zu öffnen + const desktopBox = dom.preview.querySelector(".sk-preview-box"); + if (desktopBox) desktopBox.click(); + }; + } +} + +// Main Render Function +export function render({ state, dom, dispatch }) { + // Globalen State aktualisieren für Modals und PreviewManager + currentGlobalState = state; + window.currentGlobalState = state; + + // noPrice-Modus: CSS-Klasse auf Root-Element setzen + const root = dom.form?.closest(".sk-configurator"); + if (root) { + if (state.noPrice) { + root.classList.add("sk-no-price"); + } else { + root.classList.remove("sk-no-price"); + } + } + + // Periodische Speicherung starten (nur einmal) + startPersistInterval(); + + // Hydration VOR dem Rendern prüfen - wenn hydriert wird, + // wird ein neuer Render mit dem hydrierten State ausgelöst, + // daher diesen Render-Durchlauf abbrechen + const didHydrate = hydrateIfPossible(state, dispatch); + if (didHydrate) return; + + if (dom.stepper) renderStepper(dom.stepper, state, dispatch); + renderTopbar(dom, state); + + const focusSnapshot = captureFocus(dom); + + clear(dom.form); + + let view = null; + switch (state.step) { + case STEPS.PRODUCT: + view = renderProductStep(state, dispatch); + break; + case STEPS.QUANTITY: + view = renderQuantityStep(state, dispatch); + break; + case STEPS.ENVELOPE: + view = renderEnvelopeStep(state, dispatch); + break; + case STEPS.CONTENT: + view = renderContentStep(state, dispatch); + break; + case STEPS.CUSTOMER_DATA: + view = renderCustomerDataStep(state, dispatch); + break; + case STEPS.REVIEW: + view = renderReviewStep(state, dispatch); + break; + } + + if (view) dom.form.appendChild(view); + + renderPreview(dom, state, dispatch); + + restoreFocus(dom, focusSnapshot); + + dom.prev.disabled = state.history.length === 0; + dom.next.disabled = !validateStep(state) || state.step === STEPS.REVIEW; + dom.next.textContent = + state.step === STEPS.CUSTOMER_DATA ? "Weiter zur Prüfung" : "Weiter"; + + writePersisted(state); +} diff --git a/Webseite/skrift-configurator/assets/js/configurator-utils.js b/Webseite/skrift-configurator/assets/js/configurator-utils.js new file mode 100644 index 0000000..bac3836 --- /dev/null +++ b/Webseite/skrift-configurator/assets/js/configurator-utils.js @@ -0,0 +1,96 @@ +/** + * Gemeinsame Utility-Funktionen für Skrift Konfigurator + */ + +/** + * Bereitet Platzhalter für einen bestimmten Index vor + * Wird von PreviewManager und Backend-Integration verwendet + */ +export function preparePlaceholdersForIndex(state, index) { + const placeholders = {}; + + // Prüfen ob strukturierte Empfängerdaten (klassische Adresse) im aktuellen Flow verwendet werden: + // - Adressmodus muss 'classic' sein (bei 'free' gibt es keine Felder wie vorname, name etc.) + // - UND: Direktversand ODER Kuvert mit Empfängeradresse + const isClassicAddress = (state.addressMode || 'classic') === 'classic'; + const needsRecipientData = isClassicAddress && ( + state.answers?.shippingMode === 'direct' || + (state.answers?.envelope === true && state.answers?.envelopeMode === 'recipientData') + ); + + // Empfänger-Feldnamen, die aus recipientRows kommen können + const recipientFields = ['vorname', 'name', 'ort', 'strasse', 'hausnummer', 'plz', 'land']; + + // Platzhalter aus placeholderValues extrahieren + if (state.placeholderValues) { + for (const [name, values] of Object.entries(state.placeholderValues)) { + // Empfängerfelder nur überspringen wenn sie aus recipientRows kommen + if (needsRecipientData && recipientFields.includes(name)) { + continue; + } + if (Array.isArray(values) && values.length > index) { + placeholders[name] = values[index]; + } + } + } + + // Empfängerdaten aus recipientRows hinzufügen (nur wenn benötigt) + if (needsRecipientData && Array.isArray(state.recipientRows) && state.recipientRows.length > index) { + const recipient = state.recipientRows[index]; + placeholders['vorname'] = recipient.firstName || ''; + placeholders['name'] = recipient.lastName || ''; + placeholders['ort'] = recipient.city || ''; + placeholders['strasse'] = recipient.street || ''; + placeholders['hausnummer'] = recipient.houseNumber || ''; + placeholders['plz'] = recipient.zip || ''; + placeholders['land'] = recipient.country || ''; + } + + return placeholders; +} + +/** + * Validiert Empfängerzeilen (gemeinsame Logik für klassische und freie Adressen) + */ +export function validateRecipientRows(state, requiredCount) { + const addressMode = state.addressMode || 'classic'; + + if (addressMode === 'free') { + // Freie Adresse: mindestens Zeile 1 muss ausgefüllt sein + const rows = state.freeAddressRows || []; + if (rows.length !== requiredCount) return false; + + for (const r of rows) { + if (!r) return false; + if (!String(r.line1 || "").trim()) return false; + } + } else { + // Klassische Adresse + const rows = state.recipientRows || []; + if (rows.length !== requiredCount) return false; + + const required = [ + "firstName", + "lastName", + "street", + "houseNumber", + "zip", + "city", + "country", + ]; + + for (const r of rows) { + if (!r) return false; + for (const k of required) { + if (!String(r[k] || "").trim()) return false; + } + } + } + + return true; +} + +export default { + preparePlaceholdersForIndex, + validateRecipientRows, +}; diff --git a/Webseite/skrift-configurator/assets/js/price-calculator.js b/Webseite/skrift-configurator/assets/js/price-calculator.js new file mode 100644 index 0000000..0f49c60 --- /dev/null +++ b/Webseite/skrift-configurator/assets/js/price-calculator.js @@ -0,0 +1,1151 @@ +/** + * Skrift Preisrechner + * Einfacher Preisrechner mit 2-Step-Flow: Produktauswahl -> Details & Berechnung + * Nutzt die gleiche Preislogik und Produktdefinitionen wie der Konfigurator + */ + +import { + formatEUR, +} from "./configurator-pricing.js?ver=0.3.0"; + +import { + listProducts, + getAvailableProductsForCustomerType, + isFollowups as checkIsFollowups, + supportsMotif as checkSupportsMotif, +} from "./configurator-state.js?ver=0.3.0"; + +// ========== KONSTANTEN ========== + +const STEPS = { + PRODUCT: 0, + CALCULATOR: 1, +}; + +// ========== STATE MANAGEMENT ========== + +function createInitialState() { + return { + step: STEPS.PRODUCT, + customerType: null, // 'business' | 'private' + product: null, + // Calculator-Felder - Defaults auf günstigste Optionen + quantity: 100, + format: 'a6p', // a6p ist günstiger als a4 (kein Aufpreis) + shippingMode: 'bulk', // Sammelversand ist günstiger (nur Pauschale statt pro Stück) + envelope: false, // Kein Kuvert ist günstiger + envelopeMode: 'none', // Keine Beschriftung ist günstiger + followupYearlyVolume: '50-199', + followupCreateMode: 'manual', // Manuell ist günstiger (keine API-Kosten) + // Motiv (nur für Postkarten/Einladungen mit A6) + motifNeed: false, // Kein Motiv ist günstiger + motifSource: null, // upload, design + // Inhalt + contentCreateMode: 'self', // Selbst erstellen ist günstiger + }; +} + +function reducer(state, action) { + switch (action.type) { + case 'SET_CUSTOMER_TYPE': + return { + ...state, + customerType: action.customerType, + product: null, // Reset Produkt bei Kundentyp-Wechsel + }; + + case 'SET_PRODUCT': { + // Default-Format: Günstigstes zuerst (A6 hat keinen Aufpreis bei Postkarten/Einladungen) + // Bei Produkten mit supportsMotif ist A6 günstiger, also bevorzugen + const formats = action.product.formats || []; + const hasA6 = formats.some(f => f === 'a6p' || f === 'a6l'); + const hasA4 = formats.includes('a4'); + // A6 bevorzugen wenn verfügbar (günstiger), sonst erstes Format + const defaultFormat = hasA6 ? 'a6p' : (formats[0] || 'a4'); + + // Default-Menge je nach Kundentyp + const settings = window.SkriftPreisrechner?.settings || window.SkriftConfigurator?.settings || {}; + const dynamicPricing = settings.dynamic_pricing || {}; + const isB2B = state.customerType === 'business'; + const normalQty = isB2B + ? (dynamicPricing.business_normal_quantity || 200) + : (dynamicPricing.private_normal_quantity || 50); + + return { + ...state, + product: action.product, + format: defaultFormat, + quantity: normalQty, + step: STEPS.CALCULATOR, + // Reset Motiv bei Produktwechsel + motifNeed: false, + motifSource: null, + }; + } + + case 'SET_QUANTITY': + return { ...state, quantity: action.quantity }; + + case 'SET_FORMAT': + return { + ...state, + format: action.format, + // Motiv zurücksetzen wenn nicht mehr A6 + motifNeed: action.format !== 'a4' ? state.motifNeed : false, + motifSource: action.format !== 'a4' ? state.motifSource : null, + }; + + case 'SET_SHIPPING_MODE': + return { + ...state, + shippingMode: action.shippingMode, + // Bei Direktversand ist Umschlag immer dabei mit Beschriftung + envelope: action.shippingMode === 'direct' ? true : state.envelope, + envelopeMode: action.shippingMode === 'direct' ? 'recipientData' : state.envelopeMode, + }; + + case 'SET_ENVELOPE': + return { + ...state, + envelope: action.envelope, + // Wenn kein Kuvert, dann auch keine Beschriftung + envelopeMode: action.envelope ? state.envelopeMode : 'none', + }; + + case 'SET_ENVELOPE_MODE': + return { ...state, envelopeMode: action.mode }; + + case 'SET_FOLLOWUP_VOLUME': + return { ...state, followupYearlyVolume: action.volume }; + + case 'SET_FOLLOWUP_CREATE_MODE': + return { ...state, followupCreateMode: action.mode }; + + case 'SET_MOTIF_NEED': + return { + ...state, + motifNeed: action.motifNeed, + motifSource: action.motifNeed ? (state.motifSource || 'upload') : null, + }; + + case 'SET_MOTIF_SOURCE': + return { ...state, motifSource: action.source }; + + case 'SET_CONTENT_CREATE_MODE': + return { ...state, contentCreateMode: action.mode }; + + case 'GO_BACK': + return { + ...state, + step: STEPS.PRODUCT, + product: null, + }; + + default: + return state; + } +} + +// ========== PRODUKT-DEFINITIONEN ========== +// Nutzt getAvailableProductsForCustomerType und listProducts aus configurator-state.js +// Diese enthalten die korrekten Formate, supportsMotif, isFollowUp etc. + +// ========== PREIS-BERECHNUNG ========== + +function calculatePrice(state) { + if (!state.product) return null; + + const settings = window.SkriftPreisrechner?.settings || window.SkriftConfigurator?.settings || {}; + const prices = settings.prices || {}; + const dynamicPricing = settings.dynamic_pricing || {}; + const isB2B = state.customerType === 'business'; + const taxRate = (prices.tax_rate || 19) / 100; + + // Follow-ups haben spezielle Preislogik + if (state.product.isFollowUp === true) { + return calculateFollowupsPrice(state, prices, taxRate, isB2B); + } + + // Normale Produkte + return calculateStandardPrice(state, prices, dynamicPricing, taxRate, isB2B); +} + +function calculateFollowupsPrice(state, prices, taxRate, isB2B) { + const basePrice = state.product.basePrice; + + // Multiplikator basierend auf Monatsvolumen + const multipliers = { + '5-49': prices.followup_mult_5_49 || 2.0, + '50-199': prices.followup_mult_50_199 || 1.7, + '200-499': prices.followup_mult_200_499 || 1.4, + '500-999': prices.followup_mult_500_999 || 1.2, + '1000+': prices.followup_mult_1000_plus || 1.0, + }; + + const multiplier = multipliers[state.followupYearlyVolume] || 1.7; + + // A4-Aufschlag bei Follow-ups (wie bei anderen Produkten mit supportsMotif) + let formatSurcharge = 0; + if (state.format === 'a4' && state.product.supportsMotif) { + formatSurcharge = parseFloat(prices.a4_upgrade_surcharge) || 0.50; + } + + const pricePerPiece = (basePrice * multiplier) + formatSurcharge; + + // Versandkosten (immer Direktversand bei Follow-ups, immer mit Kuvert + Beschriftung) + const shippingDomestic = parseFloat(prices.shipping_domestic) || 0.95; + const shippingService = parseFloat(prices.shipping_service) || 0.95; + const envelopeBase = parseFloat(prices.envelope_base) || 0.50; + const envelopeLabeling = parseFloat(prices.envelope_labeling) || 0.50; // Immer dabei + + const shippingPerPiece = shippingDomestic + shippingService + envelopeBase + envelopeLabeling; + const totalPerPiece = pricePerPiece + shippingPerPiece; + + // Brutto/Netto + const netPerPiece = totalPerPiece; + const grossPerPiece = totalPerPiece * (1 + taxRate); + + // Einmalige Kosten + const setupCosts = []; + + // API-Anbindung (nur bei automatischer Erstellung) + if (state.followupCreateMode === 'auto') { + const apiConnection = parseFloat(prices.api_connection) || 250.00; + setupCosts.push({ label: 'API-Anbindung', amount: apiConnection }); + } + + // Textservice (auch bei Follow-ups möglich) + if (state.contentCreateMode === 'textservice') { + const textservicePrice = parseFloat(prices.textservice) || 0; + if (textservicePrice > 0) { + setupCosts.push({ label: 'Textservice', amount: textservicePrice }); + } + } + + return { + pricePerPiece: isB2B ? netPerPiece : grossPerPiece, + isGross: !isB2B, + details: { + basePrice: basePrice, + multiplier: multiplier, + volumeTier: state.followupYearlyVolume, + formatSurcharge: formatSurcharge, + productPrice: pricePerPiece, + shippingDomestic: shippingDomestic, + shippingService: shippingService, + envelopeBase: envelopeBase, + envelopeLabeling: envelopeLabeling, + shippingTotal: shippingPerPiece, + netPerPiece: netPerPiece, + grossPerPiece: grossPerPiece, + taxRate: taxRate * 100, + setupCosts: setupCosts, + }, + }; +} + +function calculateStandardPrice(state, prices, dynamicPricing, taxRate, isB2B) { + const basePrice = state.product.basePrice; + const quantity = state.quantity || 1; + + // Dynamischer Multiplikator basierend auf Menge + const normalQty = isB2B + ? (dynamicPricing.business_normal_quantity || 200) + : (dynamicPricing.private_normal_quantity || 50); + + let multiplier = 1.0; + if (quantity < normalQty) { + // Formel: 2 - sqrt(qty / normalQty) + multiplier = 2 - Math.sqrt(quantity / normalQty); + } + + // A4-Aufschlag für Postkarten/Einladungen + let formatSurcharge = 0; + const isPostcardLike = state.product.supportsMotif; + if (state.format === 'a4' && isPostcardLike) { + formatSurcharge = parseFloat(prices.a4_upgrade_surcharge) || 0.50; + } + + const productPrice = (basePrice * multiplier) + formatSurcharge; + + // Versandkosten + let shippingTotal = 0; + let shippingDetails = {}; + + if (state.shippingMode === 'direct') { + // Direktversand: Porto + Service + Kuvert + Beschriftung (immer dabei) + const shippingDomestic = parseFloat(prices.shipping_domestic) || 0.95; + const shippingService = parseFloat(prices.shipping_service) || 0.95; + const envelopeBase = parseFloat(prices.envelope_base) || 0.50; + const envelopeLabeling = parseFloat(prices.envelope_labeling) || 0.50; // Immer bei Direktversand + + shippingTotal = shippingDomestic + shippingService + envelopeBase + envelopeLabeling; + shippingDetails = { + type: 'direct', + porto: shippingDomestic, + service: shippingService, + envelope: envelopeBase, + labeling: envelopeLabeling, + }; + } else { + // Sammelversand: Nur Kuvert + optionale Beschriftung (KEINE anteilige Versandpauschale pro Stück!) + let envelopeCost = 0; + let labelingCost = 0; + if (state.envelope) { + envelopeCost = parseFloat(prices.envelope_base) || 0.50; + // Beschriftung nur wenn envelopeMode nicht 'none' ist + if (state.envelopeMode && state.envelopeMode !== 'none') { + labelingCost = parseFloat(prices.envelope_labeling) || 0.50; + } + } + shippingTotal = envelopeCost + labelingCost; + shippingDetails = { + type: 'bulk', + flatRate: parseFloat(prices.shipping_bulk) || 4.95, // Wird separat als Pauschale angezeigt + envelopeCost: envelopeCost, + labelingCost: labelingCost, + envelopeMode: state.envelopeMode, + }; + } + + const totalPerPiece = productPrice + shippingTotal; + + // Brutto/Netto + const netPerPiece = totalPerPiece; + const grossPerPiece = totalPerPiece * (1 + taxRate); + + // Einmalige Kosten + const setupCosts = []; + + // Motiv-Upload + if (state.motifNeed && state.motifSource === 'upload') { + const motifUploadPrice = parseFloat(prices.motif_upload) || 0.30; + if (motifUploadPrice > 0) { + setupCosts.push({ label: 'Motiv-Upload', amount: motifUploadPrice }); + } + } + + // Motiv-Design + if (state.motifNeed && state.motifSource === 'design') { + const motifDesignPrice = parseFloat(prices.motif_design) || 0; + if (motifDesignPrice > 0) { + setupCosts.push({ label: 'Motiv-Design', amount: motifDesignPrice }); + } + } + + // Textservice + if (state.contentCreateMode === 'textservice') { + const textservicePrice = parseFloat(prices.textservice) || 0; + if (textservicePrice > 0) { + setupCosts.push({ label: 'Textservice', amount: textservicePrice }); + } + } + + // Bei Sammelversand: Versandpauschale zum Gesamtpreis hinzufügen (einmalig, nicht pro Stück) + const bulkShippingFlat = (shippingDetails.type === 'bulk') ? (shippingDetails.flatRate || 0) : 0; + const totalNet = (netPerPiece * quantity) + bulkShippingFlat; + const totalGross = (grossPerPiece * quantity) + (bulkShippingFlat * (1 + taxRate)); + + return { + pricePerPiece: isB2B ? netPerPiece : grossPerPiece, + isGross: !isB2B, + totalPrice: isB2B ? totalNet : totalGross, + details: { + basePrice: basePrice, + multiplier: multiplier, + normalQuantity: normalQty, + formatSurcharge: formatSurcharge, + productPrice: productPrice, + shipping: shippingDetails, + shippingTotal: shippingTotal, + netPerPiece: netPerPiece, + grossPerPiece: grossPerPiece, + taxRate: taxRate * 100, + quantity: quantity, + setupCosts: setupCosts, + }, + }; +} + +// ========== DOM HELPER ========== + +function h(tag, attrs = {}, children = []) { + const el = document.createElement(tag); + for (const [key, val] of Object.entries(attrs)) { + if (key === 'text') { + el.textContent = val; + } else if (key === 'html') { + el.innerHTML = val; + } else if (key.startsWith('on') && typeof val === 'function') { + el.addEventListener(key.slice(2).toLowerCase(), val); + } else if (key === 'class') { + el.className = val; + } else if (key === 'style' && typeof val === 'object') { + Object.assign(el.style, val); + } else if (val !== null && val !== undefined) { + el.setAttribute(key, val); + } + } + for (const child of children) { + if (typeof child === 'string') { + el.appendChild(document.createTextNode(child)); + } else if (child instanceof Node) { + el.appendChild(child); + } + } + return el; +} + +// ========== UI RENDERING ========== + +function renderCard(title, subtitle, bodyContent) { + return h('div', { class: 'sk-card' }, [ + h('div', { class: 'sk-card-head' }, [ + h('h3', { class: 'sk-card-title', text: title }), + subtitle ? h('p', { class: 'sk-card-subtitle', text: subtitle }) : null, + ].filter(Boolean)), + h('div', { class: 'sk-card-body' }, bodyContent), + ]); +} + +function renderProductStep(state, dispatch) { + const blocks = []; + + // Kundentyp Auswahl (identisch zum Konfigurator) + const customerOptions = [ + { + value: 'business', + label: 'Geschäftskunde', + desc: 'Für Unternehmen, Selbstständige und gewerbliche Kunden', + icon: '🏢', + }, + { + value: 'private', + label: 'Privatkunde', + desc: 'Für private Anlässe und persönliche Korrespondenz', + icon: '🏠', + }, + ]; + + const customerBody = customerOptions.map(opt => + h('div', { + class: `sk-option ${state.customerType === opt.value ? 'is-selected' : ''}`, + onclick: () => dispatch({ type: 'SET_CUSTOMER_TYPE', customerType: opt.value }), + }, [ + h('input', { + type: 'radio', + name: 'customerType', + checked: state.customerType === opt.value ? 'checked' : null, + }), + h('div', { class: 'sk-option-content' }, [ + h('div', { class: 'sk-option-label', text: `${opt.icon} ${opt.label}` }), + h('div', { class: 'sk-option-desc', text: opt.desc }), + ]), + ]) + ); + + blocks.push(renderCard( + 'Als was möchten Sie einkaufen?', + 'Wählen Sie Ihren Kundentyp aus', + [h('div', { class: 'sk-options' }, customerBody)] + )); + + // Produktauswahl (nur wenn Kundentyp gewählt) - nutzt getAvailableProductsForCustomerType aus Konfigurator + if (state.customerType) { + const baseProducts = getAvailableProductsForCustomerType(state.customerType); + const settings = window.SkriftPreisrechner?.settings || window.SkriftConfigurator?.settings || {}; + const productSettings = settings.products || {}; + const prices = settings.prices || {}; + const taxRate = (prices.tax_rate || 19) / 100; + const isB2B = state.customerType === 'business'; + + // Produkte mit korrekten Labels und Preisen aus Settings anreichern + const products = baseProducts.map(p => { + const pSettings = productSettings[p.key] || {}; + return { + ...p, + label: pSettings.label || p.label, + description: pSettings.description || p.description, + basePrice: parseFloat(pSettings.base_price) || p.basePrice || 2.50, + }; + }); + + const grid = h('div', { class: 'sk-selection-grid' }); + + for (const p of products) { + const displayPrice = isB2B ? p.basePrice : p.basePrice * (1 + taxRate); + const priceText = `ab ${formatEUR(displayPrice)}`; + + const card = h('div', { + class: `sk-selection-card ${state.product?.key === p.key ? 'is-selected' : ''}`, + onclick: () => dispatch({ type: 'SET_PRODUCT', product: p }), + }, [ + h('div', { class: 'sk-selection-card-image' }, [ + h('div', { text: '📄', style: 'font-size: 48px' }), + ]), + h('div', { class: 'sk-selection-card-content' }, [ + h('div', { class: 'sk-selection-card-title', text: p.label }), + h('div', { class: 'sk-selection-card-price', text: priceText }), + h('div', { class: 'sk-selection-card-desc', text: p.description }), + ]), + ]); + grid.appendChild(card); + } + + blocks.push(renderCard( + 'Welches Produkt benötigen Sie?', + 'Wählen Sie das passende Produkt für Ihren Bedarf', + [grid] + )); + } + + return h('div', { class: 'sk-stack' }, blocks); +} + +function renderCalculatorStep(state, dispatch) { + const blocks = []; + const settings = window.SkriftPreisrechner?.settings || window.SkriftConfigurator?.settings || {}; + const prices = settings.prices || {}; + const dynamicPricing = settings.dynamic_pricing || {}; + const isB2B = state.customerType === 'business'; + const taxRate = (prices.tax_rate || 19) / 100; + // Hilfsfunktion: Bei B2C Brutto-Preis anzeigen (inkl. 19% MwSt.) + const displayPrice = (nettoPrice) => isB2B ? nettoPrice : nettoPrice * (1 + taxRate); + // Nutze Helper-Funktionen aus Konfigurator für korrekte Prüfung + const isFollowups = state.product?.isFollowUp === true; + const isPostcardLike = state.product?.supportsMotif === true; + + // Produkt-Info Header + blocks.push(h('div', { class: 'sk-topbar' }, [ + h('div', { class: 'sk-product-info' }, [ + h('div', { class: 'sk-product-icon', text: state.product?.icon || '📄' }), + h('div', {}, [ + h('div', { class: 'sk-product-label', text: state.product?.label }), + h('div', { class: 'sk-text-small sk-text-muted', text: isB2B ? 'Geschäftskunde' : 'Privatkunde' }), + ]), + ]), + h('button', { + class: 'sk-btn sk-btn-secondary', + onclick: () => dispatch({ type: 'GO_BACK' }), + text: '← Produkt ändern', + }), + ])); + + // ========== MENGE / FOLLOW-UP VOLUMEN ========== + if (isFollowups) { + // Follow-ups: Monatsvolumen (identisch zum Konfigurator) + const volumes = [ + { value: '5-49', label: '5-49 Follow-ups pro Monat' }, + { value: '50-199', label: '50-199 Follow-ups pro Monat' }, + { value: '200-499', label: '200-499 Follow-ups pro Monat' }, + { value: '500-999', label: '500-999 Follow-ups pro Monat' }, + { value: '1000+', label: '1000+ Follow-ups pro Monat' }, + ]; + + const volBody = volumes.map(vol => + h('div', { + class: `sk-option ${state.followupYearlyVolume === vol.value ? 'is-selected' : ''}`, + onclick: () => dispatch({ type: 'SET_FOLLOWUP_VOLUME', volume: vol.value }), + }, [ + h('input', { + type: 'radio', + name: 'volume', + checked: state.followupYearlyVolume === vol.value ? 'checked' : null, + }), + h('div', { class: 'sk-option-content' }, [ + h('div', { class: 'sk-option-label', text: vol.label }), + ]), + ]) + ); + + blocks.push(renderCard( + 'Um wie viele Follow-ups geht es ca. pro Monat?', + 'Dies hilft uns bei der Preiskalkulation', + [h('div', { class: 'sk-options' }, volBody)] + )); + } else { + // Normale Produkte: Menge (identisch zum Konfigurator) + const minQuantity = isB2B + ? (dynamicPricing.business_min_quantity || 50) + : (dynamicPricing.private_min_quantity || 10); + const normalQuantity = isB2B + ? (dynamicPricing.business_normal_quantity || 200) + : (dynamicPricing.private_normal_quantity || 50); + + const quantityInput = h('input', { + class: 'sk-input', + type: 'number', + min: String(minQuantity), + value: String(state.quantity), + onchange: (e) => { + let val = parseInt(e.target.value) || minQuantity; + if (val < minQuantity) val = minQuantity; + dispatch({ type: 'SET_QUANTITY', quantity: val }); + }, + }); + + blocks.push(renderCard( + 'Wie viele Schriftstücke benötigen Sie?', + `Mindestmenge: ${minQuantity} Stück • Unser bester Preis gilt ab ${normalQuantity} Stück`, + [quantityInput] + )); + } + + // ========== FORMAT (nur A4 vs A6 fragen - Hoch/Querformat macht keinen Preisunterschied) ========== + // Prüfen ob sowohl A4 als auch A6 verfügbar sind + const hasA4 = state.product?.formats?.includes('a4'); + const hasA6 = state.product?.formats?.some(f => f === 'a6p' || f === 'a6l'); + + if (hasA4 && hasA6) { + const a4Surcharge = prices.a4_upgrade_surcharge || 0; + const hasA4Surcharge = isPostcardLike; + + const formatOptions = [ + { value: 'a6p', label: 'A6', desc: 'Kompaktes Format, ideal für Postkarten und kurze Nachrichten', price: '' }, + { value: 'a4', label: 'A4', desc: 'Standardformat für ausführliche Briefe', price: hasA4Surcharge ? `+ ${formatEUR(displayPrice(a4Surcharge))}` : '' }, + ]; + + const formatBody = formatOptions.map(opt => { + // Für A6: prüfen ob a6p oder a6l aktuell gewählt ist + const isSelected = opt.value === 'a4' ? state.format === 'a4' : (state.format === 'a6p' || state.format === 'a6l'); + return h('div', { + class: `sk-option ${isSelected ? 'is-selected' : ''}`, + onclick: () => dispatch({ type: 'SET_FORMAT', format: opt.value }), + }, [ + h('input', { + type: 'radio', + name: 'format', + checked: isSelected ? 'checked' : null, + }), + h('div', { class: 'sk-option-content' }, [ + h('div', { class: 'sk-option-label', text: opt.label }), + h('div', { class: 'sk-option-desc', text: opt.desc }), + opt.price ? h('div', { class: 'sk-option-price', text: opt.price }) : null, + ].filter(Boolean)), + ]); + }); + + blocks.push(renderCard( + 'Welches Format soll das Schriftstück haben?', + null, + [h('div', { class: 'sk-options' }, formatBody)] + )); + } + + // ========== FOLLOW-UP ERSTELLUNGSMODUS ========== + if (isFollowups) { + const apiPrice = prices.api_connection || 250; + + const createModes = [ + { + value: 'auto', + label: 'Automatisch aus System', + desc: 'Wir verbinden uns mit Ihrem CRM/Shop-System', + price: `Einmalig ${formatEUR(displayPrice(apiPrice))}`, + }, + { + value: 'manual', + label: 'Manuell', + desc: 'Sie senden uns die Empfängerliste im gewählten Rhythmus', + price: '', + }, + ]; + + const createBody = createModes.map(mode => + h('div', { + class: `sk-option ${state.followupCreateMode === mode.value ? 'is-selected' : ''}`, + onclick: () => dispatch({ type: 'SET_FOLLOWUP_CREATE_MODE', mode: mode.value }), + }, [ + h('input', { + type: 'radio', + name: 'createMode', + checked: state.followupCreateMode === mode.value ? 'checked' : null, + }), + h('div', { class: 'sk-option-content' }, [ + h('div', { class: 'sk-option-label', text: mode.label }), + h('div', { class: 'sk-option-desc', text: mode.desc }), + mode.price ? h('div', { class: 'sk-option-price', text: mode.price }) : null, + ].filter(Boolean)), + ]) + ); + + blocks.push(renderCard( + 'Wie sollen die Follow-ups erstellt werden?', + null, + [h('div', { class: 'sk-options' }, createBody)] + )); + } + + // ========== VERSANDART (nicht bei Follow-ups) ========== + if (!isFollowups) { + const shippingBulk = prices.shipping_bulk || 4.95; + const shippingDomestic = prices.shipping_domestic || 0.95; + const shippingService = prices.shipping_service || 0.95; + const envelopeBase = prices.envelope_base || 0.50; + const envelopeLabeling = prices.envelope_labeling || 0.50; + + const domesticPrice = shippingDomestic + shippingService + envelopeBase + envelopeLabeling; + const internationalPrice = (prices.shipping_international || 1.25) + shippingService + envelopeBase + envelopeLabeling; + + const shippingOptions = [ + { + value: 'direct', + label: 'Einzeln an die Empfänger', + desc: 'Wir versenden direkt an Ihre Empfänger – inklusive Kuvertierung und Beschriftung', + price: `Inland: ${formatEUR(displayPrice(domesticPrice))} / Ausland: ${formatEUR(displayPrice(internationalPrice))} pro Stück`, + }, + { + value: 'bulk', + label: 'Sammelversand an Sie', + desc: 'Sie erhalten alle Schriftstücke zur eigenen Verteilung', + price: `Einmalig ${formatEUR(displayPrice(shippingBulk))} Versandkosten`, + }, + ]; + + const shippingBody = shippingOptions.map(opt => + h('div', { + class: `sk-option ${state.shippingMode === opt.value ? 'is-selected' : ''}`, + onclick: () => dispatch({ type: 'SET_SHIPPING_MODE', shippingMode: opt.value }), + }, [ + h('input', { + type: 'radio', + name: 'shippingMode', + checked: state.shippingMode === opt.value ? 'checked' : null, + }), + h('div', { class: 'sk-option-content' }, [ + h('div', { class: 'sk-option-label', text: opt.label }), + h('div', { class: 'sk-option-desc', text: opt.desc }), + h('div', { class: 'sk-option-price', text: opt.price }), + ]), + ]) + ); + + blocks.push(renderCard( + 'Wie sollen die Schriftstücke versendet werden?', + 'Wählen Sie die passende Versandart', + [h('div', { class: 'sk-options' }, shippingBody)] + )); + + // ========== KUVERT BEI SAMMELVERSAND ========== + if (state.shippingMode === 'bulk') { + const envOptions = [ + { value: true, label: 'Ja, ich wünsche ein Kuvert', price: `+ ${formatEUR(displayPrice(envelopeBase))}` }, + { value: false, label: 'Nein, ich benötige kein Kuvert', price: '' }, + ]; + + const envBody = envOptions.map(opt => + h('div', { + class: `sk-option ${state.envelope === opt.value ? 'is-selected' : ''}`, + onclick: () => dispatch({ type: 'SET_ENVELOPE', envelope: opt.value }), + }, [ + h('input', { + type: 'radio', + name: 'envelope', + checked: state.envelope === opt.value ? 'checked' : null, + }), + h('div', { class: 'sk-option-content' }, [ + h('div', { class: 'sk-option-label', text: opt.label }), + opt.price ? h('div', { class: 'sk-option-price', text: opt.price }) : null, + ].filter(Boolean)), + ]) + ); + + blocks.push(renderCard( + 'Benötigen Sie ein Kuvert?', + null, + [h('div', { class: 'sk-options' }, envBody)] + )); + + // Beschriftungsart nur wenn Kuvert gewählt + if (state.envelope) { + const modeOptions = [ + { + value: 'recipientData', + label: 'Empfängerdaten', + desc: 'Klassische Adressierung mit Name und Anschrift', + price: `+ ${formatEUR(displayPrice(envelopeLabeling))}`, + }, + { + value: 'customText', + label: 'Individueller Text', + desc: 'Freier Text mit Platzhaltern', + price: `+ ${formatEUR(displayPrice(envelopeLabeling))}`, + }, + { + value: 'none', + label: 'Keine Beschriftung', + desc: 'Umschlag bleibt unbeschriftet', + price: '', + }, + ]; + + const modeBody = modeOptions.map(opt => + h('div', { + class: `sk-option ${state.envelopeMode === opt.value ? 'is-selected' : ''}`, + onclick: () => dispatch({ type: 'SET_ENVELOPE_MODE', mode: opt.value }), + }, [ + h('input', { + type: 'radio', + name: 'envelopeMode', + checked: state.envelopeMode === opt.value ? 'checked' : null, + }), + h('div', { class: 'sk-option-content' }, [ + h('div', { class: 'sk-option-label', text: opt.label }), + h('div', { class: 'sk-option-desc', text: opt.desc }), + opt.price ? h('div', { class: 'sk-option-price', text: opt.price }) : null, + ].filter(Boolean)), + ]) + ); + + blocks.push(renderCard( + 'Wie soll das Kuvert beschriftet werden?', + null, + [h('div', { class: 'sk-options' }, modeBody)] + )); + } + } + } + + // Follow-ups: Kein Umschlag-Optionen Block - immer Direktversand mit Kuvert + + // ========== MOTIV (nur bei Postkarten/Einladungen mit A6) ========== + if (isPostcardLike && state.format !== 'a4') { + const motifOptions = [ + { value: true, label: 'Ja, ich möchte ein Motiv' }, + { value: false, label: 'Nein, kein Motiv' }, + ]; + + const motifBody = motifOptions.map(opt => + h('div', { + class: `sk-option ${state.motifNeed === opt.value ? 'is-selected' : ''}`, + onclick: () => dispatch({ type: 'SET_MOTIF_NEED', motifNeed: opt.value }), + }, [ + h('input', { + type: 'radio', + name: 'motif', + checked: state.motifNeed === opt.value ? 'checked' : null, + }), + h('div', { class: 'sk-option-content' }, [ + h('div', { class: 'sk-option-label', text: opt.label }), + ]), + ]) + ); + + blocks.push(renderCard( + 'Soll ein Motiv auf der Vorderseite abgebildet werden?', + null, + [h('div', { class: 'sk-options' }, motifBody)] + )); + + // Motiv-Quelle + if (state.motifNeed) { + const motifUploadPrice = prices.motif_upload || 0.30; + const motifDesignPrice = prices.motif_design || 0; + + const sourceOptions = [ + { + value: 'upload', + label: 'Eigenes Motiv hochladen', + desc: 'Sie laden Ihr eigenes Bild hoch', + price: motifUploadPrice > 0 ? `Einmalig ${formatEUR(displayPrice(motifUploadPrice))}` : '', + }, + { + value: 'design', + label: 'Motiv erstellen lassen', + desc: 'Wir gestalten ein Motiv nach Ihren Wünschen', + price: motifDesignPrice > 0 ? `Einmalig ${formatEUR(displayPrice(motifDesignPrice))}` : '', + }, + ]; + + const sourceBody = sourceOptions.map(opt => + h('div', { + class: `sk-option ${state.motifSource === opt.value ? 'is-selected' : ''}`, + onclick: () => dispatch({ type: 'SET_MOTIF_SOURCE', source: opt.value }), + }, [ + h('input', { + type: 'radio', + name: 'motifSource', + checked: state.motifSource === opt.value ? 'checked' : null, + }), + h('div', { class: 'sk-option-content' }, [ + h('div', { class: 'sk-option-label', text: opt.label }), + h('div', { class: 'sk-option-desc', text: opt.desc }), + opt.price ? h('div', { class: 'sk-option-price', text: opt.price }) : null, + ].filter(Boolean)), + ]) + ); + + blocks.push(renderCard( + 'Woher kommt das Motiv?', + null, + [h('div', { class: 'sk-options' }, sourceBody)] + )); + } + } + + // ========== TEXTERSTELLUNG (für alle Produkte inkl. Follow-ups) ========== + { + const textservicePrice = prices.textservice || 0; + + const contentOptions = [ + { + value: 'self', + label: 'Ich erstelle den Text selbst', + desc: 'Sie geben den vollständigen Text vor', + price: '', + }, + { + value: 'textservice', + label: 'Textservice beauftragen', + desc: 'Wir erstellen den Text professionell für Sie', + price: textservicePrice > 0 ? `Einmalig ${formatEUR(displayPrice(textservicePrice))}` : '', + }, + ]; + + const contentBody = contentOptions.map(opt => + h('div', { + class: `sk-option ${state.contentCreateMode === opt.value ? 'is-selected' : ''}`, + onclick: () => dispatch({ type: 'SET_CONTENT_CREATE_MODE', mode: opt.value }), + }, [ + h('input', { + type: 'radio', + name: 'contentMode', + checked: state.contentCreateMode === opt.value ? 'checked' : null, + }), + h('div', { class: 'sk-option-content' }, [ + h('div', { class: 'sk-option-label', text: opt.label }), + h('div', { class: 'sk-option-desc', text: opt.desc }), + opt.price ? h('div', { class: 'sk-option-price', text: opt.price }) : null, + ].filter(Boolean)), + ]) + ); + + blocks.push(renderCard( + 'Möchten Sie den Inhalt selbst erstellen?', + null, + [h('div', { class: 'sk-options' }, contentBody)] + )); + } + + return h('div', { class: 'sk-stack' }, blocks); +} + +function renderPriceSidebar(state) { + const priceData = calculatePrice(state); + + if (!priceData) { + return h('div', { class: 'sk-preview-card' }, [ + h('div', { class: 'sk-preview-title', text: 'Preisberechnung' }), + h('div', { class: 'sk-preview-sub', text: 'Wählen Sie ein Produkt aus' }), + ]); + } + + const isFollowups = state.product?.isFollowUp === true; + const taxRate = Math.round(priceData.details.taxRate) || 19; + const taxMultiplier = priceData.isGross ? (1 + taxRate / 100) : 1; // Brutto-Multiplikator für B2C + const taxNote = priceData.isGross ? `inkl. ${taxRate}% MwSt.` : `zzgl. ${taxRate}% MwSt.`; + + // Preiszeilen sammeln - bei B2C (isGross) Brutto-Werte anzeigen + const priceRows = []; + + // Produktpreis (dynamisch) - OHNE Formataufschlag, da dieser separat angezeigt wird + const baseProductPrice = priceData.details.productPrice - (priceData.details.formatSurcharge || 0); + priceRows.push({ label: 'Schriftstück', value: baseProductPrice * taxMultiplier }); + + // Format-Aufschlag (falls vorhanden) + if (priceData.details.formatSurcharge > 0) { + priceRows.push({ label: 'A4-Aufschlag', value: priceData.details.formatSurcharge * taxMultiplier }); + } + + // Direktversand: Zusammengefasst als ein Posten + if (priceData.details.shipping?.type === 'direct') { + const totalDirectShipping = priceData.details.shipping.porto + + priceData.details.shipping.service + + priceData.details.shipping.envelope + + (priceData.details.shipping.labeling || 0); + priceRows.push({ label: 'Einzeln an Empfänger', value: totalDirectShipping * taxMultiplier }); + } else if (priceData.details.shipping?.type === 'bulk') { + // Sammelversand: Kuvert + Beschriftung (falls gewählt) + if (priceData.details.shipping.envelopeCost > 0) { + priceRows.push({ label: 'Kuvert', value: priceData.details.shipping.envelopeCost * taxMultiplier }); + } + if (priceData.details.shipping.labelingCost > 0) { + // Label je nach Modus + const labelText = priceData.details.shipping.envelopeMode === 'customText' + ? 'Individueller Text' + : 'Empfängerdaten'; + priceRows.push({ label: labelText, value: priceData.details.shipping.labelingCost * taxMultiplier }); + } + // KEINE anteilige Versandpauschale pro Stück - wird separat als Pauschale angezeigt + } + + // Follow-ups: Versand zusammengefasst + if (isFollowups) { + const totalDirectShipping = priceData.details.shippingDomestic + + priceData.details.shippingService + + priceData.details.envelopeBase + + (priceData.details.envelopeLabeling || 0); + priceRows.push({ label: 'Einzeln an Empfänger', value: totalDirectShipping * taxMultiplier }); + } + + // Preistabelle rendern + const priceTable = h('table', { class: 'sk-calc-price-table' }, [ + h('tbody', {}, priceRows.map(row => + h('tr', {}, [ + h('td', { text: row.label }), + h('td', { text: formatEUR(row.value) }), + ]) + )), + ]); + + // Einmalige Kosten + let setupSection = null; + if (priceData.details.setupCosts?.length > 0) { + const setupTable = h('table', { class: 'sk-calc-price-table' }, [ + h('tbody', {}, priceData.details.setupCosts.map(cost => + h('tr', {}, [ + h('td', { text: cost.label }), + h('td', { text: formatEUR(cost.amount * taxMultiplier) }), + ]) + )), + ]); + + setupSection = h('div', { class: 'sk-calc-setup-section' }, [ + h('div', { class: 'sk-calc-section-label', text: 'Einmalige Kosten' }), + setupTable, + ]); + } + + // Gesamtpreis (nur bei Standardprodukten) - netto für B2B, brutto für B2C + let totalSection = null; + if (!isFollowups && priceData.totalPrice) { + const totalLabel = priceData.isGross + ? `Gesamt inkl. MwSt. (${state.quantity} St.)` + : `Gesamt netto (${state.quantity} St.)`; + totalSection = h('div', { class: 'sk-calc-total-section' }, [ + h('div', { class: 'sk-calc-total-row' }, [ + h('span', { text: totalLabel }), + h('span', { class: 'sk-calc-total-value', text: formatEUR(priceData.totalPrice) }), + ]), + ]); + } + + // Sammelversand Hinweis + let bulkNote = null; + if (priceData.details.shipping?.type === 'bulk') { + bulkNote = h('div', { class: 'sk-calc-note' }, [ + h('span', { text: `zzgl. ${formatEUR(priceData.details.shipping.flatRate)} Versandpauschale` }), + ]); + } + + // Mengenhinweis + let quantityNote = null; + if (!isFollowups && priceData.details.multiplier > 1.01) { + quantityNote = h('div', { class: 'sk-calc-note sk-calc-note--info' }, [ + h('span', { text: `Tipp: Ab ${priceData.details.normalQuantity} Stück erhalten Sie unseren besten Preis.` }), + ]); + } + + // Staffelpreis-Hinweis bei Follow-ups + let tierNote = null; + if (isFollowups) { + tierNote = h('div', { class: 'sk-calc-note' }, [ + h('span', { text: `Staffelpreis für ${priceData.details.volumeTier} Follow-ups/Monat` }), + ]); + } + + return h('div', { class: 'sk-calc-sidebar-card' }, [ + // Hauptpreis + h('div', { class: 'sk-calc-main-price' }, [ + h('div', { class: 'sk-calc-main-price__value', text: formatEUR(priceData.pricePerPiece) }), + h('div', { class: 'sk-calc-main-price__label', text: 'pro Stück' }), + h('div', { class: 'sk-calc-main-price__note', text: taxNote }), + ]), + + // Preisaufschlüsselung + h('div', { class: 'sk-calc-details' }, [ + priceTable, + bulkNote, + quantityNote, + tierNote, + ].filter(Boolean)), + + // Einmalige Kosten + setupSection, + + // Gesamtpreis + totalSection, + + // CTA Button + h('a', { + class: 'sk-calc-cta-btn', + href: `/konfigurator/?${state.product.key}`, + }, [ + h('span', { text: 'Jetzt konfigurieren' }), + h('span', { class: 'sk-calc-cta-arrow', text: '→' }), + ]), + ].filter(Boolean)); +} + +// ========== MAIN RENDER ========== + +function render(state, dispatch) { + const container = document.querySelector('[data-skrift-preisrechner]'); + if (!container) return; + + // Bei Produktauswahl: Volle Breite (1200px wie Calculator) + if (state.step === STEPS.PRODUCT) { + const wrapper = h('div', {}); + wrapper.appendChild(renderProductStep(state, dispatch)); + container.innerHTML = ''; + container.appendChild(wrapper); + return; + } + + // Calculator-Step: 2-Spalten-Layout mit Sidebar + const layout = h('div', { class: 'sk-configurator__layout' }); + const main = h('div', { class: 'sk-main' }); + const side = h('aside', { class: 'sk-side' }); + + // Hauptinhalt + const calculatorContent = renderCalculatorStep(state, dispatch); + main.appendChild(calculatorContent); + + // Mobile Sidebar (wird am Ende des Hauptinhalts angezeigt) + const mobileSidebar = h('div', { class: 'sk-calc-mobile-sidebar' }); + mobileSidebar.appendChild(renderPriceSidebar(state)); + main.appendChild(mobileSidebar); + + // Desktop Sidebar + side.appendChild(renderPriceSidebar(state)); + + layout.appendChild(main); + layout.appendChild(side); + + container.innerHTML = ''; + container.appendChild(layout); +} + +// ========== INIT ========== + +export function initPriceCalculator() { + const container = document.querySelector('[data-skrift-preisrechner]'); + if (!container) return; + + let state = createInitialState(); + + function dispatch(action) { + state = reducer(state, action); + render(state, dispatch); + } + + // Initial render + render(state, dispatch); +} + +// Auto-Init wenn DOM ready +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', initPriceCalculator); +} else { + initPriceCalculator(); +} diff --git a/Webseite/skrift-configurator/check-db.php b/Webseite/skrift-configurator/check-db.php new file mode 100644 index 0000000..6ba4ce7 --- /dev/null +++ b/Webseite/skrift-configurator/check-db.php @@ -0,0 +1,110 @@ + + + + + + Gutschein DB Check + + + +

    🔍 Gutschein Datenbank Check

    + + + +
    +

    1. Datenbank Status

    +

    Anzahl Gutscheine:

    + +

    ⚠️ KEINE GUTSCHEINE IN DER DATENBANK!

    + +

    ✅ Gutscheine gefunden!

    + +
    + +
    +

    2. Rohe Daten (PHP)

    +
    +
    + +
    +

    3. JSON Encoding (wie im Frontend)

    +
    +
    + +
    +

    4. JavaScript Test

    + +

    Öffnen Sie die Browser Console (F12) für JavaScript-Output

    +
    + + +
    +

    ⚠️ Lösung: Gutscheine erstellen

    +

    Es sind keine Gutscheine in der Datenbank. Bitte:

    +
      +
    1. Gehen Sie zu: Gutschein-Verwaltung
    2. +
    3. Erstellen Sie einen Test-Gutschein (z.B. Code: TEST10, Typ: Prozent, Wert: 10)
    4. +
    5. Oder führen Sie create-test-voucher.php aus
    6. +
    +
    + + +
    +

    5. Schnell-Fix: Test-Gutschein erstellen

    +
    + +
    + + 'TEST10', + 'type' => 'percent', + 'value' => 10, + 'expiry_date' => '', + 'usage_limit' => 0, + 'usage_count' => 0, + ]; + update_option('skrift_konfigurator_vouchers', $vouchers); + echo '

    ✅ Gutschein TEST10 wurde erstellt! Seite neu laden...

    '; + echo ''; + } + ?> +
    + + + diff --git a/Webseite/skrift-configurator/create-test-voucher.php b/Webseite/skrift-configurator/create-test-voucher.php new file mode 100644 index 0000000..adfcb45 --- /dev/null +++ b/Webseite/skrift-configurator/create-test-voucher.php @@ -0,0 +1,61 @@ + 'TEST10', + 'type' => 'percent', + 'value' => 10, + 'expiry_date' => '', // Unbegrenzt + 'usage_limit' => 0, // Unbegrenzt + 'usage_count' => 0, +]; + +// Test-Gutschein 2: 5€ Rabatt +$vouchers['SAVE5'] = [ + 'code' => 'SAVE5', + 'type' => 'fixed', + 'value' => 5.00, + 'expiry_date' => '', // Unbegrenzt + 'usage_limit' => 0, // Unbegrenzt + 'usage_count' => 0, +]; + +// Test-Gutschein 3: 20% Rabatt mit Limit +$vouchers['WELCOME20'] = [ + 'code' => 'WELCOME20', + 'type' => 'percent', + 'value' => 20, + 'expiry_date' => date('Y-m-d', strtotime('+30 days')), // 30 Tage gültig + 'usage_limit' => 10, // Max 10x einlösbar + 'usage_count' => 0, +]; + +update_option('skrift_konfigurator_vouchers', $vouchers); + +echo '

    ✓ Test-Gutscheine erfolgreich erstellt!

    '; +echo '
      '; +echo '
    • TEST10 - 10% Rabatt (unbegrenzt)
    • '; +echo '
    • SAVE5 - 5,00€ Rabatt (unbegrenzt)
    • '; +echo '
    • WELCOME20 - 20% Rabatt (30 Tage gültig, max. 10x)
    • '; +echo '
    '; +echo '

    → Gutscheine im Backend anzeigen

    '; +echo '

    WICHTIG: Bitte löschen Sie diese Datei jetzt aus Sicherheitsgründen!

    '; diff --git a/Webseite/skrift-configurator/debug-vouchers.php b/Webseite/skrift-configurator/debug-vouchers.php new file mode 100644 index 0000000..b445914 --- /dev/null +++ b/Webseite/skrift-configurator/debug-vouchers.php @@ -0,0 +1,72 @@ +Gutschein Debug'; +echo ''; + +// 1. Direkt aus Datenbank lesen +$vouchers_db = get_option('skrift_konfigurator_vouchers', []); +echo '

    1. Direkt aus Datenbank (get_option)

    '; +echo '
    ';
    +print_r($vouchers_db);
    +echo '
    '; + +// 2. Über die Klasse +require_once plugin_dir_path(__FILE__) . 'includes/admin-vouchers.php'; +$vouchers_class = Skrift_Konfigurator_Vouchers::get_vouchers(); +echo '

    2. Über Klassen-Methode get_vouchers()

    '; +echo '
    ';
    +print_r($vouchers_class);
    +echo '
    '; + +// 3. JSON-Encoding prüfen (wie wp_localize_script es macht) +$vouchers_json = json_encode($vouchers_class); +echo '

    3. JSON-Encoded (wie wp_localize_script)

    '; +echo '
    ';
    +echo htmlspecialchars($vouchers_json);
    +echo '
    '; + +// 4. Zurück decodiert +$vouchers_decoded = json_decode($vouchers_json, true); +echo '

    4. JSON wieder decodiert

    '; +echo '
    ';
    +print_r($vouchers_decoded);
    +echo '
    '; + +// 5. Test: Ist es ein assoziatives Array oder Objekt? +echo '

    5. Datentyp-Analyse

    '; +echo '
    ';
    +echo 'is_array: ' . (is_array($vouchers_class) ? 'JA' : 'NEIN') . "\n";
    +echo 'count: ' . count($vouchers_class) . "\n";
    +echo 'empty: ' . (empty($vouchers_class) ? 'JA' : 'NEIN') . "\n";
    +echo 'Keys: ' . print_r(array_keys($vouchers_class), true) . "\n";
    +echo '
    '; + +// 6. Simuliere wp_localize_script +echo '

    6. Simuliertes wp_localize_script Output

    '; +echo ''; + +echo '

    Öffnen Sie die Browser-Console, um das simulierte Output zu sehen!

    '; diff --git a/Webseite/skrift-configurator/includes/admin-orders.php b/Webseite/skrift-configurator/includes/admin-orders.php new file mode 100644 index 0000000..d4655b8 --- /dev/null +++ b/Webseite/skrift-configurator/includes/admin-orders.php @@ -0,0 +1,186 @@ + 'POST', + 'callback' => [$this, 'rest_generate_order_number'], + 'permission_callback' => ['Skrift_Konfigurator_Admin_Settings', 'rest_api_key_permission'], + ]); + + // Bestellung registrieren (nach erfolgreicher Zahlung) + register_rest_route('skrift/v1', '/order/register', [ + 'methods' => 'POST', + 'callback' => [$this, 'rest_register_order'], + 'permission_callback' => ['Skrift_Konfigurator_Admin_Settings', 'rest_api_key_permission'], + ]); + } + + /** + * Generiert die nächste fortlaufende Bestellnummer + * Format: S-YYYY-MM-DD-XXX (z.B. S-2026-01-12-001) + * Verwendet Transient-Lock um Race Conditions zu vermeiden + */ + public static function generate_order_number() { + $lock_key = 'skrift_order_number_lock'; + $max_attempts = 10; + $attempt = 0; + + // Versuche Lock zu bekommen (einfaches Locking mit Transients) + while ($attempt < $max_attempts) { + // Prüfe ob Lock existiert + if (get_transient($lock_key) === false) { + // Setze Lock (5 Sekunden Timeout) + set_transient($lock_key, time(), 5); + + // Aktuellen Counter holen + $counter_data = get_option(self::COUNTER_OPTION_KEY, [ + 'date' => '', + 'counter' => 0 + ]); + + $today = date('Y-m-d'); + + // Counter zurücksetzen wenn neuer Tag + if ($counter_data['date'] !== $today) { + $counter_data = [ + 'date' => $today, + 'counter' => 0 + ]; + } + + // Counter erhöhen + $counter_data['counter']++; + + // Speichern + update_option(self::COUNTER_OPTION_KEY, $counter_data); + + // Lock freigeben + delete_transient($lock_key); + + // Format: S-YYYY-MM-DD-XXX + $year = date('Y'); + $month = date('m'); + $day = date('d'); + $number = str_pad($counter_data['counter'], 3, '0', STR_PAD_LEFT); + + return "S-{$year}-{$month}-{$day}-{$number}"; + } + + // Warte kurz und versuche erneut + usleep(100000); // 100ms + $attempt++; + } + + // Fallback wenn Lock nicht bekommen werden konnte + // Verwende Microtime für Eindeutigkeit + $year = date('Y'); + $month = date('m'); + $day = date('d'); + $micro = substr(str_replace('.', '', microtime(true)), -6); + + return "S-{$year}-{$month}-{$day}-{$micro}"; + } + + /** + * REST API Endpoint: Neue Bestellnummer generieren + */ + public function rest_generate_order_number($request) { + $order_number = self::generate_order_number(); + + return [ + 'success' => true, + 'orderNumber' => $order_number + ]; + } + + /** + * REST API Endpoint: Bestellung registrieren + */ + public function rest_register_order($request) { + $order_number = $request->get_param('orderNumber'); + $customer = $request->get_param('customer'); + $quote = $request->get_param('quote'); + $payment_method = $request->get_param('paymentMethod'); + $payment_id = $request->get_param('paymentId'); + + if (empty($order_number)) { + return new WP_Error('missing_order_number', 'Bestellnummer fehlt', ['status' => 400]); + } + + // Bestellung speichern + $orders = get_option(self::ORDERS_OPTION_KEY, []); + + $orders[$order_number] = [ + 'orderNumber' => $order_number, + 'customer' => $customer, + 'quote' => $quote, + 'paymentMethod' => $payment_method, + 'paymentId' => $payment_id, + 'createdAt' => current_time('mysql'), + 'status' => 'pending' + ]; + + update_option(self::ORDERS_OPTION_KEY, $orders); + + return [ + 'success' => true, + 'orderNumber' => $order_number, + 'message' => 'Bestellung erfolgreich registriert' + ]; + } + + /** + * Bestellung als bezahlt markieren + */ + public static function mark_order_paid($order_number, $payment_id = null) { + $orders = get_option(self::ORDERS_OPTION_KEY, []); + + if (isset($orders[$order_number])) { + $orders[$order_number]['status'] = 'paid'; + $orders[$order_number]['paidAt'] = current_time('mysql'); + if ($payment_id) { + $orders[$order_number]['paymentId'] = $payment_id; + } + update_option(self::ORDERS_OPTION_KEY, $orders); + return true; + } + + return false; + } + + /** + * Alle Bestellungen abrufen + */ + public static function get_orders() { + return get_option(self::ORDERS_OPTION_KEY, []); + } + + /** + * Einzelne Bestellung abrufen + */ + public static function get_order($order_number) { + $orders = self::get_orders(); + return $orders[$order_number] ?? null; + } +} + +new Skrift_Konfigurator_Orders(); diff --git a/Webseite/skrift-configurator/includes/admin-settings.php b/Webseite/skrift-configurator/includes/admin-settings.php new file mode 100644 index 0000000..acaf95e --- /dev/null +++ b/Webseite/skrift-configurator/includes/admin-settings.php @@ -0,0 +1,944 @@ + 'array', + 'sanitize_callback' => [$this, 'sanitize_settings'], + ]); + } + + public function sanitize_settings($input) { + $sanitized = []; + + // Produkte sanitieren + if (isset($input['products']) && is_array($input['products'])) { + $sanitized['products'] = []; + foreach ($input['products'] as $key => $product) { + $sanitized['products'][sanitize_key($key)] = [ + 'label' => sanitize_text_field($product['label'] ?? ''), + 'description' => sanitize_textarea_field($product['description'] ?? ''), + 'base_price' => floatval($product['base_price'] ?? 0), + ]; + } + } + + // Preise sanitieren + if (isset($input['prices']) && is_array($input['prices'])) { + $sanitized['prices'] = []; + foreach ($input['prices'] as $key => $value) { + $sanitized['prices'][sanitize_key($key)] = floatval($value); + } + } + + // Dynamische Preisformeln sanitieren + if (isset($input['dynamic_pricing'])) { + $sanitized['dynamic_pricing'] = [ + 'business_formula' => sanitize_textarea_field($input['dynamic_pricing']['business_formula'] ?? ''), + 'private_formula' => sanitize_textarea_field($input['dynamic_pricing']['private_formula'] ?? ''), + 'business_min_quantity' => intval($input['dynamic_pricing']['business_min_quantity'] ?? 0), + 'private_min_quantity' => intval($input['dynamic_pricing']['private_min_quantity'] ?? 0), + 'business_normal_quantity' => intval($input['dynamic_pricing']['business_normal_quantity'] ?? 0), + 'private_normal_quantity' => intval($input['dynamic_pricing']['private_normal_quantity'] ?? 0), + ]; + } + + // Backend-Verbindung sanitieren + if (isset($input['backend_connection'])) { + $sanitized['backend_connection'] = [ + 'api_url' => esc_url_raw($input['backend_connection']['api_url'] ?? ''), + 'api_token' => sanitize_text_field($input['backend_connection']['api_token'] ?? ''), + 'webhook_url_business' => esc_url_raw($input['backend_connection']['webhook_url_business'] ?? ''), + 'webhook_url_private' => esc_url_raw($input['backend_connection']['webhook_url_private'] ?? ''), + 'redirect_url_business' => esc_url_raw($input['backend_connection']['redirect_url_business'] ?? ''), + 'redirect_url_private' => esc_url_raw($input['backend_connection']['redirect_url_private'] ?? ''), + ]; + } + + // REST API Key sanitieren + if (isset($input['api_security'])) { + $sanitized['api_security'] = [ + 'api_key' => sanitize_text_field($input['api_security']['api_key'] ?? ''), + ]; + } + + // PayPal-Verbindung sanitieren + if (isset($input['paypal'])) { + $sanitized['paypal'] = [ + 'enabled' => !empty($input['paypal']['enabled']), + 'mode' => sanitize_text_field($input['paypal']['mode'] ?? 'sandbox'), + 'client_id_sandbox' => sanitize_text_field($input['paypal']['client_id_sandbox'] ?? ''), + 'client_secret_sandbox' => sanitize_text_field($input['paypal']['client_secret_sandbox'] ?? ''), + 'client_id_live' => sanitize_text_field($input['paypal']['client_id_live'] ?? ''), + 'client_secret_live' => sanitize_text_field($input['paypal']['client_secret_live'] ?? ''), + ]; + } + + // Schriftmuster und Platzhalter-Hilfe sanitieren + if (isset($input['font_sample'])) { + $sanitized['font_sample'] = [ + 'url' => esc_url_raw($input['font_sample']['url'] ?? ''), + 'placeholder_help_url' => esc_url_raw($input['font_sample']['placeholder_help_url'] ?? ''), + ]; + } + + return $sanitized; + } + + public function render_settings_page(): void { + if (!current_user_can('manage_options')) { + return; + } + + $settings = $this->get_settings(); + ?> + +
    +

    +
    + + + +
    +

    📦 Produkte

    +

    Verwalten Sie Namen, Beschreibungen und Startpreise für alle Produkte.

    + +
    + render_product_card('businessbriefe', 'Businessbriefe', $settings); ?> + render_product_card('business-postkarten', 'Business Postkarten', $settings); ?> + render_product_card('follow-ups', 'Follow-ups', $settings); ?> + render_product_card('einladungen', 'Einladungen', $settings); ?> + render_product_card('private-briefe', 'Private Briefe', $settings); ?> +
    +
    + + +
    +

    📐 Format Aufpreise

    +

    Aufpreise wenn bei bestimmten Produkten das Format gewechselt wird.

    +
    +
    + + Aufpreis pro Stück wenn bei Postkarten oder Einladungen auf A4 gewechselt wird +
    +
    +
    + + +
    +

    🚚 Versand & Umschlag

    +
    +
    + + Portokosten für Versand innerhalb Deutschlands (0% MwSt.) +
    +
    + + Portokosten für Auslandsversand (0% MwSt.) +
    +
    + + Service-Aufschlag für Direktversand (19% MwSt.) +
    +
    + +
    +
    + + Grundpreis für Kuvert +
    +
    + + Aufschlag für Beschriftung des Umschlags (Empfängeradresse oder individueller Text) +
    +
    +
    + + +
    +

    ✨ Zusatzleistungen

    +
    +
    + +
    +
    + +
    +
    + +
    +
    + +
    +
    + + Einmalige Einrichtungsgebühr für die API-Anbindung +
    +
    +
    + + +
    +

    📊 Follow-ups Preis-Multiplikatoren

    +

    Die Gesamtkosten pro Schriftstück werden mit diesen Multiplikatoren je nach Menge multipliziert.

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    MengeMultiplikator
    5 - 49 Stück + + × +
    50 - 199 Stück + + × +
    200 - 499 Stück + + × +
    500 - 999 Stück + + × +
    1000+ Stück + + × +
    +
    + + +
    +

    📋 Steuern

    +
    +
    + + Gilt für alle Positionen inkl. Versand +
    +
    +
    + + +
    +

    🧮 Dynamische Preisberechnung

    +

    Konfigurieren Sie die dynamische Preisberechnung basierend auf Mengen. Die Formeln unterstützen Platzhalter wie %qty% (aktuelle Menge), %norm_b% (Normalpreis Menge Business), %mind_b% (Mind. Menge Business), %norm_p% (Normalpreis Menge Privat), %mind_p% (Mind. Menge Privat).

    + +
    + + +
    +

    Business

    + +
    + + + Minimale Menge für Business-Bestellungen (außer Follow-ups) +
    + +
    + + + Ab dieser Menge gilt der Normalpreis (Multiplikator = 1) +
    + +
    + + + + JavaScript-Formel zur Berechnung des Preis-Multiplikators.
    + Platzhalter verwenden: %qty% für aktuelle Menge, %norm_b% für Normalpreis-Menge Business, %mind_b% für Mindestmenge Business.
    + Beispiel: (%qty% >= %norm_b%) ? 1 : (2 - Math.sqrt(%qty% / %norm_b%))
    + Dies bedeutet: Wenn die Menge >= Normalpreis-Menge ist, dann Multiplikator = 1, sonst dynamische Berechnung. +
    +
    +
    + + +
    +

    Privat

    + +
    + + + Minimale Menge für Private Bestellungen +
    + +
    + + + Ab dieser Menge gilt der Normalpreis (Multiplikator = 1) +
    + +
    + + + + JavaScript-Formel zur Berechnung des Preis-Multiplikators.
    + Platzhalter verwenden: %qty% für aktuelle Menge, %norm_p% für Normalpreis-Menge Privat, %mind_p% für Mindestmenge Privat.
    + Beispiel: (%qty% >= %norm_p%) ? 1 : (2 - Math.sqrt(%qty% / %norm_p%))
    + Dies bedeutet: Wenn die Menge >= Normalpreis-Menge ist, dann Multiplikator = 1, sonst dynamische Berechnung. +
    +
    +
    + +
    + +
    + Verfügbare Platzhalter: +
      +
    • %qty% - Aktuelle Menge (eingegebene Stückzahl)
    • +
    • %norm_b% - Normalpreis Menge Business
    • +
    • %mind_b% - Mindestmenge Business
    • +
    • %norm_p% - Normalpreis Menge Privat
    • +
    • %mind_p% - Mindestmenge Privat
    • +
    +
    +
    + + +
    +

    🔌 Backend-Verbindung

    +

    Konfigurieren Sie die Verbindung zum Backend-System für erweiterte Funktionen.

    + +
    +
    + + + Basis-URL des Backend-Systems (z.B. https://api.example.com) +
    + +
    + + + Authentifizierungs-Token für API-Zugriff +
    + +
    + + + Webhook wird nach Klick auf "Jetzt kostenpflichtig bestellen" für Geschäftskunden aufgerufen +
    + +
    + + + Webhook wird nach erfolgreicher PayPal-Zahlung für Privatkunden aufgerufen +
    + +
    + + + Weiterleitung nach Bestellung für Geschäftskunden (nach Klick auf "Jetzt kostenpflichtig bestellen") +
    + +
    + + + Weiterleitung nach erfolgreicher PayPal-Zahlung für Privatkunden +
    + +
    +
    + + +
    +

    🔐 REST API Sicherheit

    +

    Konfigurieren Sie einen API-Key für die REST-API-Endpunkte. Dieser Key muss im Header X-Skrift-API-Key mitgesendet werden.

    + +
    +
    + +
    + + +
    + Leer lassen um API-Key-Prüfung zu deaktivieren (nicht empfohlen für Produktion) +
    +
    +
    + + +
    +

    ✍️ Schriftmuster (Vorschau-Fallback)

    +

    Wenn die Vorschau-Generierung fehlschlägt oder nicht verfügbar ist, wird ein "Schriftmuster ansehen"-Link angezeigt. Der Link öffnet sich in einem neuen Tab.

    + +
    +
    + + + URL zur Schriftmuster-Seite (wird in neuem Tab geöffnet) +
    + +
    + + + URL zur Platzhalter-Hilfeseite (wird bei Platzhalter-Infotexten als Link angezeigt) +
    +
    +
    + + +
    +

    💳 PayPal-Verbindung (nur Privatkunden)

    +

    Konfigurieren Sie die PayPal-Zahlungsintegration. PayPal ist nur für Privatkunden aktiviert.

    + +
    +
    + + Aktiviert PayPal als Zahlungsoption für Privatkunden +
    + +
    + + + Sandbox für Tests, Live für echte Zahlungen +
    +
    + +

    Sandbox-Zugangsdaten (Test)

    +
    +
    + + +
    + +
    + + +
    +
    + +

    Live-Zugangsdaten (Produktion)

    +
    +
    + + +
    + +
    + + +
    +
    + +
    + Hinweis: Um PayPal zu aktivieren, benötigen Sie ein PayPal Business-Konto. + Sie erhalten die API-Zugangsdaten im PayPal Developer Dashboard. +
    +
    + + +
    + + +
    +

    URL-Parameter

    +

    Der Konfigurator unterstützt folgende URL-Parameter:

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    ParameterWerteBeschreibung
    ?businessbriefe
    + ?business-postkarten
    + ?follow-ups
    + ?einladungen
    + ?private-briefe
    –Produkt direkt vorauswählen. Der Produktauswahlschritt wird übersprungen.
    quantityZahl (z.B. 100)Menge vorausfüllen.
    formata4, a6h, a6qFormat vorauswählen. a6h = A6 Hochformat, a6q = A6 Querformat.
    noPrice–Preise im Konfigurator ausblenden.
    noLimits–Keine Mindestmengen. Erlaubt Bestellungen ab 1 Stück.
    + +

    Beispiele

    +
      +
    • /konfigurator/?businessbriefe – Direkt zu Business Briefe
    • +
    • /konfigurator/?einladungen&quantity=25&format=a6h – Einladungen mit 25 Stück im A6 Hochformat
    • +
    • /konfigurator/?businessbriefe&noPrice – Business Briefe ohne Preisanzeige
    • +
    • /konfigurator/?private-briefe&noLimits – Private Briefe ohne Mindestmenge
    • +
    • /konfigurator/?business-postkarten&noLimits&noPrice – Postkarten ohne Mindestmenge und ohne Preise
    • +
    +
    +
    + +
    +

    + +
    + + +
    + +
    + + +
    + +
    + +
    + € +
    +
    +
    + [ + 'businessbriefe' => [ + 'label' => 'Businessbriefe', + 'description' => 'Professionelle handgeschriebene Korrespondenz', + 'base_price' => 2.50, + ], + 'business-postkarten' => [ + 'label' => 'Business Postkarten', + 'description' => 'Professionelle handgeschriebene Korrespondenz', + 'base_price' => 1.80, + ], + 'follow-ups' => [ + 'label' => 'Follow-ups', + 'description' => 'Professionelle handgeschriebene Korrespondenz', + 'base_price' => 2.50, + ], + 'einladungen' => [ + 'label' => 'Einladungen', + 'description' => 'Professionelle handgeschriebene Korrespondenz', + 'base_price' => 1.80, + ], + 'private-briefe' => [ + 'label' => 'Private Briefe', + 'description' => 'Professionelle handgeschriebene Korrespondenz', + 'base_price' => 2.50, + ], + ], + 'prices' => [ + // Versand & Umschlag + 'shipping_domestic' => 0.95, // Porto Inland + 'shipping_international' => 1.25, // Porto Ausland + 'shipping_service' => 0.95, // Serviceaufschlag Versand + 'shipping_bulk' => 4.95, // Bulkversand einmalig + 'envelope_base' => 0.50, // Kuvert Grundpreis + 'envelope_labeling' => 0.50, // Aufschlag Beschriftung + // Legacy fields for backwards compatibility + 'shipping_direct' => 2.40, + 'envelope_recipient_address' => 0.50, + 'envelope_custom_text' => 0.30, + + // Format Aufpreise + 'a4_upgrade_surcharge' => 0.50, + + // Zusatzleistungen + 'motif_upload' => 0.30, + 'motif_printed' => 0.00, + 'motif_design' => 0.00, + 'textservice' => 0.00, + 'api_connection' => 250.00, + + // Follow-ups Multiplikatoren + 'followup_mult_5_49' => 2.0, + 'followup_mult_50_199' => 1.7, + 'followup_mult_200_499' => 1.4, + 'followup_mult_500_999' => 1.2, + 'followup_mult_1000_plus' => 1.0, + + // Steuern + 'tax_rate' => 19, + 'shipping_tax_rate' => 0, + ], + 'dynamic_pricing' => [ + 'business_formula' => "(%qty% >= %norm_b%) ? 1 : (2 - Math.sqrt(%qty% / %norm_b%))", + 'private_formula' => "(%qty% >= %norm_p%) ? 1 : (2 - Math.sqrt(%qty% / %norm_p%))", + 'business_min_quantity' => 50, + 'private_min_quantity' => 10, + 'business_normal_quantity' => 200, + 'private_normal_quantity' => 50, + ], + 'backend_connection' => [ + 'api_url' => '', + 'api_token' => '', + 'webhook_url_business' => '', + 'webhook_url_private' => '', + 'redirect_url_business' => '', + 'redirect_url_private' => '', + ], + 'paypal' => [ + 'enabled' => false, + 'mode' => 'sandbox', + 'client_id_sandbox' => '', + 'client_secret_sandbox' => '', + 'client_id_live' => '', + 'client_secret_live' => '', + ], + 'api_security' => [ + 'api_key' => '', + ], + 'font_sample' => [ + 'url' => '', + 'placeholder_help_url' => '', + ], + ]; + + $saved = get_option(self::OPTION_KEY, []); + + // Merge nested arrays properly + $merged = $defaults; + foreach (['products', 'prices', 'dynamic_pricing', 'backend_connection', 'paypal', 'api_security', 'font_sample'] as $section) { + if (isset($saved[$section]) && is_array($saved[$section])) { + $merged[$section] = array_merge($defaults[$section], $saved[$section]); + } + } + + return $merged; + } + + /** + * Prüft ob ein API-Key gültig ist + */ + public static function validate_api_key($provided_key): bool { + $settings = self::get_settings(); + $stored_key = $settings['api_security']['api_key'] ?? ''; + + // Wenn kein Key konfiguriert ist, ist alles erlaubt (für Entwicklung) + if (empty($stored_key)) { + return true; + } + + // Key vergleichen (timing-safe) + return hash_equals($stored_key, $provided_key); + } + + /** + * Permission Callback für REST API mit API-Key + */ + public static function rest_api_key_permission($request): bool { + $api_key = $request->get_header('X-Skrift-API-Key'); + return self::validate_api_key($api_key ?? ''); + } +} + +new Skrift_Konfigurator_Admin_Settings(); diff --git a/Webseite/skrift-configurator/includes/admin-vouchers.php b/Webseite/skrift-configurator/includes/admin-vouchers.php new file mode 100644 index 0000000..5702f44 --- /dev/null +++ b/Webseite/skrift-configurator/includes/admin-vouchers.php @@ -0,0 +1,403 @@ + 'array', + 'sanitize_callback' => [$this, 'sanitize_vouchers'], + ]); + } + + public function sanitize_vouchers($input) { + if (!is_array($input)) { + return []; + } + + $sanitized = []; + foreach ($input as $code => $voucher) { + // Verwende den originalen Code (in Großbuchstaben) als Key + $voucherCode = strtoupper(sanitize_text_field($voucher['code'] ?? '')); + $sanitized[$voucherCode] = [ + 'code' => $voucherCode, + 'type' => in_array($voucher['type'] ?? '', ['percent', 'fixed']) ? $voucher['type'] : 'percent', + 'value' => floatval($voucher['value'] ?? 0), + 'expiry_date' => sanitize_text_field($voucher['expiry_date'] ?? ''), + 'usage_limit' => intval($voucher['usage_limit'] ?? 0), + 'usage_count' => intval($voucher['usage_count'] ?? 0), + ]; + } + + return $sanitized; + } + + public function handle_add_voucher(): void { + if (!current_user_can('manage_options')) { + wp_die('Keine Berechtigung'); + } + + check_admin_referer('sk_add_voucher'); + + $vouchers = get_option(self::OPTION_KEY, []); + $code = strtoupper(sanitize_text_field($_POST['voucher_code'] ?? '')); + + if (empty($code)) { + wp_redirect(add_query_arg(['page' => 'skrift-vouchers', 'error' => 'empty_code'], admin_url('options-general.php'))); + exit; + } + + if (isset($vouchers[$code])) { + wp_redirect(add_query_arg(['page' => 'skrift-vouchers', 'error' => 'duplicate'], admin_url('options-general.php'))); + exit; + } + + $vouchers[$code] = [ + 'code' => $code, + 'type' => sanitize_text_field($_POST['voucher_type'] ?? 'percent'), + 'value' => floatval($_POST['voucher_value'] ?? 0), + 'expiry_date' => sanitize_text_field($_POST['voucher_expiry'] ?? ''), + 'usage_limit' => intval($_POST['voucher_limit'] ?? 0), + 'usage_count' => 0, + ]; + + update_option(self::OPTION_KEY, $vouchers); + + wp_redirect(add_query_arg(['page' => 'skrift-vouchers', 'success' => 'added'], admin_url('options-general.php'))); + exit; + } + + public function handle_delete_voucher(): void { + if (!current_user_can('manage_options')) { + wp_die('Keine Berechtigung'); + } + + check_admin_referer('sk_delete_voucher'); + + $code = sanitize_text_field($_GET['code'] ?? ''); + $vouchers = get_option(self::OPTION_KEY, []); + + if (isset($vouchers[$code])) { + unset($vouchers[$code]); + update_option(self::OPTION_KEY, $vouchers); + } + + wp_redirect(add_query_arg(['page' => 'skrift-vouchers', 'success' => 'deleted'], admin_url('options-general.php'))); + exit; + } + + public function render_vouchers_page(): void { + if (!current_user_can('manage_options')) { + return; + } + + $vouchers = get_option(self::OPTION_KEY, []); + ?> +
    +

    Gutschein-Verwaltung

    + + +
    +

    + +

    +
    + + + +
    +

    + +

    +
    + + +
    +

    Neuen Gutschein erstellen

    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + +

    Code wird automatisch in Großbuchstaben umgewandelt

    +
    + +
    + +

    Bei Prozent: z.B. 10 für 10% | Bei Festbetrag: z.B. 5.00 für 5€

    +
    + +

    Leer lassen für unbegrenzte Gültigkeit

    +
    + +

    0 = Unbegrenzt oft einlösbar

    +
    + + +
    +
    + +
    +

    Vorhandene Gutscheine

    + + +

    Noch keine Gutscheine vorhanden.

    + + + + + + + + + + + + + + + + $voucher): + $is_expired = !empty($voucher['expiry_date']) && strtotime($voucher['expiry_date']) < time(); + $is_used_up = $voucher['usage_limit'] > 0 && $voucher['usage_count'] >= $voucher['usage_limit']; + $is_active = !$is_expired && !$is_used_up; + ?> + + + + + + + + + + + + +
    CodeTypWertAblaufdatumLimitEingelöstStatusAktionen
    + + + + 0 ? $voucher['usage_limit'] : 'Unbegrenzt'; ?> + + ✓ Aktiv + + ✗ Abgelaufen + + ✗ Limit erreicht + + + + Löschen + +
    + +
    +
    + false, 'error' => 'Gutschein nicht gefunden']; + } + + $voucher = $vouchers[$code]; + + // Ablaufdatum prüfen + if (!empty($voucher['expiry_date']) && strtotime($voucher['expiry_date']) < time()) { + return ['valid' => false, 'error' => 'Gutschein ist abgelaufen']; + } + + // Nutzungslimit prüfen + if ($voucher['usage_limit'] > 0 && $voucher['usage_count'] >= $voucher['usage_limit']) { + return ['valid' => false, 'error' => 'Gutschein wurde bereits zu oft eingelöst']; + } + + return [ + 'valid' => true, + 'voucher' => $voucher + ]; + } + + /** + * Markiert einen Gutschein als verwendet + */ + public static function use_voucher($code) { + $vouchers = self::get_vouchers(); + $code = strtoupper(trim($code)); + + if (isset($vouchers[$code])) { + $vouchers[$code]['usage_count']++; + update_option(self::OPTION_KEY, $vouchers); + return true; + } + + return false; + } + + /** + * REST API Routen registrieren + */ + public function register_rest_routes() { + register_rest_route('skrift/v1', '/voucher/use', [ + 'methods' => 'POST', + 'callback' => [$this, 'rest_use_voucher'], + 'permission_callback' => ['Skrift_Konfigurator_Admin_Settings', 'rest_api_key_permission'], + ]); + + register_rest_route('skrift/v1', '/voucher/validate', [ + 'methods' => 'POST', + 'callback' => [$this, 'rest_validate_voucher'], + 'permission_callback' => ['Skrift_Konfigurator_Admin_Settings', 'rest_api_key_permission'], + ]); + } + + /** + * REST API Endpoint: Gutschein validieren + */ + public function rest_validate_voucher($request) { + $code = $request->get_param('code'); + + if (empty($code)) { + return new WP_Error('missing_code', 'Gutschein-Code fehlt', ['status' => 400]); + } + + $result = self::validate_voucher($code); + + if ($result['valid']) { + return [ + 'valid' => true, + 'voucher' => [ + 'code' => $result['voucher']['code'], + 'type' => $result['voucher']['type'], + 'value' => $result['voucher']['value'], + ] + ]; + } else { + return [ + 'valid' => false, + 'error' => $result['error'] + ]; + } + } + + /** + * REST API Endpoint: Gutschein als verwendet markieren + */ + public function rest_use_voucher($request) { + $code = $request->get_param('code'); + + if (empty($code)) { + return new WP_Error('missing_code', 'Gutschein-Code fehlt', ['status' => 400]); + } + + $result = self::use_voucher($code); + + if ($result) { + return ['success' => true, 'message' => 'Gutschein wurde als verwendet markiert']; + } else { + return new WP_Error('invalid_code', 'Ungültiger Gutschein-Code', ['status' => 404]); + } + } +} + +new Skrift_Konfigurator_Vouchers(); diff --git a/Webseite/skrift-configurator/includes/api-proxy.php b/Webseite/skrift-configurator/includes/api-proxy.php new file mode 100644 index 0000000..3969d82 --- /dev/null +++ b/Webseite/skrift-configurator/includes/api-proxy.php @@ -0,0 +1,382 @@ + 'GET', + 'callback' => [$this, 'proxy_health_check'], + 'permission_callback' => ['Skrift_Konfigurator_Admin_Settings', 'rest_api_key_permission'], + ]); + + // Preview Batch generieren + register_rest_route('skrift/v1', '/proxy/preview/batch', [ + 'methods' => 'POST', + 'callback' => [$this, 'proxy_preview_batch'], + 'permission_callback' => ['Skrift_Konfigurator_Admin_Settings', 'rest_api_key_permission'], + ]); + + // Einzelne Preview abrufen + register_rest_route('skrift/v1', '/proxy/preview/(?P[a-zA-Z0-9_-]+)/(?P\d+)', [ + 'methods' => 'GET', + 'callback' => [$this, 'proxy_preview_get'], + 'permission_callback' => ['Skrift_Konfigurator_Admin_Settings', 'rest_api_key_permission'], + ]); + + // Order generieren + register_rest_route('skrift/v1', '/proxy/order/generate', [ + 'methods' => 'POST', + 'callback' => [$this, 'proxy_order_generate'], + 'permission_callback' => ['Skrift_Konfigurator_Admin_Settings', 'rest_api_key_permission'], + ]); + + // Order finalisieren + register_rest_route('skrift/v1', '/proxy/order/finalize', [ + 'methods' => 'POST', + 'callback' => [$this, 'proxy_order_finalize'], + 'permission_callback' => ['Skrift_Konfigurator_Admin_Settings', 'rest_api_key_permission'], + ]); + + register_rest_route('skrift/v1', '/proxy/order/register', [ + 'methods' => 'POST', + 'callback' => [$this, 'proxy_order_register'], + 'permission_callback' => ['Skrift_Konfigurator_Admin_Settings', 'rest_api_key_permission'], + ]); + + // Motiv hochladen (ans Docker-Backend weiterleiten) + register_rest_route('skrift/v1', '/proxy/motif/upload', [ + 'methods' => 'POST', + 'callback' => [$this, 'proxy_motif_upload'], + 'permission_callback' => ['Skrift_Konfigurator_Admin_Settings', 'rest_api_key_permission'], + ]); + } + + /** + * Holt Backend-Konfiguration + */ + private function get_backend_config(): array { + $settings = Skrift_Konfigurator_Admin_Settings::get_settings(); + return [ + 'api_url' => $settings['backend_connection']['api_url'] ?? '', + 'api_token' => $settings['backend_connection']['api_token'] ?? '', + ]; + } + + /** + * Führt einen HTTP-Request ans Backend aus + */ + private function make_backend_request(string $method, string $endpoint, array $body = null): array { + $config = $this->get_backend_config(); + + if (empty($config['api_url'])) { + return [ + 'success' => false, + 'error' => 'Backend API URL nicht konfiguriert', + 'status' => 500, + ]; + } + + $url = rtrim($config['api_url'], '/') . $endpoint; + + $args = [ + 'method' => $method, + 'timeout' => 60, + 'headers' => [ + 'Content-Type' => 'application/json', + ], + ]; + + // API Token hinzufügen wenn vorhanden + if (!empty($config['api_token'])) { + $args['headers']['X-API-Token'] = $config['api_token']; + } + + // Body hinzufügen bei POST/PUT + if ($body !== null && in_array($method, ['POST', 'PUT', 'PATCH'])) { + $args['body'] = wp_json_encode($body); + } + + $response = wp_remote_request($url, $args); + + if (is_wp_error($response)) { + return [ + 'success' => false, + 'error' => $response->get_error_message(), + 'status' => 500, + ]; + } + + $status_code = wp_remote_retrieve_response_code($response); + $response_body = wp_remote_retrieve_body($response); + + // Versuche JSON zu parsen + $data = json_decode($response_body, true); + + if ($status_code >= 200 && $status_code < 300) { + return [ + 'success' => true, + 'data' => $data ?? $response_body, + 'status' => $status_code, + ]; + } + + return [ + 'success' => false, + 'error' => $data['error'] ?? $data['message'] ?? 'Backend-Fehler', + 'status' => $status_code, + 'data' => $data, + ]; + } + + /** + * Health Check Proxy + */ + public function proxy_health_check($request) { + $result = $this->make_backend_request('GET', '/health'); + + if (!$result['success']) { + return new WP_Error('backend_error', $result['error'], ['status' => $result['status']]); + } + + return $result['data']; + } + + /** + * Preview Batch Proxy + */ + public function proxy_preview_batch($request) { + $body = $request->get_json_params(); + + if (empty($body)) { + return new WP_Error('invalid_request', 'Keine Daten empfangen', ['status' => 400]); + } + + $result = $this->make_backend_request('POST', '/api/preview/batch', $body); + + if (!$result['success']) { + // Rate Limiting Info weitergeben + if ($result['status'] === 429 && isset($result['data']['retryAfter'])) { + return new WP_REST_Response([ + 'error' => $result['error'], + 'retryAfter' => $result['data']['retryAfter'], + ], 429); + } + // Overflow-Daten durchreichen: Backend liefert hasOverflow/overflowFiles auch bei Fehler-Status + if (!empty($result['data']['hasOverflow'])) { + return new WP_REST_Response([ + 'hasOverflow' => true, + 'overflowFiles' => $result['data']['overflowFiles'] ?? [], + 'files' => $result['data']['files'] ?? [], + 'error' => $result['error'], + ], 200); + } + return new WP_Error('backend_error', $result['error'], ['status' => $result['status']]); + } + + return $result['data']; + } + + /** + * Einzelne Preview abrufen Proxy + */ + public function proxy_preview_get($request) { + $session_id = $request->get_param('sessionId'); + $index = $request->get_param('index'); + + // Sicherheits-Validierung: Session-ID darf nur alphanumerische Zeichen, Unterstriche und Bindestriche enthalten + if (!preg_match('/^[a-zA-Z0-9_-]+$/', $session_id)) { + return new WP_Error('invalid_session_id', 'Ungültige Session-ID', ['status' => 400]); + } + + // Index muss eine positive Ganzzahl sein + $index = absint($index); + + $result = $this->make_backend_request('GET', "/api/preview/{$session_id}/{$index}"); + + if (!$result['success']) { + return new WP_Error('backend_error', $result['error'], ['status' => $result['status']]); + } + + // Bei SVG-Daten: Content-Type setzen + if (is_string($result['data']) && strpos($result['data'], 'header('Content-Type', 'image/svg+xml'); + return $response; + } + + return $result['data']; + } + + /** + * Order generieren Proxy + */ + public function proxy_order_generate($request) { + $body = $request->get_json_params(); + + if (empty($body)) { + return new WP_Error('invalid_request', 'Keine Daten empfangen', ['status' => 400]); + } + + $result = $this->make_backend_request('POST', '/api/order/generate', $body); + + if (!$result['success']) { + return new WP_Error('backend_error', $result['error'], ['status' => $result['status']]); + } + + return $result['data']; + } + + /** + * Order finalisieren Proxy + */ + public function proxy_order_finalize($request) { + $body = $request->get_json_params(); + + if (empty($body)) { + return new WP_Error('invalid_request', 'Keine Daten empfangen', ['status' => 400]); + } + + $result = $this->make_backend_request('POST', '/api/order/finalize', $body); + + if (!$result['success']) { + return new WP_Error('backend_error', $result['error'], ['status' => $result['status']]); + } + + return $result['data']; + } + + public function proxy_order_register($request) { + $body = $request->get_json_params(); + + if (empty($body)) { + return new WP_Error('invalid_request', 'Keine Daten empfangen', ['status' => 400]); + } + + $result = $this->make_backend_request('POST', '/api/order/register', $body); + + if (!$result['success']) { + return new WP_Error('backend_error', $result['error'], ['status' => $result['status']]); + } + + return $result['data']; + } + + /** + * Motiv hochladen Proxy + * Leitet Datei ans Docker-Backend weiter, speichert dort im Auftragsordner + */ + public function proxy_motif_upload($request) { + // Datei aus Request holen + $files = $request->get_file_params(); + + if (empty($files['motif'])) { + return new WP_Error('no_file', 'Keine Datei empfangen', ['status' => 400]); + } + + $file = $files['motif']; + $order_number = $request->get_param('orderNumber'); + + if (empty($order_number)) { + return new WP_Error('no_order_number', 'Bestellnummer fehlt', ['status' => 400]); + } + + // Überprüfe auf Upload-Fehler + if ($file['error'] !== UPLOAD_ERR_OK) { + return new WP_Error('upload_error', 'Upload-Fehler: ' . $file['error'], ['status' => 400]); + } + + // Erlaubte Dateitypen prüfen + $allowed_types = ['image/png', 'image/jpeg', 'image/jpg', 'image/webp', 'image/svg+xml', 'application/pdf']; + $file_type = wp_check_filetype($file['name']); + + // MIME-Type Prüfung + $finfo = finfo_open(FILEINFO_MIME_TYPE); + $real_type = finfo_file($finfo, $file['tmp_name']); + finfo_close($finfo); + + // SVG wird oft als text/xml erkannt + if ($real_type === 'text/xml' || $real_type === 'text/plain') { + $content = file_get_contents($file['tmp_name']); + if (strpos($content, ' 400]); + } + + // Datei ans Docker-Backend senden (multipart/form-data) + $config = $this->get_backend_config(); + + if (empty($config['api_url'])) { + return new WP_Error('no_backend', 'Backend API URL nicht konfiguriert', ['status' => 500]); + } + + $url = rtrim($config['api_url'], '/') . '/api/order/motif'; + + // Boundary für multipart + $boundary = wp_generate_password(24, false); + + // Multipart Body bauen + $body = ''; + + // orderNumber Feld + $body .= "--{$boundary}\r\n"; + $body .= "Content-Disposition: form-data; name=\"orderNumber\"\r\n\r\n"; + $body .= $order_number . "\r\n"; + + // Datei Feld + $body .= "--{$boundary}\r\n"; + $body .= "Content-Disposition: form-data; name=\"motif\"; filename=\"" . basename($file['name']) . "\"\r\n"; + $body .= "Content-Type: " . ($real_type ?: 'application/octet-stream') . "\r\n\r\n"; + $body .= file_get_contents($file['tmp_name']) . "\r\n"; + $body .= "--{$boundary}--\r\n"; + + $args = [ + 'method' => 'POST', + 'timeout' => 60, + 'headers' => [ + 'Content-Type' => 'multipart/form-data; boundary=' . $boundary, + ], + 'body' => $body, + ]; + + // API Token hinzufügen wenn vorhanden + if (!empty($config['api_token'])) { + $args['headers']['X-API-Token'] = $config['api_token']; + } + + $response = wp_remote_post($url, $args); + + if (is_wp_error($response)) { + return new WP_Error('backend_error', $response->get_error_message(), ['status' => 500]); + } + + $status_code = wp_remote_retrieve_response_code($response); + $response_body = wp_remote_retrieve_body($response); + $data = json_decode($response_body, true); + + if ($status_code >= 200 && $status_code < 300) { + return $data; + } + + return new WP_Error('backend_error', $data['error'] ?? $data['message'] ?? 'Upload fehlgeschlagen', ['status' => $status_code]); + } +} + +new Skrift_Konfigurator_API_Proxy(); diff --git a/Webseite/skrift-configurator/readme.txt b/Webseite/skrift-configurator/readme.txt new file mode 100644 index 0000000..e69de29 diff --git a/Webseite/skrift-configurator/skrift-konfigurator.php b/Webseite/skrift-configurator/skrift-konfigurator.php new file mode 100644 index 0000000..33a819c --- /dev/null +++ b/Webseite/skrift-configurator/skrift-konfigurator.php @@ -0,0 +1,202 @@ +'; + } + + public function render_shortcode($atts = []): string { + wp_enqueue_style(self::SLUG); + wp_enqueue_script(self::SLUG); + + // Einstellungen aus DB holen + $settings = Skrift_Konfigurator_Admin_Settings::get_settings(); + $vouchers = Skrift_Konfigurator_Vouchers::get_vouchers(); + + // WICHTIG: Sicherstellen dass leere Arrays als Objekt {} encodiert werden, nicht als Array [] + if (empty($vouchers)) { + $vouchers = new stdClass(); + } + + // PayPal-Einstellungen für Frontend vorbereiten (ohne sensible Daten) + $paypal_frontend = []; + if (!empty($settings['paypal']['enabled'])) { + $mode = $settings['paypal']['mode'] ?? 'sandbox'; + $client_id = ($mode === 'live') + ? ($settings['paypal']['client_id_live'] ?? '') + : ($settings['paypal']['client_id_sandbox'] ?? ''); + + $paypal_frontend = [ + 'enabled' => true, + 'mode' => $mode, + 'client_id' => $client_id, + ]; + } + + ob_start(); + ?> + + +
    +
    + + +
    + + +
    + + +
    + + +
    + + +
    + + +
    + + +
    + + +
    + +
    + + + + +
    +
    + + +
    + +
    + – also außerhalb von .sk-configurator, wo die + --sk-*-Variablen definiert sind. Ohne diese Re-Definition blieben + var(--sk-accent) & Co. leer und die .sk-btn-Buttons erschienen unsichtbar + (weißer Primärbutton, kein Rahmen). Werte identisch zu configurator.css. */ + --sk-accent: #1e3a6a; + --sk-text: #0f172a; + --sk-muted: rgba(15, 23, 42, 0.68); + --sk-card: #ffffff; + --sk-border: rgba(15, 23, 42, 0.1); + --sk-border-strong: rgba(15, 23, 42, 0.16); + --sk-radius: 8px; + --sk-font-text: system-ui, -apple-system, "Segoe UI", Roboto, Arial, sans-serif; + color: var(--sk-text); +} + +@keyframes sk-modal-fade-in { + from { opacity: 0; } + to { opacity: 1; } +} + +.sk-job-modal { + background: #fff; + border-radius: var(--sk-radius); + padding: 32px; + max-width: 480px; + width: 100%; + box-shadow: 0 20px 60px rgba(0, 0, 0, .2); + animation: sk-modal-slide-in .15s ease; +} + +@keyframes sk-modal-slide-in { + from { transform: translateY(-12px); opacity: 0; } + to { transform: translateY(0); opacity: 1; } +} + +.sk-job-modal-title { + margin: 0 0 16px; + font-size: 18px; + font-weight: 700; + color: var(--sk-text); +} + +.sk-job-modal-text { + color: var(--sk-muted); + font-size: 14px; + margin: 0 0 10px; + line-height: 1.6; +} + +.sk-job-modal-text strong { + color: var(--sk-text); +} + +.sk-job-modal-actions { + display: flex; + gap: 10px; + justify-content: flex-end; + margin-top: 24px; + flex-wrap: wrap; +} + +/* ── Vollbreite Checkbox-Option ──────────────────── */ +.sk-option-full { + display: flex; + align-items: flex-start; + gap: 12px; + padding: 14px; + border: 1px solid var(--sk-border); + border-radius: var(--sk-radius); + cursor: pointer; + transition: all 0.2s ease; + background: white; + width: 100%; + box-sizing: border-box; + scroll-margin: 0; +} + +.sk-option-full input[type="checkbox"] { + margin-top: 2px; + flex-shrink: 0; + scroll-margin: 0; +} + +.sk-option-full:hover { + border-color: var(--sk-border-strong); + background: rgba(15, 23, 42, 0.02); +} + +.sk-option-full.is-selected { + border-color: var(--sk-accent); + background: rgba(30, 58, 106, 0.04); +} + +.sk-option-full.is-selected .sk-option-label { + font-weight: 600; +} + +/* Warn-Variante (finale Bestätigung) */ +.sk-option-full--warn.is-selected { + border-color: #d97706; + background: rgba(217, 119, 6, 0.06); +} + +.sk-option-full--warn.is-selected .sk-option-label { + color: #92400e; +} + +/* ── Tabellen-Nummerierungs-Spalte (#) ───────────── */ +.sk-table .sk-col-nr { + width: 36px; + min-width: 36px; + max-width: 36px; + padding: 8px 6px !important; + text-align: center; + color: var(--sk-muted); + font-variant-numeric: tabular-nums; + font-size: 12px; + white-space: nowrap; +} + +.sk-job-app .sk-table { + min-width: 0; /* Konfigurator-Default ist 600px → bei wenig Spalten ungenutzte Breite */ +} + +/* Vertikale Scroll-Höhe für lange Tabellen begrenzen */ +.sk-job-app .sk-table-wrapper { + max-height: 420px; + overflow: auto; +} + +/* ── Editierbare Tabelle (Direkteingabe Adresse/Platzhalter) ─────── */ +.sk-table-input { + width: 100%; + min-width: 90px; + padding: 6px 8px; + border: 1px solid transparent; + border-radius: 4px; + font-size: 13px; + font-family: inherit; + background: transparent; + box-sizing: border-box; +} + +.sk-table-input:hover { + border-color: var(--sk-border); +} + +.sk-table-input:focus { + border-color: var(--sk-accent); + background: white; + outline: none; +} + +.sk-table-readonly { + color: var(--sk-muted); + font-style: italic; +} + +/* ── Preview-Sektionen (Brief + Umschlag gestapelt) ─ */ +.sk-job-preview-section { + margin-top: 20px; +} + +.sk-job-preview-section-title { + font-size: 13px; + font-weight: 600; + color: var(--sk-muted); + text-transform: uppercase; + letter-spacing: .05em; + margin-bottom: 8px; +} + +.sk-job-preview-box--envelope { + min-height: 160px; + background: rgba(15, 23, 42, 0.04); +} + +.sk-job-preview-box--envelope img { + max-height: 280px; +} + +/* ── Overflow-Warnung (Text zu lang) ─────────────── */ +.sk-overflow-warning .sk-overflow-title { + font-weight: 700; + font-size: 15px; + margin-bottom: 10px; +} + +.sk-overflow-list { + list-style: none; + margin: 0 0 10px; + padding: 0; + max-height: 220px; + overflow-y: auto; +} + +.sk-overflow-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 6px 10px; + border-radius: 4px; + font-size: 13px; + gap: 12px; +} + +.sk-overflow-item + .sk-overflow-item { + border-top: 1px solid rgba(220, 38, 38, 0.15); +} + +.sk-overflow-lines { + font-variant-numeric: tabular-nums; + font-weight: 600; + white-space: nowrap; +} + +.sk-overflow-more { + font-style: italic; + opacity: .8; + justify-content: flex-start; +} + +.sk-overflow-hint { + font-size: 13px; + margin: 8px 0 0; + line-height: 1.5; + opacity: .9; +} + +/* ── Lightbox / Bild-Vergrößerung ────────────────── */ +.sk-job-lightbox { + position: fixed; + inset: 0; + background: rgba(15, 23, 42, 0.92); + z-index: 100000; + display: flex; + align-items: center; + justify-content: center; + padding: 40px; + cursor: zoom-out; + animation: sk-modal-fade-in .15s ease; +} + +.sk-job-lightbox img { + max-width: 95vw; + max-height: 90vh; + object-fit: contain; + background: white; + border-radius: 6px; + box-shadow: 0 12px 60px rgba(0, 0, 0, 0.5); + cursor: default; +} + +.sk-job-lightbox-close { + position: absolute; + top: 16px; + right: 16px; + width: 44px; + height: 44px; + border-radius: 50%; + border: none; + background: rgba(255, 255, 255, 0.9); + color: var(--sk-text); + font-size: 28px; + line-height: 1; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: background .15s, transform .15s; +} + +.sk-job-lightbox-close:hover { + background: white; + transform: scale(1.05); +} + +/* ── Platzhalter-Import Tabelle ──────────────────── */ +.sk-job-ph-table-wrap { + overflow: auto; + margin-top: 12px; + border: 1px solid var(--sk-border); + border-radius: var(--sk-radius); +} + +.sk-job-ph-table { + width: 100%; + border-collapse: collapse; + font-size: 13px; +} + +.sk-job-ph-table th, +.sk-job-ph-table td { + padding: 8px 12px; + text-align: left; + border-bottom: 1px solid var(--sk-border); + white-space: nowrap; +} + +.sk-job-ph-table th { + background: rgba(15, 23, 42, 0.03); + font-weight: 600; + color: var(--sk-text); + position: sticky; + top: 0; +} + +.sk-job-ph-table td { + color: var(--sk-muted); +} + +.sk-job-ph-table tr:last-child td { + border-bottom: none; +} + +/* ── Mobile Optimierungen ────────────────────────── */ +@media (max-width: 600px) { + .sk-job-app .sk-card-head { + padding: 16px; + } + + .sk-job-app .sk-card-body { + padding: 16px; + } + + .sk-job-overview-grid { + grid-template-columns: 1fr; + } + + .sk-job-dl-row { + flex-direction: column; + } + + .sk-job-dl-btn { + width: 100%; + justify-content: center; + } + + .sk-job-verify-row { + flex-direction: column; + } + + .sk-job-verify-row .sk-input { + min-width: 0; + width: 100%; + } + + .sk-job-modal { + padding: 24px 16px; + } + + .sk-job-modal-actions { + flex-direction: column-reverse; + } + + .sk-job-modal-actions .sk-btn { + width: 100%; + justify-content: center; + } +} diff --git a/Webseite/skrift-job-manager/assets/js/job-app.js b/Webseite/skrift-job-manager/assets/js/job-app.js new file mode 100644 index 0000000..5081e6e --- /dev/null +++ b/Webseite/skrift-job-manager/assets/js/job-app.js @@ -0,0 +1,2630 @@ +/** + * Skrift Kundenauftrag App + * ES6-Modul – wird via [skrift_kundenauftrag] eingebunden. + * Nutzt window.XLSX (SheetJS, als reguläres Script geladen). + */ + +// ─── Konfiguration ──────────────────────────────────────────────────────────── +const CFG = window.SkriftJobApp || {}; +const REST_URL = CFG.restUrl || '/wp-json/'; +const NONCE = CFG.nonce || ''; +const API_KEY = CFG.apiKey || ''; +const SETTINGS = CFG.settings || {}; +const BACKEND_CFG = SETTINGS.backend_connection || {}; +const BACKEND_URL = (BACKEND_CFG.api_url || '').replace(/\/$/, ''); +const REDIRECT_URL = CFG.redirectUrl || ''; + +// ─── Schriften ──────────────────────────────────────────────────────────────── +const FONTS = [ + { key: 'tilda', label: 'Tilda', desc: 'Druckschrift – leicht lesbar und steril' }, + { key: 'alva', label: 'Alva', desc: 'Druckschrift – organisch und informell' }, + { key: 'ellie', label: 'Ellie', desc: 'Schreibschrift – elegant und geschwungen' }, +]; + +const STEP = { + VERIFY: 'verify', + OVERVIEW: 'overview', + ADDRESS: 'address', + LETTERTEXT: 'lettertext', + PRINTED_UPLOAD: 'printed_upload', + FONT: 'font', + SIGNATURE: 'signature', + CONFIRM: 'confirm', + STATUS: 'status', +}; + +const STEP_LABELS = { + verify: 'Auftrag', + overview: 'Übersicht', + address: 'Adressdaten', + lettertext: 'Brieftext', + printed_upload: 'Brief-Upload', + font: 'Schrift & Vorschau', + signature: 'Signatur', + confirm: 'Abschluss', + status: 'Status', +}; + +const STATUS_LABELS = { + waiting: 'Warten auf Daten', + waiting_routine: 'Warten auf Daten Routine', + text_proposal: 'Textvorschlag hinterlegt', + submitted: 'Daten übermittelt', + error: 'Daten fehlerhaft', + printing: 'Druck abgeschlossen', + shipped: 'Versendet', +}; + +// ─── State ──────────────────────────────────────────────────────────────────── +let S = { + step: STEP.VERIFY, + jobIdInput: '', + job: null, + verifyError: null, + verifying: false, + + quantity: 0, + + addressMode: 'classic', + addressInputMode: 'file', // 'file' | 'table' – Datei-Upload oder Direkteingabe + rows: [], + uploadDone: false, + uploadError: null, + uploadCharsetWarning: false, + uploadStrippedChars: '', + customUploadCharsetWarning: false, + customUploadStrippedChars: '', + + briefText: '', + customRows: [], // [{phName: value, …}] – eine Zeile pro Empfänger + customInputMode: 'file', // 'file' | 'table' – Datei-Upload oder Direkteingabe + customUploadDone: false, + customUploadError: null, + customUploadLoading: false, + + font: 'tilda', + realisticHandwriting: false, + sessionId: null, + previews: [], // Brief-Previews (von Backend, mit url/index) + envelopePreviews: [], // Umschlag-Previews + previewIndex: 0, // gemeinsam für Brief & Umschlag + previewLoading: false, + previewError: null, + hasOverflow: false, + overflowFiles: [], // [{index, lineCount, lineLimit}] + previewBatchSize: 15, // Anzahl Vorschauen pro Ladeschritt + previewTotalLoaded: 0, // Wie viele Briefe bisher ans Backend gesendet wurden + + shippingApproved: false, + + // Gedruckte Briefe + printedLetterFile: null, + printedLetterDone: false, + printedLetterError: null, + printedExcelFile: null, + printedExcelDone: false, + + // Signatur + signatures: [], // SVG-String je Signaturfeld + signatureStrokes: [], // [[{x,y}[]]] – aktuelle Zeichnungs-Pfade je Canvas + + // Unteraufträge / Routine-Modus + isSubOrder: false, + subOrderNumber: null, // Bestellnummer des aktuell laufenden Unterauftrags – dient als eindeutiger Dateispeicher-Schlüssel + subOrders: [], // aus Server geladen + + acceptedAgb: false, + acceptedPrivacy: false, + acceptedFinal: false, + submitting: false, + submitError: null, +}; + +// ─── Step-Liste ─────────────────────────────────────────────────────────────── +function stepList() { + if (!S.job) return [STEP.VERIFY]; + const isPrinted = S.job.produkt === 'Gedruckte Briefe'; + const l = [STEP.VERIFY, STEP.OVERVIEW]; + if (S.job.kuvert !== 'keine') l.push(STEP.ADDRESS); + if ((S.job.signatur || 0) > 0) l.push(STEP.SIGNATURE); + if (isPrinted) { + l.push(STEP.PRINTED_UPLOAD); + if (S.job.kuvert !== 'keine') l.push(STEP.FONT); // Umschlag-Vorschau & Schriftwahl + } else { + if (!S.job.textservice || S.job.status === 'text_proposal') l.push(STEP.LETTERTEXT); + l.push(STEP.FONT); + } + l.push(STEP.CONFIRM); + return l; +} + +function go(dir) { + const l = stepList(); + const idx = l.indexOf(S.step); + const nxt = l[idx + dir]; + if (nxt) { S.step = nxt; render(); window.scrollTo({ top: 0, behavior: 'smooth' }); } +} + +// ─── API ────────────────────────────────────────────────────────────────────── +function hdrs() { + const h = { 'Content-Type': 'application/json', 'X-WP-Nonce': NONCE }; + if (API_KEY) h['X-Skrift-API-Key'] = API_KEY; + return h; +} + +async function post(ep, body) { + const r = await fetch(REST_URL + ep, { method: 'POST', headers: hdrs(), body: JSON.stringify(body) }); + return r.json(); +} + +async function verifyJob(id) { + const r = await fetch(`${REST_URL}skrift/v1/job/verify?id=${encodeURIComponent(id)}`, { headers: hdrs() }); + return r.json(); +} + +async function genOrderNumber() { + try { + const d = await post('skrift/v1/order/generate-number', {}); + return d.orderNumber || fallbackNum(); + } catch { return fallbackNum(); } +} + +function fallbackNum() { + const d = new Date(); + return `S-${d.getFullYear()}-${p2(d.getMonth()+1)}-${p2(d.getDate())}-${rnd3()}`; +} +const p2 = n => String(n).padStart(2, '0'); +const rnd3 = () => String(Math.floor(Math.random() * 1000)).padStart(3, '0'); + +async function genPreviewBatch(letters) { + if (!S.sessionId) S.sessionId = `job-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`; + const r = await fetch(`${REST_URL}skrift/v1/proxy/preview/batch`, { + method: 'POST', headers: hdrs(), + body: JSON.stringify({ sessionId: S.sessionId, letters }), + }); + // JSON immer lesen – Backend liefert auch bei Fehler-Status auswertbare Overflow-Daten + const data = await r.json().catch(() => null); + if (!data) throw new Error(`Server antwortete mit Status ${r.status}`); + return data; +} + +async function genOrder(orderNumber, letters, envelopes, metadata) { + const docs = [ + ...letters.map((l, i) => ({ ...l, index: l.index ?? i, type: 'letter' })), + ...envelopes.map((e, i) => ({ ...e, index: e.index ?? i, type: 'envelope' })), + ]; + const r = await fetch(`${REST_URL}skrift/v1/proxy/order/generate`, { + method: 'POST', headers: hdrs(), + body: JSON.stringify({ orderNumber, letters: docs, metadata }), + }); + return r.json(); +} + +// Order aus Preview-Cache finalisieren: liefert genau die SVGs, die der Nutzer gesehen hat (1:1-Garantie) +async function finalizeOrder(orderNumber, metadata) { + const r = await fetch(`${REST_URL}skrift/v1/proxy/order/finalize`, { + method: 'POST', headers: hdrs(), + body: JSON.stringify({ sessionId: S.sessionId, orderNumber, metadata }), + }); + return r.json(); +} + +async function updateCustomerEmail(jobId, email) { + return post('skrift/v1/job/customer-email', { jobId, email }); +} + +async function approveShipping(jobId, token) { + return post('skrift/v1/job/approve-shipping', { jobId, token }); +} + +async function uploadSignatureSvg(jobId, index, svg) { + return post('skrift/v1/job/upload-signature', { jobId, index, svg }); +} + +async function notifySubOrder(jobId, orderNumber, quantity, briefText, countDE, countAbroad) { + return post('skrift/v1/job/sub-order/notify', { jobId, orderNumber, quantity, briefText, countDE, countAbroad }); +} + +// Datei serverseitig speichern (best-effort, blockiert den Ablauf nicht) +// subDir: bei Unteraufträgen die Unterauftrags-Bestellnummer, damit jeder Unterauftrag +// seine eigenen Dateien bekommt statt die des vorherigen zu überschreiben. +async function uploadFileToServer(file, jobId, fileType, subDir) { + if (!file || !jobId) return; + const formData = new FormData(); + formData.append('file', file); + formData.append('jobId', jobId); + formData.append('fileType', fileType); + if (subDir) formData.append('subDir', subDir); + const headers = { 'X-WP-Nonce': NONCE }; + if (API_KEY) headers['X-Skrift-API-Key'] = API_KEY; + // Kein Content-Type setzen – Browser setzt multipart/form-data + Boundary automatisch + const resp = await fetch(`${REST_URL}skrift/v1/job/upload-file`, { method: 'POST', headers, body: formData }); + // Fehler nicht verschlucken: Ein abgelehnter Upload (z.B. MIME-Prüfung) führte bisher dazu, + // dass die Datei nie beim Weiterleiten ans Docker-Backend ankam – ohne jede Rückmeldung. + if (!resp.ok) { + const detail = await resp.text().catch(() => ''); + console.warn(`[Skrift] Datei-Upload fehlgeschlagen (${fileType}): HTTP ${resp.status} ${detail}`); + } +} + +// Preview-Cache invalidieren wenn sich relevante Daten ändern +function invalidatePreviews() { + S.previews = []; + S.envelopePreviews = []; + S.previewIndex = 0; + S.hasOverflow = false; + S.overflowFiles = []; + S.sessionId = null; + S.previewError = null; + S.previewTotalLoaded = 0; +} + +// ─── Format-Mapping ─────────────────────────────────────────────────────────── +function fmtKey(txt) { + const t = (txt || '').toLowerCase(); + if (t.includes('a4')) return 'a4'; + if (t.includes('a6') && /hoch|portrait/.test(t)) return 'a6p'; + if (t.includes('a6') && /quer|landscape/.test(t)) return 'a6l'; + if (t.includes('a6')) return 'a6p'; + return 'a4'; +} +// Preview-API erwartet Kleinbuchstaben-Keys ('a4', 'a6p', 'a6l') – identisch mit dem Konfigurator +const fmtBackend = txt => fmtKey(txt); +// Order-Finalisierung nutzt Session-Cache, daher kein gesondertes Uppercase-Mapping nötig +// Envelope-Format bleibt wie vom Backend erwartet: 'DIN_LANG' (A4) oder 'C6' (A6) +const fmtEnvelope = txt => fmtKey(txt) === 'a4' ? 'DIN_LANG' : 'C6'; + +// Lesbare Format-/Umschlag-Labels für order-metadata.json – identisch zum Hauptkonfigurator, +// damit das Backend nicht nur 'null' schreibt (siehe order-controller: meta.letterFormat …). +function orderMetaFormats() { + const k = fmtKey(S.job?.format); + const hasEnvelope = S.job?.kuvert && S.job.kuvert !== 'keine'; + const beschriftung = { adresse: 'Empfängeradresse', individueller_text: 'Individueller Text', keine: 'Keine Beschriftung' }; + return { + letterFormat: { a4: 'A4', a6p: 'A6 Hochformat', a6l: 'A6 Querformat' }[k] || S.job?.format || null, + envelopeFormat: hasEnvelope ? (k === 'a4' ? 'DIN Lang' : 'C6') : null, + envelopeBeschriftung: beschriftung[S.job?.kuvert] || null, + }; +} +const fontBack = f => ({ tilda: 'tilda', alva: 'alva', ellie: 'ellie' }[f] || 'tilda'); + +// ─── Daten für Backend ──────────────────────────────────────────────────────── +function rowPlaceholders(row) { + const k = S.job.kuvert, m = S.addressMode; + if (k === 'adresse' && m === 'classic') { + return { + vorname: row.firstName || '', + nachname: row.lastName || '', + strasse: [row.street, row.houseNumber].filter(Boolean).join(' '), + plz: row.zip || '', + ort: row.city || '', + land: row.country || '', + }; + } + if (k === 'adresse' && m === 'free') { + return { zeile1: row.line1||'', zeile2: row.line2||'', zeile3: row.line3||'', zeile4: row.line4||'', zeile5: row.line5||'' }; + } + if (k === 'individueller_text') return { kuverttext: row.text || '' }; + return {}; +} + +function phNames() { + const k = S.job?.kuvert, m = S.addressMode; + if (!k || k === 'keine') return []; + if (k === 'adresse' && m === 'classic') return ['vorname', 'nachname', 'strasse', 'plz', 'ort', 'land']; + if (k === 'adresse' && m === 'free') return ['zeile1', 'zeile2', 'zeile3', 'zeile4', 'zeile5']; + if (k === 'individueller_text') return ['kuverttext']; + return []; +} + +// ─── Direkteingabe-Tabelle (Alternative zum Datei-Upload) ───────────────────── +const MAX_TABLE_ROWS = 15; // ab dieser Menge nur noch Datei-Upload anbieten + +function addressTableColumns() { + if (S.job.kuvert === 'individueller_text') return [{ key: 'text', label: 'Kuverttext' }]; + if (S.addressMode === 'free') { + return [1, 2, 3, 4, 5].map(n => ({ key: `line${n}`, label: `Zeile ${n}` })); + } + return [ + { key: 'firstName', label: 'Vorname' }, + { key: 'lastName', label: 'Nachname' }, + { key: 'street', label: 'Straße' }, + { key: 'houseNumber', label: 'Hausnr.' }, + { key: 'zip', label: 'PLZ' }, + { key: 'city', label: 'Ort' }, + { key: 'country', label: 'Land' }, + ]; +} + +// Sorgt dafür, dass ein Zeilen-Array immer genau `quantity` Einträge hat (bestehende Werte bleiben erhalten) +function syncTableRows(arr, quantity) { + if (arr.length === quantity) return arr; + return Array.from({ length: quantity }, (_, i) => arr[i] || {}); +} + +// Gibt alle [[x]] aus dem Brieftext zurück, die NICHT aus Adressdaten kommen +function customPlaceholderNames() { + if (!S.briefText) return []; + const addrSet = new Set(phNames()); + const all = [...S.briefText.matchAll(/\[\[([^\]]+)\]\]/g)].map(m => m[1].trim()); + return [...new Set(all)].filter(p => !addrSet.has(p)); +} + +// Tatsächlich zu erzeugende Dokumentenzahl: Liegen weniger Datensätze vor als die +// Auftragsmenge (z.B. 10 Adressen bei einer Menge von 200), werden nur so viele +// Briefe/Umschläge erzeugt wie Datensätze vorhanden sind – keine leeren Dokumente. +function effectiveQuantity() { + if (S.job?.kuvert !== 'keine' && S.rows.length) { + return Math.min(S.quantity, S.rows.length); + } + return S.quantity; +} + +function buildLetters(qty) { + const q = Math.min(qty ?? S.quantity, effectiveQuantity()); + const font = fontBack(S.font); + const format = fmtBackend(S.job.format); + const text = S.briefText || ''; + + // Signaturen als Platzhalter mitgeben (signatur1, signatur2, signatur3) + const sigPlaceholders = {}; + (S.signatures || []).forEach((svg, j) => { + if (svg) sigPlaceholders[`signatur${j + 1}`] = svg; + }); + + return Array.from({ length: q }, (_, i) => ({ + index: i, text, font, format, + placeholders: { + ...rowPlaceholders(S.rows[i] || {}), + ...(S.customRows[i] || {}), + ...sigPlaceholders, + }, + type: 'letter', realisticHandwriting: S.realisticHandwriting, + })); +} + +function buildEnvelopes() { + if (S.job.kuvert === 'keine') return []; + const font = fontBack(S.font); + const format = fmtEnvelope(S.job.format); + + return Array.from({ length: effectiveQuantity() }, (_, i) => { + const row = S.rows[i] || {}; + let text = '', envType = 'recipient'; + + if (S.job.kuvert === 'adresse' && S.addressMode === 'classic') { + const lines = []; + const name = [row.firstName, row.lastName].filter(Boolean).join(' '); + if (name) lines.push(name); + const st = [row.street, row.houseNumber].filter(Boolean).join(' '); + if (st) lines.push(st); + const loc = [row.zip, row.city].filter(Boolean).join(' '); + if (loc) lines.push(loc); + if (row.country && row.country !== 'Deutschland') lines.push(row.country); + text = lines.join('\n'); + } else if (S.job.kuvert === 'adresse' && S.addressMode === 'free') { + text = [row.line1, row.line2, row.line3, row.line4, row.line5].filter(Boolean).join('\n'); + } else if (S.job.kuvert === 'individueller_text') { + text = row.text || ''; envType = 'custom'; + } + return { index: i, text, font, format, placeholders: {}, type: 'envelope', envelopeType: envType, realisticHandwriting: S.realisticHandwriting }; + }); +} + +// ─── Länder-Erkennung (DE / Ausland) ────────────────────────────────────────── +// Erkennt in der letzten belegten Freitext-Zeile Hinweise auf Deutschland: +// "Deutschland", "Germany", "DE" (als eigenes Wort/Zeile) oder "D-" (z.B. "D-66679"). +function isGermanFreeTextLine(line) { + const s = (line || '').trim(); + if (!s) return true; // keine Angabe → wie bei "classic" als Inland gewertet + const upper = s.toUpperCase(); + if (/\bDEUTSCHLAND\b/.test(upper)) return true; + if (/\bGERMANY\b/.test(upper)) return true; + if (/\bD-\d{4,5}\b/.test(upper)) return true; + if (/(^|\s)DE(\s|$)/.test(upper)) return true; + return false; +} + +// Letzte nicht-leere Zeile einer Freitext-Adresse (dort steht typischerweise das Land/PLZ+Land) +function lastFreeAddressLine(row) { + const lines = [row.line1, row.line2, row.line3, row.line4, row.line5]; + for (let i = lines.length - 1; i >= 0; i--) { + if (lines[i] && lines[i].trim()) return lines[i]; + } + return ''; +} + +// Ermittelt die DE/Ausland-Verteilung der Adressdaten – classic über die Land-Spalte, +// free über die letzte belegte Freitext-Zeile. Bei anderen Kuvert-Arten nicht verfügbar. +function countryStats() { + if (S.job?.kuvert !== 'adresse') return { countDE: null, countAbroad: null }; + if (S.addressMode === 'classic') { + return { + countDE: S.rows.filter(r => !r.country || r.country.trim().toLowerCase() === 'deutschland').length, + countAbroad: S.rows.filter(r => r.country && r.country.trim().toLowerCase() !== 'deutschland').length, + }; + } + if (S.addressMode === 'free') { + const countDE = S.rows.filter(r => isGermanFreeTextLine(lastFreeAddressLine(r))).length; + return { countDE, countAbroad: S.rows.length - countDE }; + } + return { countDE: null, countAbroad: null }; +} + +// ─── Admin-Benachrichtigung ─────────────────────────────────────────────────── +async function notifyAdmin(orderNumber) { + const { countDE, countAbroad } = countryStats(); + + try { + await post('skrift/v1/job/notify', { + jobId: S.job.id, + orderNumber, + quantity: S.quantity, + countDE, + countAbroad, + addressMode: S.addressMode, + briefText: S.briefText || '', + }); + } catch (e) { + console.warn('[JobApp] Admin-Benachrichtigung fehlgeschlagen:', e); + } +} + +// ─── Template herunterladen ─────────────────────────────────────────────────── +function dlTemplate(type) { + const menge = S.quantity || 10; + const a = document.createElement('a'); + a.href = `${REST_URL}skrift/v1/job/template?type=${encodeURIComponent(type)}&menge=${menge}`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); +} + +// Platzhalter-Vorlage als xlsx client-seitig generieren und herunterladen +function dlCustomTemplate() { + const XLSX = window.XLSX; + if (!XLSX) { alert('SheetJS nicht verfügbar – bitte Seite neu laden.'); return; } + + const phs = customPlaceholderNames(); + if (!phs.length) return; + + // Include Vorname/Nachname/Ort from address rows for orientation (read-only on import) + const hasAddr = S.job.kuvert === 'adresse' && S.addressMode === 'classic' && S.rows.length; + const addrCols = hasAddr ? ['Vorname', 'Nachname', 'Ort'] : []; + + const header = [...addrCols, ...phs]; + const data = [header]; + for (let i = 0; i < S.quantity; i++) { + const existing = S.customRows[i] || {}; + const addr = hasAddr ? [ + S.rows[i]?.firstName || '', + S.rows[i]?.lastName || '', + S.rows[i]?.city || '', + ] : []; + data.push([...addr, ...phs.map(p => existing[p] ?? '')]); + } + + const ws = XLSX.utils.aoa_to_sheet(data); + const wb = XLSX.utils.book_new(); + XLSX.utils.book_append_sheet(wb, ws, 'Platzhalter'); + XLSX.writeFile(wb, 'platzhalter-vorlage.xlsx'); +} + +// ─── Direkt eingegebene Tabellendaten serverseitig als Excel-Datei hinterlegen ─ +function rowsToXlsxFile(header, rows, filename) { + const XLSX = window.XLSX; + if (!XLSX) return null; + const ws = XLSX.utils.aoa_to_sheet([header, ...rows]); + const wb = XLSX.utils.book_new(); + XLSX.utils.book_append_sheet(wb, ws, 'Daten'); + const buf = XLSX.write(wb, { type: 'array', bookType: 'xlsx' }); + return new File([buf], filename, { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }); +} + +// Best-effort: dieselbe Ablage wie beim Datei-Upload, damit das Backend auch bei Direkteingabe eine Excel-Datei vorliegen hat +function persistTableAsFile(type) { + if (!S.job?.id) return; + let header, rows, filename; + if (type === 'adresse') { + const cols = addressTableColumns(); + header = cols.map(c => c.label); + rows = S.rows.map(r => cols.map(c => r[c.key] || '')); + filename = 'adresse.xlsx'; + } else { + const phs = customPlaceholderNames(); + header = phs; + rows = S.customRows.map(r => phs.map(p => r[p] || '')); + filename = 'platzhalter.xlsx'; + } + const file = rowsToXlsxFile(header, rows, filename); + if (file) uploadFileToServer(file, S.job.id, type, S.isSubOrder ? S.subOrderNumber : null).catch(() => {}); +} + +// ─── Mengen-Popup ───────────────────────────────────────────────────────────── +function showQuantityDialog(detected, current) { + return new Promise(resolve => { + const overlay = document.createElement('div'); + overlay.className = 'sk-job-modal-overlay'; + overlay.innerHTML = ` + `; + + document.body.appendChild(overlay); + + const close = result => { overlay.remove(); resolve(result); }; + overlay.querySelector('#sk-modal-yes').addEventListener('click', () => close(true)); + overlay.querySelector('#sk-modal-no').addEventListener('click', () => close(false)); + + const onKey = e => { if (e.key === 'Escape') { document.removeEventListener('keydown', onKey); close(false); } }; + document.addEventListener('keydown', onKey); + }); +} + +// Popup bei abweichender Zeilenzahl in der Direkteingabe-Tabelle (z.B. nach Excel-Einfügen): +// Menge auf die Empfängerzahl erhöhen ('add') oder aktuelle Menge beibehalten ('keep'). +function showRowCountDialog(detected, current) { + return new Promise(resolve => { + const overlay = document.createElement('div'); + overlay.className = 'sk-job-modal-overlay'; + overlay.innerHTML = ` + `; + + document.body.appendChild(overlay); + + const close = result => { document.removeEventListener('keydown', onKey); overlay.remove(); resolve(result); }; + overlay.querySelector('#sk-rowcount-add') .addEventListener('click', () => close('add')); + overlay.querySelector('#sk-rowcount-keep').addEventListener('click', () => close('keep')); + + const onKey = e => { if (e.key === 'Escape') close('keep'); }; + document.addEventListener('keydown', onKey); + }); +} + +// ─── Datei parsen (via window.XLSX von SheetJS) ─────────────────────────────── +async function parseFile(file) { + const XLSX = window.XLSX; + if (!XLSX) throw new Error('SheetJS nicht verfügbar. Bitte Seite neu laden.'); + + const buf = await file.arrayBuffer(); + const wb = XLSX.read(buf, { type: 'array' }); + const ws = wb.Sheets[wb.SheetNames[0]]; + const raw = XLSX.utils.sheet_to_json(ws, { header: 1, defval: '' }); + + if (!raw.length) throw new Error('Die Datei ist leer.'); + + const headers = (raw[0] || []).map(h => String(h).trim().toLowerCase()); + const dataRows = raw.slice(1).filter(r => r.some(c => String(c).trim() !== '')); + + if (!dataRows.length) throw new Error('Keine Datenzeilen gefunden.'); + return { headers, dataRows }; +} + +function mapClassic(headers, row) { + const get = (...aliases) => { + for (const a of aliases) { + const i = headers.indexOf(a); + if (i !== -1 && row[i] !== undefined) return sanitizeText(String(row[i]).trim(), false); + } + return ''; + }; + return { + firstName: get('vorname'), + lastName: get('nachname'), + street: get('straße', 'strasse'), + houseNumber: get('hausnummer'), + zip: get('plz'), + city: get('ort'), + country: get('land') || 'Deutschland', + }; +} + +function mapFree(headers, row) { + const g = (alias, fallback) => { + const i = headers.indexOf(alias); + return sanitizeText(String((i !== -1 ? row[i] : row[fallback]) ?? '').trim(), false); + }; + return { line1: g('zeile 1', 0), line2: g('zeile 2', 1), line3: g('zeile 3', 2), line4: g('zeile 4', 3), line5: g('zeile 5', 4) }; +} + +function mapCustomText(headers, row) { + const i = headers.indexOf('kuverttext'); + return { text: sanitizeText(String((i !== -1 ? row[i] : row[0]) ?? '').trim(), false) }; +} + +// ─── Zeichenfilter ──────────────────────────────────────────────────────────── +// Erlaubte Zeichen: A-Z a-z Ä Ö Ü ä ö ü ß + ASCII-Druckzeichen + ¡£§«»¿ + ‐–— + ‘’\”\” + € ₤ +const ALLOWED_CHARS_RE = /[^\x20-\x5F\x61-\x7B\x7D\n\r\tÄÖÜäöüß¡£§«»¿‐–—‘’“”€]/g; +const ALLOWED_CHARS_NO_NL_RE = /[^\x20-\x5F\x61-\x7B\x7DÄÖÜäöüß¡£§«»¿‐–—‘’“”€]/g; + +let _charsetStripped = false; +let _strippedChars = new Set(); + +function sanitizeText(str, allowNewlines = true) { + const s = String(str ?? ''); + const re = allowNewlines ? ALLOWED_CHARS_RE : ALLOWED_CHARS_NO_NL_RE; + const cleaned = s.replace(re, ''); + if (cleaned !== s) { + _charsetStripped = true; + const found = s.match(new RegExp(re.source, 'g')) || []; + found.forEach(ch => _strippedChars.add(ch)); + } + return cleaned; +} + +// ─── CAPS-Warnung ───────────────────────────────────────────────────────────── +function hasCapsWarn(text) { + return text.split(/\s+/).some(w => { + const letters = w.replace(/[^a-zA-ZÄÖÜäöü]/g, ''); + return letters.length >= 7 && letters === letters.toUpperCase() && /[A-ZÄÖÜ]/.test(letters); + }); +} + +// ─── HTML-Helfer ────────────────────────────────────────────────────────────── +const esc = s => String(s ?? '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + +function stepper() { + const list = stepList(); + const curIdx = list.indexOf(S.step); + + return `
    ${list.map((s, i) => { + const done = i < curIdx; + const active = i === curIdx; + const cls = done ? 'sk-chip is-complete' : active ? 'sk-chip is-active' : 'sk-chip'; + const inner = done ? `✓ ${STEP_LABELS[s]}` : STEP_LABELS[s]; + const arrow = i < list.length - 1 ? '›' : ''; + return `${arrow}`; + }).join('')}
    `; +} + +function navBar(showPrev, nextLabel, nextId, nextDisabled) { + return `
    + ${showPrev ? `` : ''} + +
    `; +} + +// ─── Steps ──────────────────────────────────────────────────────────────────── + +function renderVerify() { + return ` + ${stepper()} +
    +
    +

    Auftrag abrufen

    +

    Geben Sie Ihre Auftragsnummer ein, die Sie von uns erhalten haben.

    +
    +
    +
    + +
    + + +
    + ${S.verifyError ? `
    ${esc(S.verifyError)}
    ` : ''} +
    +
    +
    `; +} + +function renderSubOrdersPanel() { + if (S.job?.status !== 'waiting_routine' || S.isSubOrder) return ''; + const subs = S.subOrders.length ? S.subOrders : (S.job?.subOrders || []); + + const badgeCls = st => ({ + waiting: 'sk-badge-waiting', submitted: 'sk-badge-submitted', + error: 'sk-badge-error', printing: 'sk-badge-printing', shipped: 'sk-badge-shipped', + }[st] || 'sk-badge-waiting'); + + const rows = subs.length + ? subs.map(sub => { + const ts = sub.createdAt + ? new Date(sub.createdAt.replace(' ', 'T') + 'Z').toLocaleString('de-DE', + { day:'2-digit', month:'2-digit', year:'numeric', hour:'2-digit', minute:'2-digit' }) + : '–'; + return ` + ${esc(sub.id)}${sub.orderNumber ? `
    ${esc(sub.orderNumber)}` : ''} + ${esc(STATUS_LABELS[sub.status] || sub.status)} + ${esc(String(sub.quantity || '–'))} + ${esc(ts)} + `; + }).join('') + : `Noch keine Unteraufträge vorhanden.`; + + return ` +
    +
    +

    Bisherige Unteraufträge

    +
    +
    +
    + + + ${rows} +
    UnterauftragStatusMengeDatum
    +
    +
    + +
    +
    +
    `; +} + +function renderOverview() { + const j = S.job; + const kuvertLabel = { keine: 'Keine', adresse: 'Adresse', individueller_text: 'Individueller Text' }; + const isError = j.status === 'error'; + + return ` + ${stepper()} + ${isError ? ` +
    + Ihre Daten konnten nicht verarbeitet werden.
    + ${esc(j.statusReason || 'Bitte laden Sie Ihre Daten korrigiert erneut hoch.')} +
    ` : ''} + ${j.status === 'text_proposal' ? ` +
    + Textvorschlag verfügbar
    + Skrift hat Ihnen einen Brieftext-Vorschlag hinterlegt. Sie finden ihn im Schritt Brieftext – dort können Sie ihn lesen und anpassen. +
    ` : ''} +
    +
    +

    ${S.isSubOrder ? 'Unterauftrag – Übersicht' : 'Ihr Auftrag'}

    +

    Auftragsnummer: ${esc(j.id)}

    +
    +
    +
    +
    + +
    ${esc(j.produkt)}
    +
    +
    + +
    ${esc(j.format || '–')}
    +
    +
    + +
    ${kuvertLabel[j.kuvert] || esc(j.kuvert)}
    +
    +
    + + +
    +
    + ${j.textservice ? `
    + Textservice aktiv – Skrift verfasst den Brieftext für Sie. +
    ` : ''} + +
    +
    + +
    + + +
    +

    + Wir informieren Sie per E-Mail über den Status Ihres Auftrags. + ${!j.customerEmail ? 'Bitte hinterlegen Sie Ihre E-Mail-Adresse.' : ''} +

    +
    +
    +
    + ${renderSubOrdersPanel()} + ${(S.job?.status === 'waiting_routine' && !S.isSubOrder) ? '' : navBar(false, 'Weiter →', 'sk-job-next', false)}`; +} + +function renderAddress() { + return S.job.kuvert === 'individueller_text' ? renderCustomTextUpload() : renderAddressUpload(); +} + +function renderAddressUpload() { + const showTableToggle = S.quantity > 0 && S.quantity <= MAX_TABLE_ROWS; + if (!showTableToggle) S.addressInputMode = 'file'; + const tableMode = showTableToggle && S.addressInputMode === 'table'; + + return ` + ${stepper()} +
    +
    +

    Adressdaten

    +

    Laden Sie eine Vorlage herunter, füllen Sie sie aus und laden Sie die Datei zurück hoch.

    +
    +
    +
    + + +
    +
    + ${showTableToggle ? renderInputModeToggle(S.addressInputMode, 'sk-addr-input-mode') : ''} + ${tableMode ? renderAddressTable() : ` +
    + ${S.addressMode === 'classic' + ? `` + : `` + } +
    +
    + +
    + ${S.uploadDone ? `${S.rows.length} ${S.rows.length === 1 ? 'Datensatz' : 'Datensätze'} geladen – erneut klicken zum Ersetzen` : 'Ausgefüllte Datei hier ablegen oder klicken'} +
    +
    .xlsx oder .csv
    +
    + ${S.uploadError ? `
    ${esc(S.uploadError)}
    ` : ''} + ${S.uploadCharsetWarning ? `
    Hinweis: Folgende Zeichen werden nicht unterstützt und wurden entfernt: ${esc(S.uploadStrippedChars)}
    ` : ''} + ${S.uploadDone ? mismatchWarning() + uploadTable() : ''} + `} +
    +
    + ${navBar(true, 'Weiter →', 'sk-job-next', !S.uploadDone)}`; +} + +function renderCustomTextUpload() { + const showTableToggle = S.quantity > 0 && S.quantity <= MAX_TABLE_ROWS; + if (!showTableToggle) S.addressInputMode = 'file'; + const tableMode = showTableToggle && S.addressInputMode === 'table'; + + return ` + ${stepper()} +
    +
    +

    Individueller Kuverttext

    +

    Laden Sie die Vorlage herunter, tragen Sie den gewünschten Text pro Umschlag ein und laden Sie die Datei hoch.

    +
    +
    + ${showTableToggle ? renderInputModeToggle(S.addressInputMode, 'sk-addr-input-mode') : ''} + ${tableMode ? renderAddressTable() : ` +
    + +
    +
    + +
    + ${S.uploadDone ? `${S.rows.length} ${S.rows.length === 1 ? 'Zeile' : 'Zeilen'} geladen – erneut klicken zum Ersetzen` : 'Ausgefüllte Datei hier ablegen oder klicken'} +
    +
    .xlsx oder .csv
    +
    + ${S.uploadError ? `
    ${esc(S.uploadError)}
    ` : ''} + ${S.uploadCharsetWarning ? `
    Hinweis: Folgende Zeichen werden nicht unterstützt und wurden entfernt: ${esc(S.uploadStrippedChars)}
    ` : ''} + ${S.uploadDone ? mismatchWarning() + uploadTable() : ''} + `} +
    +
    + ${navBar(true, 'Weiter →', 'sk-job-next', !S.uploadDone)}`; +} + +function renderPrintedUpload() { + return ` + ${stepper()} +
    +
    +

    Brief-Upload

    +

    Laden Sie das Briefdokument hoch (Word oder PDF). Bei Briefen mit Platzhaltern können Sie zusätzlich eine Excel-Datei hochladen.

    +
    +
    +
    + +
    + +
    + ${S.printedLetterDone + ? `✓ ${esc(S.printedLetterFile?.name || 'Datei geladen')} – erneut klicken zum Ersetzen` + : 'Word- oder PDF-Datei hier ablegen oder klicken'} +
    +
    .pdf, .doc oder .docx
    +
    + ${S.printedLetterError ? `
    ${esc(S.printedLetterError)}
    ` : ''} +
    + +
    + +
    + +

    Enthält Ihr Brief Word-Platzhalter (z. B. «Vorname»), laden Sie hier die Excel-Datei mit den Werten hoch.

    +
    + +
    + ${S.printedExcelDone + ? `✓ ${esc(S.printedExcelFile?.name || 'Datei geladen')} – erneut klicken zum Ersetzen` + : 'Excel-Datei hier ablegen oder klicken (optional)'} +
    +
    .xlsx oder .csv
    +
    +
    +
    +
    + ${navBar(true, 'Weiter →', 'sk-job-next', !S.printedLetterDone)}`; +} + +function mismatchWarning() { + if (!S.rows.length || S.rows.length === S.quantity) return ''; + const diff = S.rows.length - S.quantity; + const more = diff > 0; + const action = more + ? `Es werden nur die ersten ${S.quantity} Datensätze verwendet, oder erhöhen Sie die Auftragsmenge in Schritt „Übersicht".` + : `Es werden nur ${S.rows.length} Briefe/Umschläge erzeugt (keine Leerdokumente), oder ergänzen Sie die fehlenden ${Math.abs(diff)} Datensätze bzw. reduzieren Sie die Auftragsmenge in Schritt „Übersicht".`; + return `
    + Anzahl weicht ab: Die Datei enthält ${S.rows.length} Datensätze, die Auftragsmenge beträgt ${S.quantity}. ${action} +
    `; +} + +function uploadTable() { + if (!S.rows.length) return ''; + const keys = Object.keys(S.rows[0]); + const label = { firstName:'Vorname', lastName:'Nachname', street:'Straße', houseNumber:'Nr.', zip:'PLZ', city:'Ort', country:'Land', line1:'Zeile 1', line2:'Zeile 2', line3:'Zeile 3', line4:'Zeile 4', line5:'Zeile 5', text:'Kuverttext' }; + return ` +
    + + ${keys.map(k => ``).join('')} + ${S.rows.map((r, i) => `${keys.map(k => ``).join('')}`).join('')} +
    #${label[k] || k}
    ${i+1}${esc(r[k])}
    +
    +

    + ${S.rows.length} ${S.rows.length === 1 ? 'Datensatz' : 'Datensätze'} geladen. +

    `; +} + +function customTable(phs) { + if (!S.customRows.length) return ''; + const hasAddr = S.job.kuvert === 'adresse' && S.addressMode === 'classic' && S.rows.length; + return ` +
    + + + + ${hasAddr ? '' : ''} + ${phs.map(p => ``).join('')} + + ${S.customRows.map((r, i) => ` + + ${hasAddr ? `` : ''} + ${phs.map(p => ``).join('')} + `).join('')} +
    #VornameNachnameOrt[[${esc(p)}]]
    ${i+1}${esc(S.rows[i]?.firstName||'')}${esc(S.rows[i]?.lastName||'')}${esc(S.rows[i]?.city||'')}${esc(r[p] || '')}
    +
    +

    + ${S.customRows.length} ${S.customRows.length === 1 ? 'Zeile' : 'Zeilen'} importiert. +

    `; +} + +function renderInputModeToggle(mode, name) { + return ` +
    + + +
    `; +} + +function renderAddressTable() { + S.rows = syncTableRows(S.rows, S.quantity); + const cols = addressTableColumns(); + return ` +
    + + ${cols.map(c => ``).join('')} + ${S.rows.map((r, i) => ` + + ${cols.map(c => ``).join('')} + `).join('')} +
    #${esc(c.label)}
    ${i + 1}
    +
    +

    ${S.rows.length} Empfänger – wird automatisch übernommen.

    `; +} + +function renderCustomTableEditable(phs) { + S.customRows = syncTableRows(S.customRows, S.quantity); + const hasAddr = S.job.kuvert === 'adresse' && S.addressMode === 'classic' && S.rows.length === S.quantity; + return ` +
    + + + + ${hasAddr ? '' : ''} + ${phs.map(p => ``).join('')} + + ${S.customRows.map((r, i) => ` + + ${hasAddr ? `` : ''} + ${phs.map(p => ``).join('')} + `).join('')} +
    #VornameNachnameOrt[[${esc(p)}]]
    ${i + 1}${esc(S.rows[i]?.firstName || '')}${esc(S.rows[i]?.lastName || '')}${esc(S.rows[i]?.city || '')}
    +
    +

    ${S.customRows.length} Empfänger – wird automatisch übernommen.

    `; +} + +function renderLetterText() { + const addrNames = phNames(); + const allChips = addrNames.map(n => + `[[${n}]]` + ).join(''); + const customPhs = customPlaceholderNames(); + + const customTableToggle = S.quantity > 0 && S.quantity <= MAX_TABLE_ROWS; + if (!customTableToggle) S.customInputMode = 'file'; + const customTableMode = customTableToggle && S.customInputMode === 'table'; + + return ` + ${stepper()} +
    +
    +

    Brieftext

    +

    Verwenden Sie [[Platzhalter]] für personalisierte Inhalte – sie werden pro Empfänger ersetzt.

    +
    +
    + ${S.job?.status === 'text_proposal' ? ` +
    + Textvorschlag von Skrift
    + Wir haben Ihnen einen Brieftext-Vorschlag hinterlegt. Prüfen Sie den Text und passen Sie ihn bei Bedarf an – er ist vollständig editierbar. +
    ` : ''} + +
    + + +
    + ⚠️ Wörter in GROSSBUCHSTABEN (7+ Zeichen) sehen im Handschriftdruck sehr unschön aus. Bitte prüfen Sie den Text. +
    + +
    + + ${addrNames.length ? ` +
    + Verfügbare Platzhalter aus Adressdaten – klicken zum Einfügen: +
    ${allChips}
    +
    ` : ''} + + ${(S.job?.signatur || 0) > 0 ? ` +
    + Signatur-Platzhalter – klicken zum Einfügen: +
    + ${Array.from({length: S.job.signatur}, (_, i) => + `[[signatur${i+1}]]` + ).join('')} +
    + Die Signatur wird an dieser Stelle des Briefs eingebettet. +
    ` : ''} + +
    + Eigene Platzhalter: Mit [[bezeichnung]] direkt im Text einfügen, z. B. [[spende]] oder [[firmenname]]. Diese werden im nächsten Abschnitt pro Empfänger befüllt. +
    + + ${customPhs.length ? ` +
    +
    + +
    + Die Spaltenstruktur wird durch den Brieftext bestimmt. Spalten wie Vorname, Nachname und Ort werden aus den Adressdaten mitbefüllt, dienen nur zur Orientierung und werden beim Import ignoriert. +
    + ${customTableToggle ? renderInputModeToggle(S.customInputMode, 'sk-custom-input-mode') : ''} + ${customTableMode ? renderCustomTableEditable(customPhs) : ` +
    + +
    +
    + ${S.customUploadLoading ? ` +
    +
    Wird verarbeitet…
    + ` : ` + +
    + ${S.customUploadDone + ? `${S.customRows.length} ${S.customRows.length === 1 ? 'Zeile' : 'Zeilen'} importiert – erneut klicken zum Ersetzen` + : 'Ausgefüllte Vorlage hier ablegen oder klicken'} +
    +
    .xlsx oder .csv
    + `} +
    + ${S.customUploadError ? `
    ${esc(S.customUploadError)}
    ` : ''} + ${S.customUploadCharsetWarning ? `
    Hinweis: Folgende Zeichen werden nicht unterstützt und wurden entfernt: ${esc(S.customUploadStrippedChars)}
    ` : ''} + ${S.customUploadDone ? customTable(customPhs) : ''} + `} +
    ` : ''} +
    +
    + ${S.hasOverflow ? ` +
    ${overflowWarning()}
    +

    + Bitte kürzen Sie den Brieftext oder die Umschlag-Adresse, damit Sie fortfahren können. +

    ` : ''} + ${navBar(true, 'Weiter →', 'sk-job-next', !S.briefText.trim() || S.hasOverflow || S.previewLoading)}`; +} + +function renderFont() { + const isPrinted = S.job.produkt === 'Gedruckte Briefe'; + const fontCards = FONTS.map(f => ` +
    +
    ${f.label}
    +
    ${f.desc}
    +
    `).join(''); + + return ` + ${stepper()} +
    +
    +

    ${isPrinted ? 'Umschlag: Schrift & Vorschau' : 'Schrift & Vorschau'}

    +

    ${isPrinted + ? 'Wählen Sie eine Schriftart für den Umschlag und generieren Sie eine Vorschau.' + : 'Wählen Sie eine Schriftart und generieren Sie eine Vorschau.'}

    +
    +
    +
    + +
    ${fontCards}
    +
    +
    +
    + +
    +
    Realistische Handschrift
    +
    Leichte Unregelmäßigkeiten für ein authentischeres Erscheinungsbild.
    +
    +
    +
    + + ${overflowWarning()} +
    ${renderPreviewContent()}
    + ${!S.previews.length && !S.envelopePreviews.length && !S.previewLoading ? ` +

    + Die Vorschau ist optional – falls Sie sie überspringen, wird beim Abschicken automatisch eine erstellt. +

    ` : ''} +
    +
    + ${navBar(true, 'Weiter →', 'sk-job-next', S.hasOverflow || S.previewLoading)}`; +} + +// ─── Signatur ───────────────────────────────────────────────────────────────── + +function pathsToSvg(paths, w, h) { + const elements = paths.map(pts => { + if (pts.length < 2) return ''; + const d = pts.map((p, i) => `${i === 0 ? 'M' : 'L'}${p[0].toFixed(1)},${p[1].toFixed(1)}`).join(' '); + return ``; + }).join(''); + return `${elements}`; +} + +function renderSignature() { + const count = S.job?.signatur || 0; + const allSigned = count > 0 && Array.from({length: count}, (_, i) => !!S.signatures[i]).every(Boolean); + + const fields = Array.from({length: count}, (_, i) => { + const hasSig = !!S.signatures[i]; + return ` +
    + ${count > 1 ? `` : ''} +
    + +
    +
    +
    + + ${hasSig + ? '✓ Signatur gespeichert' + : 'Bitte hier unterschreiben'} +
    +
    `; + }).join(''); + + return ` + ${stepper()} +
    +
    +

    ${count > 1 ? 'Signaturen' : 'Signatur'}

    +

    Bitte ${count > 1 ? 'alle Felder' : 'das Feld'} mit Ihrer Unterschrift ausfüllen. Maus oder Finger verwenden.

    +
    +
    + ${fields} +
    +
    + ${navBar(true, 'Weiter →', 'sk-job-next', !allSigned)}`; +} + +function overflowWarning() { + if (!S.hasOverflow || !S.overflowFiles.length) return ''; + const max = 10; + const show = S.overflowFiles.slice(0, max); + const rest = S.overflowFiles.length - show.length; + const items = show.map(f => ` +
  • + Empfänger in Tabellenzeile ${f.index + 1} + ${f.lineCount} / ${f.lineLimit} Zeilen +
  • `).join(''); + const restItem = rest > 0 + ? `
  • … und ${rest} weitere Empfänger
  • ` + : ''; + return ` +
    +
    Text zu lang für das gewählte Format
    +
      ${items}${restItem}
    +

    + Bitte den Brieftext kürzen oder lange Platzhalterwerte (z. B. Namen) prüfen, um fortzufahren. + Die Zeilennummern beziehen sich auf die Empfänger-Tabelle aus Schritt „Adressdaten". +

    +
    `; +} + +function renderPreviewContent() { + if (S.previewLoading) return ` +
    + +
    `; + + if (S.previewError) return `
    ${esc(S.previewError)}
    `; + + const isPrinted = S.job.produkt === 'Gedruckte Briefe'; + const list = isPrinted ? S.envelopePreviews : S.previews; + if (!list.length) return ''; + + const cur = S.previews[S.previewIndex]; + const env = S.envelopePreviews[S.previewIndex]; + const loaded = list.length; + const total = effectiveQuantity(); + const hasMore = S.previewTotalLoaded < total && loaded < total; + + const buildUrl = u => !u ? '' : (u.startsWith('http') ? `${u}?t=${S.sessionId}` : `${BACKEND_URL}${u}?t=${S.sessionId}`); + const letterUrl = buildUrl(cur?.url); + const envelopeUrl = buildUrl(env?.url); + + // Nächster Button: navigieren wenn Vorschau vorhanden, Batch laden wenn am Ende + const atEnd = S.previewIndex >= loaded - 1; + const nextLabel = (atEnd && hasMore) ? (S.previewLoading ? '…' : '→ Weitere laden') : '→'; + const nextDisabled = (atEnd && !hasMore) || (atEnd && hasMore && S.previewLoading); + + return ` + ${!isPrinted ? ` +
    +
    Brief ${S.previewIndex + 1}
    +
    + Briefvorschau ${S.previewIndex + 1} +
    +
    ` : ''} + ${envelopeUrl ? ` +
    +
    Umschlag ${S.previewIndex + 1}
    +
    + Umschlagvorschau ${S.previewIndex + 1} +
    +
    ` : ''} +
    + + ${S.previewIndex + 1} / ${total} + +
    + `; +} + +// ─── Lightbox: Vorschau-Bild maximieren ─────────────────────────────────────── +function openLightbox(src) { + const overlay = document.createElement('div'); + overlay.className = 'sk-job-lightbox'; + overlay.innerHTML = ` + + Briefvorschau vergrößert`; + document.body.appendChild(overlay); + document.body.style.overflow = 'hidden'; + + const close = () => { + overlay.remove(); + document.body.style.overflow = ''; + document.removeEventListener('keydown', onKey); + }; + const onKey = e => { if (e.key === 'Escape') close(); }; + overlay.addEventListener('click', e => { + if (e.target === overlay || e.target.classList.contains('sk-job-lightbox-close')) close(); + }); + document.addEventListener('keydown', onKey); +} + +function refreshPreview() { + const area = document.getElementById('sk-preview-area'); + if (area) area.innerHTML = renderPreviewContent(); + const root = document.querySelector('[data-skrift-kundenauftrag="1"]'); + bindPreviewNav(root); +} + +function renderConfirm() { + const canSubmit = S.acceptedAgb && S.acceptedPrivacy && S.acceptedFinal; + return ` + ${stepper()} +
    +
    +

    Abschluss

    +

    Bitte bestätigen Sie die folgenden Punkte, bevor der Auftrag übermittelt wird.

    +
    +
    +
    +
    + +
    +
    + Ich habe die Allgemeinen Geschäftsbedingungen gelesen und akzeptiere deren Geltung. + Ein Widerrufsrecht besteht gemäß § 312g Abs. 2 Nr. 1 BGB nicht, da Skrift ausschließlich Waren anbietet, + die nicht vorgefertigt sind und für deren Herstellung eine individuelle Auswahl oder Bestimmung durch den + Kunden maßgeblich ist oder die eindeutig auf die persönlichen Bedürfnisse des Kunden zugeschnitten sind. +
    +
    +
    +
    + +
    +
    + Ja, ich habe die Datenschutzerklärung zur Kenntnis genommen und bin damit einverstanden, + dass die von mir angegebenen Daten elektronisch erhoben und gespeichert werden. Meine Daten werden dabei nur + streng zweckgebunden zur Bearbeitung und Beantwortung meiner Anfrage benutzt. Mit dem Absenden des Kontaktformulars erkläre ich mich + mit der Verarbeitung einverstanden. Die Daten werden im Falle eines ausbleibenden Auftrags gelöscht. +
    +
    +
    +
    + +
    +
    Druckdaten und Schriftauswahl sind final
    +
    Ich bestätige, dass Schriftart, Brieftext und alle Empfängerdaten endgültig sind. Der Auftrag wird unmittelbar in den Druck gegeben und kann danach nicht mehr geändert werden.
    +
    +
    +
    + ${S.submitError ? `
    ${esc(S.submitError)}
    ` : ''} +
    +
    +
    + + +
    `; +} + +function renderStatus() { + const j = S.job; + + const labels = { + submitted: { text: 'Daten erfolgreich übermittelt', cls: 'sk-alert-success' }, + printing: { text: 'Im Druck', cls: 'sk-alert-success' }, + shipped: { text: 'Versendet', cls: 'sk-alert-success' }, + waiting_routine: { text: 'Routine-Auftrag aktiv', cls: 'sk-alert-info' }, + }; + const info = labels[j.status] || { text: j.status, cls: 'sk-alert-info' }; + + // Timestamp ist UTC (WordPress current_time): 'Z' anhängen damit Browser korrekt in Lokalzeit umrechnet + const updatedAt = j.statusUpdatedAt + ? new Date(j.statusUpdatedAt.replace(' ', 'T') + 'Z').toLocaleString('de-DE', { + day: '2-digit', month: '2-digit', year: 'numeric', + hour: '2-digit', minute: '2-digit', + }) + : ''; + + const statusMessages = { + submitted: 'Ihre Daten wurden erfolgreich übermittelt. Wir machen uns nun an die Arbeit und informieren Sie über den nächsten Schritt.', + printing: 'Ihre Briefe sind fertig gedruckt und bereit zum Versand. Bitte erteilen Sie unten die Versandfreigabe.', + shipped: 'Ihre Briefe wurden versendet. Vielen Dank für Ihren Auftrag!', + waiting_routine: 'Ihr Auftrag ist eingerichtet. Wir melden uns, sobald weitere Schritte erforderlich sind.', + }; + + return ` +
    +
    +

    Auftrag ${esc(j.id)}

    +

    ${esc(j.produkt)}${j.format ? ' · ' + esc(j.format) : ''} · ${S.quantity} Stück

    +
    +
    +
    + ${info.text} + ${updatedAt ? `
    Zuletzt aktualisiert: ${updatedAt}` : ''} +
    +

    + ${esc(statusMessages[j.status] || '')} +

    +
    + Mit Ihrer Auftragsnummer ${esc(j.id)} können Sie jederzeit wieder hier einloggen, um den aktuellen Status zu sehen. +
    + ${j.status === 'printing' ? ` +
    + ${S.shippingApproved ? ` +
    + ✓ Versandfreigabe erteilt. Ihre Briefe werden in Kürze auf den Weg gebracht. Sie erhalten eine Benachrichtigung per E-Mail. +
    + ` : ` +
    + +

    + Mit Ihrer Bestätigung geben Sie den Versand frei. Ihre Briefe werden daraufhin unverzüglich verschickt. +

    +
    + `}` : ''} +
    +
    `; +} + + +function refreshConfirm() { + const inner = document.getElementById('sk-job-inner'); + if (!inner) return; + inner.innerHTML = renderConfirm(); + bindEvents(document.querySelector('[data-skrift-kundenauftrag="1"]')); +} + +// ─── Preview generieren ─────────────────────────────────────────────────────── + +// Ersten Batch starten (15 Briefe) – reset aller Preview-Daten +async function doGenPreview() { + invalidatePreviews(); + await doGenPreviewBatch(0, S.previewBatchSize); +} + +// Einen Batch von `count` Briefen ab Index `startIdx` generieren und anhängen +async function doGenPreviewBatch(startIdx, count) { + if (S.previewLoading) return; + + const actualCount = Math.min(count, effectiveQuantity() - startIdx); + if (actualCount <= 0) return; + + S.previewLoading = true; + S.previewError = null; + refreshFontStep(); + + // Nur die Briefe/Umschläge dieses Batches bauen + // buildLetters/buildEnvelopes setzen index: i bereits korrekt (0..quantity-1), + // daher liefert slice(startIdx, ...) Elemente mit bereits korrekten absoluten Indizes. + // Gedruckte Briefe: kein Brieftext vorhanden (physisches Dokument) → nur Umschlag-Vorschau + const isPrinted = S.job.produkt === 'Gedruckte Briefe'; + const letters = isPrinted ? [] : buildLetters(S.quantity).slice(startIdx, startIdx + actualCount); + const envelopes = buildEnvelopes().slice(startIdx, startIdx + actualCount); + + if (!isPrinted && S.job.textservice && !S.briefText) { + letters.forEach(l => { l.text = 'Sehr geehrte Damen und Herren,\n\nIhr Brief wird vom Skrift-Textservice verfasst.\n\nMit freundlichen Grüßen'; }); + } + + const allDocs = [...letters, ...envelopes]; + + try { + const res = await genPreviewBatch(allDocs); + if (res.sessionId) S.sessionId = res.sessionId; + const files = res.files || res.previews || []; + + if (files.length) { + const isEnvelope = f => (f.filename || '').startsWith('envelope_') || f.type === 'envelope'; + const newLetters = files.filter(f => !isEnvelope(f)).sort((a, b) => a.index - b.index); + const newEnvelopes = files.filter(isEnvelope).sort((a, b) => a.index - b.index); + + // An bestehende Liste anhängen (erster Batch setzt, folgende hängen an) + S.previews = [...S.previews, ...newLetters]; + S.envelopePreviews = [...S.envelopePreviews, ...newEnvelopes]; + S.previewTotalLoaded = startIdx + actualCount; + + // Overflow aus diesem Batch akkumulieren + if (res.hasOverflow) S.hasOverflow = true; + const newOverflow = (res.overflowFiles || newLetters.filter(f => f.overflow)).map(f => ({ + index: f.index, lineCount: f.lineCount, lineLimit: f.lineLimit, + })); + S.overflowFiles = [...S.overflowFiles, ...newOverflow]; + } else if (res.hasOverflow) { + // Backend hat alle Briefe als zu lang erkannt (keine files, aber Overflow-Info vorhanden) + S.hasOverflow = true; + const newOverflow = (res.overflowFiles || []).map(f => ({ + index: f.index, lineCount: f.lineCount, lineLimit: f.lineLimit, + })); + S.overflowFiles = [...S.overflowFiles, ...newOverflow]; + } else { + S.previewError = res.error || res.message || 'Vorschau konnte nicht geladen werden.'; + } + } catch (e) { + S.previewError = `Verbindungsfehler: ${e.message}`; + } + + S.previewLoading = false; + refreshFontStep(); +} + +// Re-render the whole FONT step (needed because the overflow warning is OUTSIDE the preview area) +function refreshFontStep() { + if (S.step !== STEP.FONT) { refreshPreview(); return; } + render(); +} + +// ─── Weiter auf Brieftext-Schritt: Overflow-Prüfung via Preview ─────────────── +async function doLetterTextNext() { + // Aktuellen Textarea-Inhalt sichern + const ta = document.getElementById('sk-brieftext'); + if (ta) S.briefText = ta.value; + if (!S.briefText.trim()) return; // Button ist disabled – sollte nicht passieren + + // Direkt eingegebene Platzhalter-Tabelle zusätzlich als Datei am Server hinterlegen (best-effort) + if (S.customInputMode === 'table') persistTableAsFile('platzhalter'); + + // Vorschau schon gültig vorhanden? → Overflow-Status bekannt, entsprechend reagieren + if (S.sessionId && S.previews.length) { + if (S.hasOverflow) { + render(); // Overflow-Warnung auf Brieftext-Schritt zeigen, Navigation blockieren + } else { + go(1); + } + return; + } + + // Button deaktivieren und Ladeindikator zeigen + const root = document.querySelector('[data-skrift-kundenauftrag="1"]'); + const btn = root?.querySelector('#sk-job-next'); + if (btn) { + btn.disabled = true; + btn.innerHTML = ' Text wird geprüft…'; + } + + // Vorschau generieren – serverseitig wird Overflow erkannt + await doGenPreview(); + + if (S.hasOverflow) { + render(); // Brieftext-Schritt neu rendern mit Overflow-Warnung → Navigation gesperrt + } else { + go(1); + } +} + +// ─── Signaturen hochladen ───────────────────────────────────────────────────── +async function doUploadSignatures() { + const count = S.job?.signatur || 0; + for (let i = 0; i < count; i++) { + if (!S.signatures[i] || !S.job?.id) continue; + try { + await uploadSignatureSvg(S.job.id, i, S.signatures[i]); + } catch (e) { + console.warn('[JobApp] Signatur-Upload fehlgeschlagen:', e); + } + } +} + +// ─── Abschicken (Unterauftrag) ──────────────────────────────────────────────── +async function doSubmitSubOrder() { + S.submitting = true; S.submitError = null; refreshConfirm(); + try { + // Bestellnummer wurde bereits beim Start des Unterauftrags reserviert (siehe "sk-routine-new"), + // damit alle Uploads dieses Durchlaufs (Adresse/Platzhalter/Brief) unter demselben Schlüssel + // landen und nicht den vorherigen Unterauftrag überschreiben. + const orderNumber = S.subOrderNumber || await genOrderNumber(); + const btn = document.getElementById('sk-job-submit'); + if (btn) btn.innerHTML = ' Unterauftrag wird übermittelt…'; + + // Signaturen hochladen (optional – Fehler blockieren nicht die Auftragsübermittlung) + try { await doUploadSignatures(); } catch (e) { console.warn('[Skrift] Signatur-Upload fehlgeschlagen:', e.message); } + + // Dateien hochladen – mit orderNumber als Speicher-Schlüssel für diesen Unterauftrag + if (S.printedLetterFile && S.job?.id) { + await uploadFileToServer(S.printedLetterFile, S.job.id, 'brief', orderNumber); + } + if (S.printedExcelFile && S.job?.id) { + await uploadFileToServer(S.printedExcelFile, S.job.id, 'brief_excel', orderNumber); + } + + const metadata = { + customer: { type: 'job', jobId: S.job.id, subOrder: true }, + orderDate: new Date().toISOString(), + product: S.job.produkt, + quantity: S.quantity, + format: S.job.format, + ...orderMetaFormats(), + }; + + const isPrinted = S.job.produkt === 'Gedruckte Briefe'; + + if (isPrinted && S.job.kuvert === 'keine') { + // Gedruckte Briefe ohne Umschlag: Unterauftrag im Docker-Backend nur mit Metadaten registrieren + try { + const regResp = await fetch(`${REST_URL}skrift/v1/job/register-order`, { + method: 'POST', headers: hdrs(), + body: JSON.stringify({ orderNumber, metadata }), + }); + if (!regResp.ok) console.warn('[Skrift] Backend-Registrierung (Unterauftrag) Status:', regResp.status, await regResp.text()); + } catch (e) { console.warn('[Skrift] Backend-Registrierung (Unterauftrag) fehlgeschlagen:', e.message); } + } else { + // Gedruckte Briefe mit Umschlag: nur Umschlag-SVGs generieren (Brief liegt bereits als Datei vor) + // Reguläre Briefprodukte: Brief- und Umschlag-SVGs wie beim Hauptauftrag generieren + try { + const letters = isPrinted ? [] : buildLetters(S.quantity); + const envelopes = buildEnvelopes(); + if (!isPrinted && S.job.textservice && !S.briefText) { + letters.forEach(l => { + l.text = 'Sehr geehrte Damen und Herren,\n\nIhr Brief wird vom Skrift-Textservice verfasst.\n\nMit freundlichen Grüßen'; + }); + } + const result = await genOrder(orderNumber, letters, envelopes, metadata); + if (result?.error || result?.code) { + console.warn('[Skrift] Backend-Generierung (Unterauftrag) fehlgeschlagen:', result.error || result.message); + } + } catch (e) { console.warn('[Skrift] Backend-Generierung (Unterauftrag) fehlgeschlagen:', e.message); } + } + + // Hochgeladene Dateien dieses Unterauftrags zusätzlich in den Backend-Auftragsordner kopieren + try { + const fwResp = await fetch(`${REST_URL}skrift/v1/job/forward-files`, { + method: 'POST', headers: hdrs(), + body: JSON.stringify({ jobId: S.job.id, orderNumber, subDir: orderNumber }), + }); + if (!fwResp.ok) console.warn('[Skrift] Datei-Weiterleitung (Unterauftrag) Status:', fwResp.status, await fwResp.text()); + } catch (e) { console.warn('[Skrift] Datei-Weiterleitung (Unterauftrag) fehlgeschlagen:', e.message); } + + const { countDE, countAbroad } = countryStats(); + const res = await notifySubOrder(S.job.id, orderNumber, S.quantity, S.briefText || '', countDE, countAbroad); + if (!res?.success) throw new Error(res?.message || 'Unterauftrag konnte nicht angelegt werden.'); + + // Sub-Order-Liste aktualisieren und zurück zum Status + S.subOrders = [...S.subOrders, { + id: res.subOrderId, + status: 'submitted', + quantity: S.quantity, + createdAt: new Date().toISOString().replace('T', ' ').slice(0, 19), + }]; + S.isSubOrder = false; + S.subOrderNumber = null; + S.step = STEP.STATUS; + S.submitting = false; + // State für nächsten Unterauftrag zurücksetzen + S.printedLetterFile = null; S.printedLetterDone = false; + S.printedExcelFile = null; S.printedExcelDone = false; + S.signatures = []; S.signatureStrokes = []; + S.briefText = ''; S.rows = []; S.uploadDone = false; + invalidatePreviews(); + render(); + } catch (e) { + S.submitError = e.message || 'Unbekannter Fehler'; + S.submitting = false; + refreshConfirm(); + } +} + +// ─── Abschicken ─────────────────────────────────────────────────────────────── +async function doSubmit() { + if (S.isSubOrder) return doSubmitSubOrder(); + S.submitting = true; S.submitError = null; refreshConfirm(); + + try { + const isPrinted = S.job?.produkt === 'Gedruckte Briefe'; + + // Vorschau sicherstellen – nicht bei gedruckten Briefen (kein Handschriftinhalt) + if (!isPrinted && (!S.sessionId || !S.previews.length)) { + await doGenPreview(); + if (S.previewError) { + console.warn('[Skrift] Vorschau fehlgeschlagen, Auftrag wird trotzdem übermittelt:', S.previewError); + S.previewError = null; + } + } + + // Overflow prüfen (Brief- oder Umschlagtext zu lang) + if (!isPrinted && S.hasOverflow) { + throw new Error('Der Text ist zu lang für das gewählte Format. Bitte gehen Sie zurück und kürzen Sie den Text.'); + } + + const orderNumber = await genOrderNumber(); + const metadata = { + customer: { type: 'job', jobId: S.job.id }, + orderDate: new Date().toISOString(), + product: S.job.produkt, + quantity: S.quantity, + format: S.job.format, + ...orderMetaFormats(), + }; + + const btn = document.getElementById('sk-job-submit'); + if (btn) btn.innerHTML = ' Auftrag wird übermittelt…'; + + // Signaturen hochladen (optional – Fehler blockieren nicht die Auftragsübermittlung) + try { await doUploadSignatures(); } catch (e) { console.warn('[Skrift] Signatur-Upload fehlgeschlagen:', e.message); } + + // Gedruckte Briefe: Dateien hochladen, Umschlag generieren (falls gewählt), Metadaten im Backend speichern + if (S.job.produkt === 'Gedruckte Briefe') { + if (S.printedLetterFile && S.job?.id) { + await uploadFileToServer(S.printedLetterFile, S.job.id, 'brief'); + } + if (S.printedExcelFile && S.job?.id) { + await uploadFileToServer(S.printedExcelFile, S.job.id, 'brief_excel'); + } + + if (S.job.kuvert !== 'keine') { + // Umschlag-SVGs beim Backend generieren (keine Briefe – das Schriftstück ist bereits als Datei hochgeladen) + try { + const envelopes = buildEnvelopes(); + const result = await genOrder(orderNumber, [], envelopes, metadata); + if (result?.error || result?.code) { + console.warn('[Skrift] Umschlag-Generierung fehlgeschlagen:', result.error || result.message); + } + } catch (e) { console.warn('[Skrift] Umschlag-Generierung fehlgeschlagen:', e.message); } + } else { + // Kein Umschlag: Auftrag im Docker-Backend nur mit Metadaten registrieren (Verzeichnis anlegen) + try { + const regResp = await fetch(`${REST_URL}skrift/v1/job/register-order`, { + method: 'POST', headers: hdrs(), + body: JSON.stringify({ orderNumber, metadata }), + }); + if (!regResp.ok) console.warn('[Skrift] Backend-Registrierung Status:', regResp.status, await regResp.text()); + } catch (e) { console.warn('[Skrift] Backend-Registrierung fehlgeschlagen:', e.message); } + } + + // Hochgeladene Dateien (Brief, Excel-Tabellen) zusätzlich in den Backend-Auftragsordner kopieren + try { + const fwResp = await fetch(`${REST_URL}skrift/v1/job/forward-files`, { + method: 'POST', headers: hdrs(), + body: JSON.stringify({ jobId: S.job.id, orderNumber }), + }); + if (!fwResp.ok) console.warn('[Skrift] Datei-Weiterleitung Status:', fwResp.status, await fwResp.text()); + } catch (e) { console.warn('[Skrift] Datei-Weiterleitung fehlgeschlagen:', e.message); } + + await notifyAdmin(orderNumber); + if (REDIRECT_URL) { + const u = new URL(REDIRECT_URL); + u.searchParams.set('orderNumber', orderNumber); + u.searchParams.set('jobId', S.job.id); + window.location.href = u.toString(); + } else { + alert(`Ihre Daten wurden erfolgreich übermittelt!\nBestellnummer: ${orderNumber}\n\nVielen Dank!`); + S.submitting = false; refreshConfirm(); + } + return; + } + + // Alle Briefe + Umschläge direkt ans Backend senden + const allLetters = buildLetters(S.quantity); + const allEnvelopes = buildEnvelopes(); + + if (S.job.textservice && !S.briefText) { + allLetters.forEach(l => { + l.text = 'Sehr geehrte Damen und Herren,\n\nIhr Brief wird vom Skrift-Textservice verfasst.\n\nMit freundlichen Grüßen'; + }); + } + + const result = await genOrder(orderNumber, allLetters, allEnvelopes, metadata); + if (result?.error || result?.code) { + throw new Error(result.error || result.message || 'Backend-Generierung fehlgeschlagen.'); + } + + await notifyAdmin(orderNumber); + + if (REDIRECT_URL) { + const u = new URL(REDIRECT_URL); + u.searchParams.set('orderNumber', orderNumber); + u.searchParams.set('jobId', S.job.id); + window.location.href = u.toString(); + } else { + alert(`Ihre Daten wurden erfolgreich übermittelt!\nBestellnummer: ${orderNumber}\n\nVielen Dank!`); + S.submitting = false; refreshConfirm(); + } + } catch (e) { + S.submitError = e.message || 'Unbekannter Fehler'; + S.submitting = false; + refreshConfirm(); + } +} + +// ─── Datei-Upload (Adressen) ────────────────────────────────────────────────── +async function doUpload(file) { + S.uploadError = null; S.uploadDone = false; S.rows = []; S.uploadCharsetWarning = false; + + const zone = document.getElementById('sk-upload-zone'); + if (zone) zone.innerHTML = ` +
    + +
    +
    Wird verarbeitet…
    `; + + try { + const { headers, dataRows } = await parseFile(file); + const k = S.job.kuvert; + let mapped; + + _charsetStripped = false; _strippedChars = new Set(); + if (k === 'adresse' && S.addressMode === 'classic') { + mapped = dataRows.map(r => mapClassic(headers, r)); + } else if (k === 'adresse' && S.addressMode === 'free') { + mapped = dataRows.map(r => mapFree(headers, r)); + } else { + mapped = dataRows.map(r => mapCustomText(headers, r)); + } + if (_charsetStripped) { + S.uploadCharsetWarning = true; + S.uploadStrippedChars = [..._strippedChars].join(' '); + } + + if (!mapped.length) throw new Error('Keine Datenzeilen gefunden.'); + + S.rows = mapped; + S.uploadDone = true; + invalidatePreviews(); // neue Adressen → vorhandene Previews sind nicht mehr 1:1 + + if (mapped.length > S.quantity) { + const adjust = await showQuantityDialog(mapped.length, S.quantity); + if (adjust) S.quantity = mapped.length; + } + + // Datei serverseitig speichern (best-effort) + if (S.job?.id) uploadFileToServer(file, S.job.id, 'adresse', S.isSubOrder ? S.subOrderNumber : null).catch(() => {}); + + } catch (e) { + S.uploadError = e.message || 'Fehler beim Lesen der Datei.'; + } + render(); +} + +// ─── Datei-Upload (individuelle Platzhalter) ────────────────────────────────── +async function doCustomUpload(file) { + // 1. Aktuellen Textarea-Wert in State übernehmen (vor jeglicher DOM-Manipulation) + const ta = document.getElementById('sk-brieftext'); + if (ta) S.briefText = ta.value; + + // 2. Loading-State setzen und einmal rendern (zeigt Spinner) + S.customUploadError = null; + S.customUploadCharsetWarning = false; + S.customUploadLoading = true; + render(); + + // 3. Datei parsen und mappen + try { + const phs = customPlaceholderNames(); + const { headers, dataRows } = await parseFile(file); + + _charsetStripped = false; _strippedChars = new Set(); + const mapped = dataRows.map(row => { + const obj = {}; + phs.forEach(ph => { + const idx = headers.findIndex(h => h.toLowerCase() === ph.toLowerCase()); + obj[ph] = sanitizeText(idx !== -1 ? String(row[idx] ?? '').trim() : '', false); + }); + return obj; + }); + if (_charsetStripped) { + S.customUploadCharsetWarning = true; + S.customUploadStrippedChars = [..._strippedChars].join(' '); + } + + if (!mapped.length) throw new Error('Keine Datenzeilen gefunden.'); + + S.customRows = mapped; + S.customUploadDone = true; + if (S.job?.id) uploadFileToServer(file, S.job.id, 'platzhalter', S.isSubOrder ? S.subOrderNumber : null).catch(() => {}); + invalidatePreviews(); // neue Platzhalter-Werte → vorhandene Previews sind nicht mehr 1:1 + } catch (e) { + S.customUploadError = e.message || 'Fehler beim Lesen der Datei.'; + } + + // 4. Finalen Zustand rendern (zeigt Tabelle oder Fehler) + S.customUploadLoading = false; + render(); + + // 5. Tabelle ins Sichtfeld scrollen, falls erfolgreich + if (S.customUploadDone) { + requestAnimationFrame(() => { + const tbl = document.querySelector('#sk-job-inner .sk-table-wrapper'); + if (tbl) tbl.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + }); + } +} + +// ─── Signatur-Canvas Events ─────────────────────────────────────────────────── +function bindSignatureCanvases(root) { + const count = S.job?.signatur || 0; + for (let i = 0; i < count; i++) { + const canvas = root.querySelector(`#sk-sig-canvas-${i}`); + if (!canvas) continue; + + const ctx = canvas.getContext('2d'); + ctx.strokeStyle = '#1e3a6a'; + ctx.lineWidth = 2.5; + ctx.lineCap = 'round'; + ctx.lineJoin = 'round'; + + // Bestehende Signatur neu zeichnen + if (!S.signatureStrokes[i]) S.signatureStrokes[i] = []; + const redraw = () => { + ctx.clearRect(0, 0, canvas.width, canvas.height); + S.signatureStrokes[i].forEach(pts => { + if (!pts.length) return; + ctx.beginPath(); + ctx.moveTo(pts[0][0], pts[0][1]); + pts.slice(1).forEach(p => ctx.lineTo(p[0], p[1])); + ctx.stroke(); + }); + }; + redraw(); + + let drawing = false; + let cur = []; + + const scalePos = (e) => { + const rect = canvas.getBoundingClientRect(); + const sx = canvas.width / rect.width; + const sy = canvas.height / rect.height; + const src = e.touches ? e.touches[0] : e; + return [(src.clientX - rect.left) * sx, (src.clientY - rect.top) * sy]; + }; + + const onStart = (e) => { + e.preventDefault(); + drawing = true; + const [x, y] = scalePos(e); + cur = [[x, y]]; + ctx.beginPath(); + ctx.moveTo(x, y); + }; + + const onMove = (e) => { + if (!drawing) return; + e.preventDefault(); + const [x, y] = scalePos(e); + cur.push([x, y]); + ctx.lineTo(x, y); + ctx.stroke(); + ctx.beginPath(); + ctx.moveTo(x, y); + }; + + const onEnd = () => { + if (!drawing) return; + drawing = false; + if (cur.length >= 2) { + S.signatureStrokes[i].push(cur); + S.signatures[i] = pathsToSvg(S.signatureStrokes[i], canvas.width, canvas.height); + // Weiter-Button aktualisieren + const nxt = root.querySelector('#sk-job-next'); + if (nxt) { + const cnt = S.job?.signatur || 0; + nxt.disabled = !Array.from({length: cnt}, (_, j) => !!S.signatures[j]).every(Boolean); + } + // Status-Text aktualisieren + const wrap = canvas.parentElement?.nextElementSibling; + if (wrap) { + const span = wrap.querySelectorAll('span')[0]; + if (span && !S.signatures[i].includes('Fehler')) span.textContent = '✓ Signatur gespeichert'; + } + } + cur = []; + }; + + canvas.addEventListener('mousedown', onStart); + canvas.addEventListener('mousemove', onMove); + canvas.addEventListener('mouseup', onEnd); + canvas.addEventListener('mouseleave', onEnd); + canvas.addEventListener('touchstart', onStart, { passive: false }); + canvas.addEventListener('touchmove', onMove, { passive: false }); + canvas.addEventListener('touchend', onEnd); + + // Löschen-Button + const clearBtn = root.querySelector(`#sk-sig-clear-${i}`); + if (clearBtn) clearBtn.addEventListener('click', () => { + S.signatureStrokes[i] = []; + S.signatures[i] = null; + ctx.clearRect(0, 0, canvas.width, canvas.height); + render(); + }); + } +} + +// ─── Editierbare Tabelle (Direkteingabe Adresse/Platzhalter) ────────────────── +function bindEditableTableInputs(root, tableId, targetArray) { + root.querySelectorAll(`#${tableId} .sk-table-input`).forEach(input => { + input.addEventListener('input', () => { + const i = Number(input.dataset.row); + const k = input.dataset.key; + const original = input.value; + const cleaned = sanitizeText(original, false); + if (cleaned !== original) { + const cursorPos = input.selectionStart; + const strippedBefore = original.slice(0, cursorPos).replace(ALLOWED_CHARS_NO_NL_RE, '').length; + input.value = cleaned; + input.setSelectionRange(cursorPos - strippedBefore, cursorPos - strippedBefore); + } + if (!targetArray[i]) targetArray[i] = {}; + targetArray[i][k] = input.value; + invalidatePreviews(); + }); + + // Mehrspaltiges/mehrzeiliges Einfügen aus Excel (TSV) über mehrere Zellen verteilen + input.addEventListener('paste', e => handleTablePaste(e, input, root, tableId, targetArray)); + }); +} + +// Verteilt eine aus Excel kopierte Zellmatrix (Tab = Spalte, Zeilenumbruch = Zeile) +// ab der eingefügten Zelle nach rechts und unten über die Tabelle. Übersteigt die +// Empfängerzahl die Auftragsmenge, fragt ein Popup, ob die Menge erhöht werden soll. +function handleTablePaste(e, input, root, tableId, targetArray) { + const clip = e.clipboardData || window.clipboardData; + const text = clip ? clip.getData('text') : ''; + // Einzelwert ohne Tab/Zeilenumbruch: normales Einfügen zulassen + if (!text || !/[\t\r\n]/.test(text)) return; + + e.preventDefault(); + + // TSV in Matrix zerlegen und leere Abschlusszeilen (Excel-typisch) entfernen + const matrix = text.replace(/\r\n?/g, '\n').split('\n').map(line => line.split('\t')); + while (matrix.length > 1 && matrix[matrix.length - 1].every(c => c === '')) matrix.pop(); + + // Spaltenreihenfolge der EDITIERBAREN Felder aus der ersten Zeile ermitteln + const keys = Array.from(root.querySelectorAll(`#${tableId} .sk-table-input[data-row="0"]`)) + .map(el => el.dataset.key); + const startRow = Number(input.dataset.row); + const startCol = keys.indexOf(input.dataset.key); + if (startCol < 0) return; + + // Höchstens bis MAX_TABLE_ROWS befüllen (darüber ist nur der Datei-Upload vorgesehen) + const endRow = Math.min(startRow + matrix.length, MAX_TABLE_ROWS); + for (let r = startRow; r < endRow; r++) { + const srcRow = matrix[r - startRow]; + if (!targetArray[r]) targetArray[r] = {}; + for (let c = 0; c < srcRow.length && startCol + c < keys.length; c++) { + targetArray[r][keys[startCol + c]] = sanitizeText(srcRow[c], false); + } + } + invalidatePreviews(); + + if (endRow > S.quantity) { + showRowCountDialog(endRow, S.quantity).then(choice => { + if (choice === 'add') { + S.quantity = endRow; // Menge auf Empfängerzahl anheben + } else { + targetArray.splice(S.quantity); // Menge beibehalten: überzählige Zeilen verwerfen + } + render(); + }); + } else { + render(); + } +} + +// ─── Printed-Letter Upload ──────────────────────────────────────────────────── +function bindPrintedUpload(root) { + const letterZone = root.querySelector('#sk-printed-letter-zone'); + const letterInput = root.querySelector('#sk-printed-letter-input'); + const excelZone = root.querySelector('#sk-printed-excel-zone'); + const excelInput = root.querySelector('#sk-printed-excel-input'); + + const allowedLetter = ['application/pdf', 'application/msword', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document']; + + const handleLetter = (file) => { + if (!file) return; + if (!allowedLetter.includes(file.type) && !/\.(pdf|doc|docx)$/i.test(file.name)) { + S.printedLetterError = 'Nur .pdf, .doc oder .docx erlaubt.'; + S.printedLetterDone = false; + render(); return; + } + S.printedLetterFile = file; + S.printedLetterDone = true; + S.printedLetterError = null; + render(); + }; + + const handleExcel = (file) => { + if (!file) return; + S.printedExcelFile = file; + S.printedExcelDone = true; + render(); + }; + + if (letterZone && letterInput) { + letterZone.addEventListener('click', () => letterInput.click()); + letterZone.addEventListener('dragover', e => { e.preventDefault(); letterZone.classList.add('is-dragover'); }); + letterZone.addEventListener('dragleave', () => letterZone.classList.remove('is-dragover')); + letterZone.addEventListener('drop', e => { + e.preventDefault(); letterZone.classList.remove('is-dragover'); + handleLetter(e.dataTransfer?.files[0]); + }); + letterInput.addEventListener('change', () => handleLetter(letterInput.files[0])); + } + + if (excelZone && excelInput) { + excelZone.addEventListener('click', () => excelInput.click()); + excelZone.addEventListener('dragover', e => { e.preventDefault(); excelZone.classList.add('is-dragover'); }); + excelZone.addEventListener('dragleave', () => excelZone.classList.remove('is-dragover')); + excelZone.addEventListener('drop', e => { + e.preventDefault(); excelZone.classList.remove('is-dragover'); + handleExcel(e.dataTransfer?.files[0]); + }); + excelInput.addEventListener('change', () => handleExcel(excelInput.files[0])); + } +} + +// ─── Preview-Navigation (partial re-bind) ───────────────────────────────────── +function bindPreviewNav(root) { + if (!root) return; + root.querySelector('#sk-prev-preview')?.addEventListener('click', () => { + if (S.previewIndex > 0) { S.previewIndex--; refreshPreview(); } + }); + root.querySelector('#sk-next-preview')?.addEventListener('click', async () => { + // Gedruckte Briefe haben keine Brief-Vorschau, nur Umschläge – entsprechende Liste verwenden + const isPrinted = S.job?.produkt === 'Gedruckte Briefe'; + const list = isPrinted ? S.envelopePreviews : S.previews; + const loaded = list.length; + const total = effectiveQuantity(); + const hasMore = S.previewTotalLoaded < total && loaded < total; + const atEnd = S.previewIndex >= loaded - 1; + + if (!atEnd) { + // Nächste bereits geladene Vorschau anzeigen + S.previewIndex++; + refreshPreview(); + } else if (hasMore && !S.previewLoading) { + // Nächsten Batch vom Backend laden + const nextStart = S.previewTotalLoaded; + await doGenPreviewBatch(nextStart, S.previewBatchSize); + // Nach dem Laden: automatisch eine Seite weiter (wenn verfügbar) + const newList = isPrinted ? S.envelopePreviews : S.previews; + if (newList.length > S.previewIndex + 1) { + S.previewIndex++; + } + refreshPreview(); + } + }); + + // Preview-Images: Fehler-Handling und Klick-zu-Lightbox (sowohl Brief als auch Umschlag) + root.querySelectorAll('[data-preview-img]').forEach(img => { + img.addEventListener('error', () => { + const box = img.closest('[data-preview-url]'); + if (!box) return; + const url = box.getAttribute('data-preview-url') || ''; + box.innerHTML = `
    Vorschau-Bild konnte nicht geladen werden.
    ${esc(url)}
    `; + }); + img.addEventListener('click', () => { + const box = img.closest('[data-preview-url]'); + const url = box?.getAttribute('data-preview-url') || img.src; + openLightbox(url); + }); + }); +} + +// ─── Events binden ──────────────────────────────────────────────────────────── +function bindCheckbox(root, cbId, optId, stateKey, afterToggle) { + const cb = root.querySelector(`#${cbId}`); + const opt = root.querySelector(`#${optId}`); + if (!cb || !opt) return; + + const sync = () => { opt.classList.toggle('is-selected', S[stateKey]); afterToggle?.(); }; + + cb.addEventListener('change', () => { S[stateKey] = cb.checked; sync(); }); + opt.addEventListener('click', e => { + if (e.target !== cb) { cb.checked = !cb.checked; S[stateKey] = cb.checked; sync(); } + }); +} + +function bindEvents(root) { + if (!root) return; + + // Verify + const verifyBtn = root.querySelector('#sk-job-verify-btn'); + const idInput = root.querySelector('#sk-job-id'); + if (verifyBtn) verifyBtn.addEventListener('click', async () => { + const id = (idInput?.value || '').trim(); + if (!id) return; + S.jobIdInput = id; S.verifyError = null; S.verifying = true; render(); + const d = await verifyJob(id); + S.verifying = false; + if (d.success && d.job) { + S.job = d.job; + S.quantity = d.job.menge; + + // Brieftext wiederherstellen + // Bei text_proposal IMMER die Serverversion nehmen – Admin kann den Vorschlag aktualisiert haben + // (veralteter localStorage-Cache darf den neuen Vorschlag nicht überschreiben) + try { + const cached = localStorage.getItem(`sk_brieftext_${d.job.id}`); + if (d.job.status === 'text_proposal' && d.job.proposedText) { + S.briefText = d.job.proposedText; + localStorage.setItem(`sk_brieftext_${d.job.id}`, d.job.proposedText); + } else if (cached) { + S.briefText = cached; + } else if (d.job.proposedText) { + S.briefText = d.job.proposedText; + localStorage.setItem(`sk_brieftext_${d.job.id}`, d.job.proposedText); + } + } catch (_) {} + + // Status-basiertes Routing + const st = d.job.status || 'waiting'; + S.subOrders = d.job.subOrders || []; + if (st === 'submitted' || st === 'printing' || st === 'shipped') { + S.step = STEP.STATUS; + } else { + S.step = STEP.OVERVIEW; + } + + } else { + S.verifyError = d.message || 'Auftrag nicht gefunden. Bitte Auftragsnummer prüfen.'; + } + render(); + }); + if (idInput) idInput.addEventListener('keydown', e => e.key === 'Enter' && verifyBtn?.click()); + + // Menge: bei Änderung wird Preview-Cache invalidiert (andere Anzahl SVGs nötig) + const qtyInput = root.querySelector('#sk-job-qty'); + if (qtyInput) qtyInput.addEventListener('input', () => { + const newQty = Math.max(1, parseInt(qtyInput.value) || 1); + if (newQty !== S.quantity) { + S.quantity = newQty; + invalidatePreviews(); + } + }); + + // Kunden-E-Mail speichern + root.querySelector('#sk-save-email')?.addEventListener('click', async btn => { + const input = root.querySelector('#sk-customer-email'); + if (!input || !S.job) return; + const email = input.value.trim(); + btn = root.querySelector('#sk-save-email'); + if (btn) { btn.disabled = true; btn.textContent = '…'; } + try { + await updateCustomerEmail(S.job.id, email); + S.job.customerEmail = email; + if (btn) { btn.textContent = 'Gespeichert ✓'; setTimeout(() => { if (btn) btn.textContent = 'Speichern'; btn.disabled = false; }, 2000); } + } catch (_) { + if (btn) { btn.textContent = 'Fehler'; btn.disabled = false; } + } + }); + + // Adressmodus: bei Wechsel Adress-Upload zurücksetzen und Preview invalidieren + root.querySelectorAll('input[name="sk-addr-mode"]').forEach(r => { + r.addEventListener('change', () => { + S.addressMode = r.value; + S.rows = []; + S.uploadDone = S.addressInputMode === 'table'; + invalidatePreviews(); + render(); + }); + }); + + // Eingabemodus Adresse/Kuverttext: Datei-Upload vs. Direkteingabe in Tabelle + root.querySelectorAll('input[name="sk-addr-input-mode"]').forEach(r => { + r.addEventListener('change', () => { + S.addressInputMode = r.value; + S.uploadDone = r.value === 'table'; + S.uploadError = null; + invalidatePreviews(); + render(); + }); + }); + + // Eingabemodus individuelle Platzhalter: Datei-Upload vs. Direkteingabe in Tabelle + root.querySelectorAll('input[name="sk-custom-input-mode"]').forEach(r => { + r.addEventListener('change', () => { + S.customInputMode = r.value; + S.customUploadDone = r.value === 'table'; + S.customUploadError = null; + invalidatePreviews(); + render(); + }); + }); + + // Editierbare Tabellen (Adresse / individuelle Platzhalter): Eingaben direkt in den State schreiben + if (S.step === STEP.ADDRESS && S.addressInputMode === 'table') { + bindEditableTableInputs(root, 'sk-address-table', S.rows); + } + if (S.step === STEP.LETTERTEXT && S.customInputMode === 'table') { + bindEditableTableInputs(root, 'sk-custom-table', S.customRows); + } + + // Download (Adress-Vorlagen) + root.querySelector('#sk-dl-standard') ?.addEventListener('click', e => { e.stopPropagation(); dlTemplate('adresse-standard'); }); + root.querySelector('#sk-dl-free') ?.addEventListener('click', e => { e.stopPropagation(); dlTemplate('adresse-frei'); }); + root.querySelector('#sk-dl-customtext') ?.addEventListener('click', () => dlTemplate('individueller-text')); + + // Download (Platzhalter-Vorlage) + root.querySelector('#sk-dl-custom-tpl')?.addEventListener('click', e => { e.stopPropagation(); dlCustomTemplate(); }); + + // Upload-Zone (Adressen) + const zone = root.querySelector('#sk-upload-zone'); + const fInput = root.querySelector('#sk-file-input'); + if (zone && fInput) { + zone.addEventListener('click', () => fInput.click()); + zone.addEventListener('dragover', e => { e.preventDefault(); zone.classList.add('is-dragover'); }); + zone.addEventListener('dragleave', () => zone.classList.remove('is-dragover')); + zone.addEventListener('drop', e => { + e.preventDefault(); zone.classList.remove('is-dragover'); + const f = e.dataTransfer?.files[0]; + if (f) doUpload(f); + }); + fInput.addEventListener('change', () => { if (fInput.files[0]) doUpload(fInput.files[0]); }); + } + + // Upload-Zone (individuelle Platzhalter) + const customZone = root.querySelector('#sk-custom-upload-zone'); + const customInput = root.querySelector('#sk-custom-file-input'); + if (customZone && customInput) { + customZone.addEventListener('click', () => customInput.click()); + customZone.addEventListener('dragover', e => { e.preventDefault(); customZone.classList.add('is-dragover'); }); + customZone.addEventListener('dragleave', () => customZone.classList.remove('is-dragover')); + customZone.addEventListener('drop', e => { + e.preventDefault(); customZone.classList.remove('is-dragover'); + const f = e.dataTransfer?.files[0]; + if (f) doCustomUpload(f); + }); + customInput.addEventListener('change', () => { if (customInput.files[0]) doCustomUpload(customInput.files[0]); }); + } + + // Brieftext + const ta = root.querySelector('#sk-brieftext'); + if (ta) { + // Tab-Taste im Textfeld → \t einfügen statt Fokus zu wechseln + ta.addEventListener('keydown', (e) => { + if (e.key !== 'Tab') return; + e.preventDefault(); + const s = ta.selectionStart, end = ta.selectionEnd; + ta.value = ta.value.slice(0, s) + '\t' + ta.value.slice(end); + ta.selectionStart = ta.selectionEnd = s + 1; + S.briefText = ta.value; + try { if (S.job?.id) localStorage.setItem(`sk_brieftext_${S.job.id}`, S.briefText); } catch (_) {} + invalidatePreviews(); + const nx = root.querySelector('#sk-job-next'); + if (nx) nx.disabled = !S.briefText.trim(); + }); + + let prevCustomCount = customPlaceholderNames().length; + let prevText = S.briefText; + ta.addEventListener('input', () => { + // Nicht erlaubte Zeichen sofort entfernen, Cursor-Position erhalten + const original = ta.value; + const cleaned = sanitizeText(original); + if (cleaned !== original) { + const cursorPos = ta.selectionStart; + const strippedBefore = original.slice(0, cursorPos).replace(ALLOWED_CHARS_RE, '').length; + const newCursor = cursorPos - strippedBefore; + ta.value = cleaned; + ta.setSelectionRange(newCursor, newCursor); + const charInfo = root.querySelector('#sk-charset-info'); + if (charInfo) { + const removed = [...new Set((original.match(new RegExp(ALLOWED_CHARS_RE.source, 'g')) || []))].join(' '); + const charsEl = charInfo.querySelector('#sk-charset-chars'); + if (charsEl) charsEl.textContent = removed; + charInfo.style.display = ''; + clearTimeout(charInfo._hideTimer); + charInfo._hideTimer = setTimeout(() => { charInfo.style.display = 'none'; }, 4000); + } + } + S.briefText = ta.value; + // Brieftext pro Auftrag im Browser cachen (für Seitenreload) + try { if (S.job?.id) localStorage.setItem(`sk_brieftext_${S.job.id}`, S.briefText); } catch (_) {} + + const nx = root.querySelector('#sk-job-next'); + if (nx) nx.disabled = !S.briefText.trim(); + + // CAPS-Warnung dynamisch aktualisieren + const capsWarn = root.querySelector('#sk-caps-warn'); + if (capsWarn) capsWarn.style.display = hasCapsWarn(S.briefText) ? '' : 'none'; + + // Brieftext geändert → vorherige Vorschau & Overflow-Validierung invalidieren + if (S.briefText !== prevText) { + prevText = S.briefText; + invalidatePreviews(); + } + + // Wenn sich Anzahl/Set der individuellen Platzhalter ändert: Re-render damit die Sektion live erscheint/verschwindet + const newCustomCount = customPlaceholderNames().length; + if (newCustomCount !== prevCustomCount) { + prevCustomCount = newCustomCount; + const start = ta.selectionStart, end = ta.selectionEnd; + render(); + const newTa = document.getElementById('sk-brieftext'); + if (newTa) { newTa.focus(); newTa.setSelectionRange(start, end); } + } + }); + } + + // Platzhalter-Chips (Adresse + Signaturen) + root.querySelectorAll('.sk-job-ph-chip').forEach(chip => { + chip.addEventListener('click', () => { + const t = root.querySelector('#sk-brieftext'); + if (!t) return; + const ph = `[[${chip.dataset.ph}]]`; + const s = t.selectionStart, e = t.selectionEnd; + t.value = t.value.slice(0, s) + ph + t.value.slice(e); + t.setSelectionRange(s + ph.length, s + ph.length); + S.briefText = t.value; t.focus(); + }); + }); + + + // Schriftart: bei Wechsel werden Vorschau & Overflow-Status invalidiert (andere Schrift = andere Zeilenbreite) + root.querySelectorAll('#sk-font-grid .sk-selection-card').forEach(card => { + card.addEventListener('click', () => { + if (S.font === card.dataset.font) return; + S.font = card.dataset.font; + invalidatePreviews(); + render(); + }); + }); + + // Realistische Handschrift: Vorschau bei Änderung automatisch neu generieren + bindCheckbox(root, 'sk-realistic', 'sk-realistic-opt', 'realisticHandwriting', () => { + invalidatePreviews(); + if (S.step === STEP.FONT) doGenPreview(); + }); + + // Preview generieren + root.querySelector('#sk-gen-preview')?.addEventListener('click', doGenPreview); + + // Preview Navigation + bindPreviewNav(root); + + // Confirm-Checkboxen + const updateSubmitBtn = () => { + const btn = root.querySelector('#sk-job-submit'); + if (btn) btn.disabled = !S.acceptedAgb || !S.acceptedPrivacy || !S.acceptedFinal || S.submitting; + }; + + bindCheckbox(root, 'sk-agb', 'sk-agb-opt', 'acceptedAgb', updateSubmitBtn); + bindCheckbox(root, 'sk-privacy', 'sk-privacy-opt', 'acceptedPrivacy', updateSubmitBtn); + bindCheckbox(root, 'sk-final', 'sk-final-opt', 'acceptedFinal', updateSubmitBtn); + + // Submit + root.querySelector('#sk-job-submit')?.addEventListener('click', doSubmit); + + // Signatur-Canvases + if (S.step === STEP.SIGNATURE) bindSignatureCanvases(root); + + // Printed-Letter Upload + if (S.step === STEP.PRINTED_UPLOAD) bindPrintedUpload(root); + + // Routine: Neuen Unterauftrag starten + root.querySelector('#sk-routine-new')?.addEventListener('click', async (e) => { + const btn = e.currentTarget; + btn.disabled = true; + btn.innerHTML = ' Wird vorbereitet…'; + + // Bestellnummer JETZT reservieren – dient als eindeutiger Dateispeicher-Schlüssel für + // diesen Unterauftrag, damit Uploads (Adresse/Platzhalter/Brief) den vorherigen + // Unterauftrag nicht überschreiben. + S.subOrderNumber = await genOrderNumber(); + + S.isSubOrder = true; + S.printedLetterFile = null; S.printedLetterDone = false; S.printedLetterError = null; + S.printedExcelFile = null; S.printedExcelDone = false; + S.signatures = []; S.signatureStrokes = []; + S.briefText = ''; S.rows = []; S.uploadDone = false; + S.customRows = []; S.customUploadDone = false; + S.addressInputMode = 'file'; S.customInputMode = 'file'; + invalidatePreviews(); + S.step = STEP.OVERVIEW; + render(); + }); + + // Versandfreigabe erteilen + root.querySelector('#sk-approve-shipping')?.addEventListener('click', async () => { + const btn = root.querySelector('#sk-approve-shipping'); + if (!btn || !S.job) return; + btn.disabled = true; + btn.innerHTML = ' Freigabe wird erteilt…'; + try { + await approveShipping(S.job.id, S.job.shippingToken); + S.shippingApproved = true; + render(); + } catch (_) { + btn.disabled = false; + btn.innerHTML = 'Versandfreigabe erteilen →'; + } + }); + + // Navigation + root.querySelector('#sk-job-next')?.addEventListener('click', async () => { + // Direkt eingegebene Adresstabelle zusätzlich als Datei am Server hinterlegen (best-effort) + if (S.step === STEP.ADDRESS && S.addressInputMode === 'table') persistTableAsFile('adresse'); + // Auf dem Brieftext-Schritt: erst Vorschau generieren um Overflow zu prüfen + if (S.step === STEP.LETTERTEXT) { + await doLetterTextNext(); + } else { + go(1); + } + }); + root.querySelector('#sk-job-prev')?.addEventListener('click', () => go(-1)); +} + +// ─── Haupt-Render ───────────────────────────────────────────────────────────── +function render() { + const inner = document.getElementById('sk-job-inner'); + if (!inner) return; + + let html = ''; + switch (S.step) { + case STEP.VERIFY: html = renderVerify(); break; + case STEP.OVERVIEW: html = renderOverview(); break; + case STEP.ADDRESS: html = renderAddress(); break; + case STEP.LETTERTEXT: html = renderLetterText(); break; + case STEP.PRINTED_UPLOAD: html = renderPrintedUpload(); break; + case STEP.FONT: html = renderFont(); break; + case STEP.SIGNATURE: html = renderSignature(); break; + case STEP.CONFIRM: html = renderConfirm(); break; + case STEP.STATUS: html = renderStatus(); break; + } + inner.innerHTML = html; + bindEvents(inner.closest('[data-skrift-kundenauftrag="1"]')); +} + +// ─── Init ───────────────────────────────────────────────────────────────────── +function init() { + const root = document.querySelector('[data-skrift-kundenauftrag="1"]'); + if (!root) return; + root.innerHTML = '
    '; + + // URL-Parameter: ?nr=… → Eingabefeld vorab befüllen und Auftrag direkt laden + const params = new URLSearchParams(window.location.search); + const urlJobId = (params.get('nr') || '').trim(); + + if (urlJobId) { + S.jobIdInput = urlJobId; + } + + render(); + root.classList.add('sk-ready'); + + // Auftrag automatisch abrufen wenn ID in URL + if (urlJobId) { + setTimeout(() => document.querySelector('#sk-job-verify-btn')?.click(), 80); + } +} + +document.addEventListener('DOMContentLoaded', init); diff --git a/Webseite/skrift-job-manager/includes/admin-job-manager.php b/Webseite/skrift-job-manager/includes/admin-job-manager.php new file mode 100644 index 0000000..04e209c --- /dev/null +++ b/Webseite/skrift-job-manager/includes/admin-job-manager.php @@ -0,0 +1,2162 @@ + 'Warten auf Daten', + 'waiting_routine' => 'Warten auf Daten Routine', + 'text_proposal' => 'Textvorschlag hinterlegt', + 'submitted' => 'Daten ubermittelt', + 'error' => 'Daten fehlerhaft', + 'printing' => 'Druck abgeschlossen', + 'shipped' => 'Versendet', + ]; + + /** Sub-Auftragsstatus (ohne Routine-spezifische Status) */ + const SUB_STATUSES = [ + 'waiting' => 'Warten auf Daten', + 'submitted' => 'Daten ubermittelt', + 'error' => 'Daten fehlerhaft', + 'printing' => 'Druck abgeschlossen', + 'shipped' => 'Versendet', + ]; + + /** Formate (identisch mit Frontend) */ + const FORMATS = [ + 'A4' => 'A4 (Hochformat, max. 18 Zeilen)', + 'A6 Hochformat' => 'A6 Hochformat (max. 9 Zeilen)', + 'A6 Querformat' => 'A6 Querformat (max. 6 Zeilen)', + ]; + + public function __construct() { + add_action('admin_menu', [$this, 'add_admin_menu']); + add_action('rest_api_init', [$this, 'register_rest_routes']); + add_action('admin_post_skrift_job_create', [$this, 'handle_create_job']); + add_action('admin_post_skrift_job_delete', [$this, 'handle_delete_job']); + add_action('admin_post_skrift_job_trash', [$this, 'handle_trash_job']); + add_action('admin_post_skrift_job_restore', [$this, 'handle_restore_job']); + add_action('admin_post_skrift_job_purge', [$this, 'handle_purge_job']); + add_action('admin_post_skrift_job_bulk', [$this, 'handle_bulk_job']); + add_action('admin_post_skrift_job_settings', [$this, 'handle_save_settings']); + add_action('admin_post_skrift_job_update_status', [$this, 'handle_update_status']); + add_action('admin_post_skrift_sub_order_update_status', [$this, 'handle_update_sub_order_status']); + add_action('admin_post_skrift_sub_order_delete', [$this, 'handle_delete_sub_order']); + add_action('phpmailer_init', [$this, 'configure_smtp']); + add_action('skrift_cleanup_old_jobs', [$this, 'cleanup_old_jobs']); + + // Cron-Job einplanen falls noch nicht vorhanden + if (!wp_next_scheduled('skrift_cleanup_old_jobs')) { + wp_schedule_event(time(), 'daily', 'skrift_cleanup_old_jobs'); + } + } + + // ─── Plugin-Einstellungen ──────────────────────────────────────────────────── + + public static function get_plugin_settings(): array { + return wp_parse_args(get_option(self::SETTINGS_KEY, []), [ + 'notification_email' => '', + 'redirect_url' => '', + 'portal_url' => '', + 'smtp' => [ + 'host' => '', + 'port' => 465, + 'username' => '', + 'password' => '', + 'from_email' => '', + 'from_name' => 'Skrift Handschriftmarketing', + ], + 'mail_templates' => [ + 'waiting' => [ + 'subject' => 'Ihr Auftrag {{order_id}} ist bereit - bitte Daten hochladen', + 'body' => "Guten Tag,\n\nIhr Auftrag {{order_id}} wurde angelegt. Bitte laden Sie Ihre Daten uber den folgenden Link hoch:\n\n{{link}}\n\nBei Fragen stehen wir Ihnen gerne zur Verfugung.\n\nMit freundlichen Grussen\nIhr Skrift-Team", + ], + 'waiting_routine' => [ + 'subject' => 'Ihr Auftrag {{order_id}} - Daten jederzeit hochladbar', + 'body' => "Guten Tag,\n\nIhr Auftrag {{order_id}} wurde als Routine-Auftrag angelegt. Sie konnen uber den folgenden Link jederzeit neue Datensatze hochladen:\n\n{{link}}\n\nJeder Upload wird als separater Unterauftrag erfasst und bearbeitet.\n\nMit freundlichen Grussen\nIhr Skrift-Team", + ], + 'text_proposal' => [ + 'subject' => 'Ihr Textvorschlag zu Auftrag {{order_id}} ist bereit', + 'body' => "Guten Tag,\n\nWir haben einen Brieftext-Vorschlag fur Ihren Auftrag {{order_id}} erstellt.\n\nBitte rufen Sie Ihren Auftrag uber folgenden Link auf, pruefen Sie den Text und passen Sie ihn bei Bedarf an:\n\n{{link}}\n\nMit freundlichen Grussen\nIhr Skrift-Team", + ], + 'submitted' => [ + 'subject' => 'Ihre Daten zu Auftrag {{order_id}} sind eingegangen', + 'body' => "Guten Tag,\n\nVielen Dank! Wir haben Ihre Daten zu Auftrag {{order_id}} erfolgreich erhalten und machen uns nun an die Arbeit.\n\nMit freundlichen Grussen\nIhr Skrift-Team", + ], + 'error' => [ + 'subject' => 'Hinweis zu Auftrag {{order_id}} - Bitte Daten korrigieren', + 'body' => "Guten Tag,\n\nBei der Prufung Ihrer Daten zu Auftrag {{order_id}} ist ein Problem aufgetreten:\n\n{{reason}}\n\nBitte korrigieren Sie Ihre Daten und laden Sie diese erneut hoch:\n\n{{link}}\n\nMit freundlichen Grussen\nIhr Skrift-Team", + ], + 'printing' => [ + 'subject' => 'Auftrag {{order_id}} druckfertig - Ihre Freigabe wird benotigt', + 'body' => "Guten Tag,\n\nIhre Briefe zu Auftrag {{order_id}} sind fertig gedruckt und bereit zum Versand.\n\nBitte erteilen Sie die Versandfreigabe in Ihrem Auftragsportal:\n\n{{link}}\n\nMit freundlichen Grussen\nIhr Skrift-Team", + ], + 'shipped' => [ + 'subject' => 'Auftrag {{order_id}} wurde versendet', + 'body' => "Guten Tag,\n\nIhre Briefe zu Auftrag {{order_id}} wurden versendet.\n\nVielen Dank fur Ihren Auftrag!\n\nMit freundlichen Grussen\nIhr Skrift-Team", + ], + ], + ]); + } + + // ─── Admin-Menu ────────────────────────────────────────────────────────────── + + public function add_admin_menu(): void { + add_menu_page( + 'Skrift Auftraege', + 'Skrift Auftraege', + 'manage_options', + 'skrift-auftraege', + [$this, 'render_page'], + 'dashicons-edit-page', + 30 + ); + } + + // ─── REST-Routen ───────────────────────────────────────────────────────────── + + public function register_rest_routes(): void { + register_rest_route('skrift/v1', '/job/verify', [ + 'methods' => 'GET', + 'callback' => [$this, 'rest_verify_job'], + 'permission_callback' => '__return_true', + ]); + + register_rest_route('skrift/v1', '/job/template', [ + 'methods' => 'GET', + 'callback' => [$this, 'rest_download_template'], + 'permission_callback' => '__return_true', + ]); + + register_rest_route('skrift/v1', '/job/notify', [ + 'methods' => 'POST', + 'callback' => [$this, 'rest_notify_job'], + 'permission_callback' => [$this, 'rest_nonce_permission'], + ]); + + register_rest_route('skrift/v1', '/job/customer-email', [ + 'methods' => 'POST', + 'callback' => [$this, 'rest_update_customer_email'], + 'permission_callback' => [$this, 'rest_nonce_permission'], + ]); + + register_rest_route('skrift/v1', '/job/approve-shipping', [ + 'methods' => 'POST', + 'callback' => [$this, 'rest_approve_shipping'], + 'permission_callback' => '__return_true', + ]); + + register_rest_route('skrift/v1', '/job/upload-file', [ + 'methods' => 'POST', + 'callback' => [$this, 'rest_upload_file'], + 'permission_callback' => [$this, 'rest_nonce_permission'], + ]); + + register_rest_route('skrift/v1', '/job/upload-signature', [ + 'methods' => 'POST', + 'callback' => [$this, 'rest_upload_signature'], + 'permission_callback' => [$this, 'rest_nonce_permission'], + ]); + + register_rest_route('skrift/v1', '/job/sub-order/notify', [ + 'methods' => 'POST', + 'callback' => [$this, 'rest_notify_sub_order'], + 'permission_callback' => [$this, 'rest_nonce_permission'], + ]); + + register_rest_route('skrift/v1', '/job/register-order', [ + 'methods' => 'POST', + 'callback' => [$this, 'rest_register_order'], + 'permission_callback' => [$this, 'rest_nonce_permission'], + ]); + + register_rest_route('skrift/v1', '/job/forward-files', [ + 'methods' => 'POST', + 'callback' => [$this, 'rest_forward_files'], + 'permission_callback' => [$this, 'rest_nonce_permission'], + ]); + } + + // ─── REST: Auftrag im Docker-Backend registrieren (gedruckte Briefe) ──────── + + public function rest_register_order($request) { + $body = $request->get_json_params(); + if (empty($body['orderNumber'])) { + return new WP_Error('invalid_request', 'orderNumber fehlt', ['status' => 400]); + } + + if (class_exists('Skrift_Konfigurator_Admin_Settings')) { + $settings = Skrift_Konfigurator_Admin_Settings::get_settings(); + $api_url = rtrim($settings['backend_connection']['api_url'] ?? '', '/'); + $api_token = $settings['backend_connection']['api_token'] ?? ''; + } else { + $raw = get_option('skrift_konfigurator_settings', []); + $api_url = rtrim($raw['backend_connection']['api_url'] ?? '', '/'); + $api_token = $raw['backend_connection']['api_token'] ?? ''; + } + + if (empty($api_url)) { + return new WP_Error('no_backend', 'Backend-URL nicht konfiguriert', ['status' => 500]); + } + + $response = wp_remote_post( + $api_url . '/api/order/register', + [ + 'headers' => [ + 'Content-Type' => 'application/json', + 'X-API-Token' => $api_token, + ], + 'body' => wp_json_encode($body), + 'timeout' => 15, + 'data_format' => 'body', + ] + ); + + if (is_wp_error($response)) { + return new WP_Error('backend_error', $response->get_error_message(), ['status' => 502]); + } + + $status = wp_remote_retrieve_response_code($response); + $data = json_decode(wp_remote_retrieve_body($response), true); + + if ($status >= 400) { + return new WP_Error('backend_error', $data['message'] ?? 'Backend-Fehler', ['status' => $status]); + } + + return rest_ensure_response($data); + } + + // ─── REST: Hochgeladene Auftragsdateien in den Backend-Auftragsordner kopieren ── + // Adresse/Platzhalter-Excel, Briefdokument und Brief-Excel liegen bereits lokal + // (rest_upload_file) und werden hier zusätzlich ans Docker-Backend weitergereicht, + // damit sie im selben Auftragsordner wie die generierten Umschläge liegen. + + public function rest_forward_files($request) { + $body = $request->get_json_params(); + $job_id = sanitize_text_field($body['jobId'] ?? ''); + $order_number = sanitize_text_field($body['orderNumber'] ?? ''); + $sub_dir = sanitize_file_name($body['subDir'] ?? ''); + + if (empty($job_id) || empty($order_number)) { + return new WP_Error('invalid_request', 'jobId und orderNumber sind erforderlich', ['status' => 400]); + } + + if (class_exists('Skrift_Konfigurator_Admin_Settings')) { + $settings = Skrift_Konfigurator_Admin_Settings::get_settings(); + $api_url = rtrim($settings['backend_connection']['api_url'] ?? '', '/'); + $api_token = $settings['backend_connection']['api_token'] ?? ''; + } else { + $raw = get_option('skrift_konfigurator_settings', []); + $api_url = rtrim($raw['backend_connection']['api_url'] ?? '', '/'); + $api_token = $raw['backend_connection']['api_token'] ?? ''; + } + + if (empty($api_url)) { + return new WP_Error('no_backend', 'Backend-URL nicht konfiguriert', ['status' => 500]); + } + + $dir = self::get_job_dir($job_id) . (!empty($sub_dir) ? '/' . $sub_dir : ''); + if (!is_dir($dir)) { + return new WP_REST_Response(['success' => true, 'forwarded' => []], 200); + } + + // fileType (Frontend) => lokaler Dateiname-Präfix (siehe rest_upload_file) + $file_types = [ + 'adresse' => 'adresse', + 'platzhalter' => 'platzhalter', + 'brief' => 'brief', + 'brief_excel' => 'brief_platzhalter', + ]; + + $forwarded = []; + $errors = []; + + foreach ($file_types as $type => $prefix) { + $matches = glob($dir . '/' . $prefix . '.*'); + if (empty($matches)) continue; + $file_path = $matches[0]; + + $finfo = finfo_open(FILEINFO_MIME_TYPE); + $mime = finfo_file($finfo, $file_path); + finfo_close($finfo); + + $boundary = wp_generate_password(24, false); + $filename = basename($file_path); + + $mp = "--{$boundary}\r\n"; + $mp .= "Content-Disposition: form-data; name=\"orderNumber\"\r\n\r\n{$order_number}\r\n"; + $mp .= "--{$boundary}\r\n"; + $mp .= "Content-Disposition: form-data; name=\"fileType\"\r\n\r\n{$type}\r\n"; + $mp .= "--{$boundary}\r\n"; + $mp .= "Content-Disposition: form-data; name=\"file\"; filename=\"" . $filename . "\"\r\n"; + $mp .= "Content-Type: " . ($mime ?: 'application/octet-stream') . "\r\n\r\n"; + $mp .= file_get_contents($file_path) . "\r\n"; + $mp .= "--{$boundary}--\r\n"; + + $response = wp_remote_post($api_url . '/api/order/files', [ + 'headers' => [ + 'Content-Type' => 'multipart/form-data; boundary=' . $boundary, + 'X-API-Token' => $api_token, + ], + 'body' => $mp, + // Große Brief-PDFs (mehrere Dutzend MB) brauchen länger als 30s für die Weiterleitung. + 'timeout' => 180, + ]); + + if (is_wp_error($response)) { + $errors[] = "{$type}: " . $response->get_error_message(); + continue; + } + + $status = wp_remote_retrieve_response_code($response); + if ($status >= 400) { + $errors[] = "{$type}: HTTP {$status}"; + continue; + } + + $forwarded[] = $type; + } + + return new WP_REST_Response(['success' => true, 'forwarded' => $forwarded, 'errors' => $errors], 200); + } + + // ─── REST: Nonce-Prüfung für schreibende Endpunkte ─────────────────────────── + + public function rest_nonce_permission($request) { + $nonce = $request->get_header('X-WP-Nonce'); + if ($nonce && wp_verify_nonce($nonce, 'wp_rest')) { + return true; + } + return new WP_Error('rest_forbidden', 'Ungültiger oder fehlender Nonce.', ['status' => 403]); + } + + // ─── REST: Auftrag verifizieren ────────────────────────────────────────────── + + public function rest_verify_job($request) { + $job_id = sanitize_text_field($request->get_param('id') ?? ''); + + if (empty($job_id)) { + return new WP_Error('missing_id', 'Auftragsnummer fehlt', ['status' => 400]); + } + + $jobs = self::get_jobs(); + if (!isset($jobs[$job_id])) { + return new WP_Error('not_found', 'Auftrag nicht gefunden', ['status' => 404]); + } + + $job = $jobs[$job_id]; + + // Unteraufträge aufbereiten + $sub_orders_raw = $job['sub_orders'] ?? []; + $sub_orders_out = array_values(array_map(function($sub) { + return [ + 'id' => $sub['id'], + 'status' => $sub['status'] ?? 'waiting', + 'statusReason' => $sub['status_reason'] ?? '', + 'statusUpdatedAt' => $sub['status_updated_at'] ?? '', + 'createdAt' => $sub['created_at'] ?? '', + 'orderNumber' => $sub['order_number'] ?? '', + 'quantity' => (int) ($sub['quantity'] ?? 0), + ]; + }, $sub_orders_raw)); + + return new WP_REST_Response([ + 'success' => true, + 'job' => [ + 'id' => $job['id'], + 'produkt' => $job['produkt'], + 'format' => $job['format'], + 'menge' => (int) $job['menge'], + 'kuvert' => $job['kuvert'], + 'textservice' => (bool) $job['textservice'], + 'signatur' => (int) ($job['signatur'] ?? 0), + 'redirect_url' => $job['redirect_url'] ?? '', + 'status' => $job['status'], + 'statusReason' => $job['status_reason'] ?? '', + 'statusUpdatedAt' => $job['status_updated_at'] ?? '', + 'customerEmail' => $job['customer_email'] ?? '', + 'proposedText' => $job['proposed_text'] ?? '', + 'shippingToken' => ($job['status'] === 'printing') ? ($job['shipping_token'] ?? '') : '', + 'subOrders' => $sub_orders_out, + ], + ], 200); + } + + // ─── REST: Kunden-E-Mail aktualisieren ─────────────────────────────────────── + + public function rest_update_customer_email($request) { + $job_id = sanitize_text_field($request->get_param('jobId') ?? ''); + $email = sanitize_email($request->get_param('email') ?? ''); + + $jobs = self::get_jobs(); + if (empty($job_id) || !isset($jobs[$job_id])) { + return new WP_Error('not_found', 'Auftrag nicht gefunden', ['status' => 404]); + } + + if (!empty($email) && !is_email($email)) { + return new WP_Error('invalid_email', 'Ungultige E-Mail-Adresse', ['status' => 400]); + } + + $jobs[$job_id]['customer_email'] = $email; + update_option(self::JOBS_KEY, $jobs); + + return new WP_REST_Response(['success' => true], 200); + } + + // ─── REST: Versandfreigabe ─────────────────────────────────────────────────── + + public function rest_approve_shipping($request) { + $job_id = sanitize_text_field($request->get_param('jobId') ?? ''); + $token = sanitize_text_field($request->get_param('token') ?? ''); + + $jobs = self::get_jobs(); + if (empty($job_id) || !isset($jobs[$job_id])) { + return new WP_Error('not_found', 'Auftrag nicht gefunden', ['status' => 404]); + } + + $job = $jobs[$job_id]; + + if (empty($token) || $token !== ($job['shipping_token'] ?? '')) { + return new WP_Error('invalid_token', 'Ungultiger Freigabe-Token', ['status' => 403]); + } + + if ($job['status'] !== 'printing') { + return new WP_REST_Response(['success' => true, 'message' => 'Freigabe bereits erteilt.'], 200); + } + + // Versandfreigabe-Timestamp speichern + $jobs[$job_id]['shipping_approved_at'] = current_time('mysql'); + update_option(self::JOBS_KEY, $jobs); + + // Admin benachrichtigen + $plugin_settings = self::get_plugin_settings(); + $to_admin = !empty($plugin_settings['notification_email']) + ? sanitize_email($plugin_settings['notification_email']) + : get_option('admin_email'); + + $subject = "[Skrift] Versandfreigabe erteilt: {$job_id}"; + $message = "Der Kunde hat die Versandfreigabe fur Auftrag {$job_id} erteilt.\n\nBitte veranlassen Sie den Versand und setzen Sie den Status auf 'Versendet'."; + wp_mail($to_admin, $subject, $message, ['Content-Type: text/plain; charset=UTF-8']); + + return new WP_REST_Response(['success' => true, 'message' => 'Versandfreigabe wurde erteilt. Wir wurden informiert.'], 200); + } + + // ─── REST: Datei-Upload (Adressdaten / Platzhalter) ───────────────────────── + + public function rest_upload_file($request) { + $job_id = sanitize_text_field($request->get_param('jobId') ?? ''); + $file_type = sanitize_text_field($request->get_param('fileType') ?? ''); + $sub_dir = sanitize_file_name($request->get_param('subDir') ?? ''); + + $jobs = self::get_jobs(); + if (empty($job_id) || !isset($jobs[$job_id])) { + return new WP_Error('not_found', 'Auftrag nicht gefunden', ['status' => 404]); + } + + $allowed_types = ['adresse', 'platzhalter', 'brief', 'brief_excel']; + if (!in_array($file_type, $allowed_types, true)) { + return new WP_Error('invalid_type', 'Ungültiger Dateityp', ['status' => 400]); + } + + $files = $request->get_file_params(); + $file = $files['file'] ?? null; + if (!$file || $file['error'] !== UPLOAD_ERR_OK) { + return new WP_Error('upload_error', 'Datei-Upload fehlgeschlagen', ['status' => 400]); + } + + $finfo = finfo_open(FILEINFO_MIME_TYPE); + $mime = finfo_file($finfo, $file['tmp_name']); + finfo_close($finfo); + + // MIME-Typen je nach Dateityp. Die Prüfung akzeptiert, wenn ENTWEDER der erkannte + // MIME-Typ ODER die Dateiendung erlaubt ist. finfo liefert je nach Server für die + // gleiche Datei unterschiedliche Werte (z.B. docx = application/zip, manche PDFs = + // application/octet-stream). Ohne diese Endungs-Fallback-Prüfung wurden gültige + // PDF-/DOC-/DOCX-Uploads still abgelehnt und kamen nie im Docker-Backend an. + $ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION)); + + if ($file_type === 'brief') { + $allowed_mimes = [ + 'application/pdf', + 'application/msword', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'application/zip', // docx wird oft als zip erkannt + 'application/octet-stream', // generischer Fallback mancher Server + ]; + $allowed_exts = ['pdf', 'doc', 'docx']; + if (!in_array($ext, $allowed_exts, true) && !in_array($mime, $allowed_mimes, true)) { + return new WP_Error('invalid_mime', 'Nur .pdf, .doc oder .docx erlaubt', ['status' => 400]); + } + if (!in_array($ext, $allowed_exts, true)) { $ext = 'pdf'; } + } else { + // Tabellendateien (adresse, platzhalter, brief_excel) + $allowed_mimes = [ + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'application/vnd.ms-excel', + 'text/csv', + 'text/plain', + 'application/csv', + 'application/zip', // xlsx wird oft als zip erkannt + 'application/octet-stream', + ]; + $allowed_exts = ['xlsx', 'xls', 'csv']; + if (!in_array($ext, $allowed_exts, true) && !in_array($mime, $allowed_mimes, true)) { + return new WP_Error('invalid_mime', 'Nur .xlsx, .xls oder .csv erlaubt', ['status' => 400]); + } + if (!in_array($ext, $allowed_exts, true)) { $ext = 'xlsx'; } + } + + // Unteraufträge: eigenes Unterverzeichnis je Bestellnummer, damit Uploads eines + // neuen Unterauftrags die Dateien des vorherigen nicht überschreiben. + $dir = self::get_job_dir($job_id) . (!empty($sub_dir) ? '/' . $sub_dir : ''); + if (!wp_mkdir_p($dir)) { + return new WP_Error('dir_error', 'Verzeichnis konnte nicht erstellt werden', ['status' => 500]); + } + self::protect_directory($dir); + + $filename = ($file_type === 'brief_excel' ? 'brief_platzhalter' : $file_type) . '.' . $ext; + $dest_path = $dir . '/' . $filename; + + if (!move_uploaded_file($file['tmp_name'], $dest_path)) { + return new WP_Error('save_error', 'Datei konnte nicht gespeichert werden', ['status' => 500]); + } + + return new WP_REST_Response(['success' => true, 'saved' => $filename], 200); + } + + // ─── REST: Signatur hochladen ──────────────────────────────────────────────── + + public function rest_upload_signature($request) { + $job_id = sanitize_text_field($request->get_param('jobId') ?? ''); + $index = max(0, (int) ($request->get_param('index') ?? 0)); + $svg = $request->get_param('svg') ?? ''; + + $jobs = self::get_jobs(); + if (empty($job_id) || !isset($jobs[$job_id])) { + return new WP_Error('not_found', 'Auftrag nicht gefunden', ['status' => 404]); + } + + if (empty($svg) || strpos(trim($svg), ' 400]); + } + + // Nur SVG-Tags erlauben – rudimentäre Bereinigung + if (preg_match('/ + +
    +

    Skrift Kundenauftraege

    + + +

    Auftrag erfolgreich angelegt.

    + +

    Auftrag geloscht.

    + +

    Auftrag in den Papierkorb verschoben.

    + +

    Auftrag wiederhergestellt.

    + +

    Auftrag endgültig gelöscht.

    + +

    Massenoperation abgeschlossen.

    + +

    Unterauftrag gelöscht.

    + +

    Diese Auftragsnummer existiert bereits.

    + +

    Einstellungen gespeichert.

    + +

    Status aktualisiert.

    + + + + + + + + +
    +

    Neuen Auftrag anlegen

    +
    + + +
    + +
    + + +
    + +
    + + +
    + +
    + + +
    + +
    + + +
    + +
    + + +
    + +
    + + +
    + +
    + + +
    + +
    + + +
    + +
    + + +
    + + + + + +
    +
    + +
    +
    +
    + + +
    +

    Bestehende Auftraege

    + + empty($j['deleted_at'])); + $trashed_jobs = array_filter($jobs, fn($j) => !empty($j['deleted_at'])); + $kuvert_labels = ['keine' => 'Keine', 'adresse' => 'Adresse', 'individueller_text' => 'Ind. Text']; + ?> + + +
    + + +
    + + + + Aufträge aktiv +
    +
    + + +

    Noch keine Auftraege angelegt.

    + + + $sub, 'parent_id' => $pjob['id'], 'parent_job' => $pjob]; + } + } + + $status_groups = [ + 'pending' => [ + 'label' => 'Warten auf Daten / Offen', + 'statuses' => ['waiting', 'waiting_routine', 'text_proposal', 'error'], + 'sub_statuses' => ['waiting', 'error'], + 'open' => true, + ], + 'submitted' => [ + 'label' => 'Daten übermittelt', + 'statuses' => ['submitted'], + 'sub_statuses' => ['submitted'], + 'open' => true, + ], + 'printing' => [ + 'label' => 'Druck abgeschlossen', + 'statuses' => ['printing'], + 'sub_statuses' => ['printing'], + 'open' => true, + ], + 'shipped' => [ + 'label' => 'Versandt', + 'statuses' => ['shipped'], + 'sub_statuses' => ['shipped'], + 'open' => false, + ], + ]; + + foreach ($status_groups as $group_key => $group) : + $group_jobs = array_values(array_filter($active_jobs, fn($j) => in_array($j['status'] ?? 'waiting', $group['statuses'], true))); + $group_subs = array_values(array_filter($all_subs, fn($s) => in_array($s['sub']['status'] ?? 'waiting', $group['sub_statuses'], true))); + $gcount = count($group_jobs) + count($group_subs); + ?> +
    0 && $group['open']) ? 'open' : ''; ?> class="sk-job-group" id="sk-group-"> + + + + + + +

    Keine Aufträge in dieser Kategorie.

    + + + + + + + + + + + + + + + + + + + > + + + + + + + + + + + + + + + + + + + + + + + + > + + + + + + + + + + + + + + + + + + +
    AuftragsnummerProdukt / FormatMengeKuvertSig.E-Mail KundeAngelegtStatus
    + + + + + + + + +
    + +
    0 ? esc_html($job['signatur']) : '–'; ?> + + + + – + + +
    + + + +
    + + +
    +
    + +
    + Versandfreigabe am +
    + +
    + Übermittelt am +
    + +
    +
    + + + + +
    +
    +
    + + +
    +
    + + +
    +
    + + +
    ↳ +
    + + +
    + +
    0 ? esc_html($pjob['signatur']) : '–'; ?> + + + + – + + + + +
    + + + + +
    + + +
    +
    +
    +
    + + + + + +
    +
    + +
    +
    + +
    + + + + +
    + + + Papierkorb + + + + + + + + + + + + + + + + + + + + + +
    AuftragsnummerProduktGelöscht amAktionen
    +
    + + + + +
    +
    + + + + +
    +
    +
    + + + +
    + + + + +
    + + + +
    +

    Globale Einstellungen

    +
    +
    + + + Eingehende Auftraege werden hier gemeldet. Leer = Admin-E-Mail. +
    +
    + + + Seite mit dem Shortcode [skrift_kundenauftrag] – wird in E-Mails verlinkt. +
    +
    + + + Kunde wird nach Datenubergabe hierhin weitergeleitet (mit ?orderNumber=…). +
    +
    +
    + + +
    +

    SMTP-Einstellungen

    +

    + Ausgehender Mailversand fur Kunden-E-Mails. Wenn kein Host angegeben ist, verwendet WordPress den Server-Standard (oft unzuverlassig). +

    +
    +
    + + +
    +
    + + + 465 = SSL/TLS  ·  587 = STARTTLS +
    +
    + + +
    +
    + + +
    +
    + + + Leer = SMTP-Benutzername wird als Absenderadresse genutzt. +
    +
    + + +
    +
    +
    + + +
    +

    E-Mail-Vorlagen an Kunden

    +

    + Verfugbare Platzhalter: {{order_id}}, {{link}}, + {{reason}} (nur "Fehlerhaft"), {{approve_link_button}} (nur "Druck abgeschlossen").
    + Farbige Hervorhebung: [rot]Ihr Text hier[/rot] → erscheint rot & fett im E-Mail. +

    + + ['label' => 'Warten auf Daten', 'badge' => 'sk-badge-waiting', 'note' => 'Wird beim Anlegen des Auftrags gesendet (wenn E-Mail hinterlegt)'], + 'waiting_routine' => ['label' => 'Warten auf Daten Routine','badge' => 'sk-badge-waiting-routine', 'note' => 'Wird gesendet wenn Status auf Routine gesetzt wird'], + 'text_proposal' => ['label' => 'Textvorschlag hinterlegt','badge' => 'sk-badge-text-proposal', 'note' => 'Wird gesendet wenn Sie einen Textvorschlag hinterlegen'], + 'submitted' => ['label' => 'Daten ubermittelt', 'badge' => 'sk-badge-submitted', 'note' => 'Wird nach Datenubergabe durch den Kunden gesendet'], + 'error' => ['label' => 'Daten fehlerhaft', 'badge' => 'sk-badge-error', 'note' => 'Wird gesendet wenn Sie den Status auf Fehlerhaft setzen'], + 'printing' => ['label' => 'Druck abgeschlossen', 'badge' => 'sk-badge-printing', 'note' => 'Enthalt Versandfreigabe-Button ({{approve_link_button}})'], + 'shipped' => ['label' => 'Versendet', 'badge' => 'sk-badge-shipped', 'note' => 'Abschluss-Benachrichtigung'], + ]; + foreach ($tpl_labels as $tkey => $tinfo) : + $tpl = $templates[$tkey] ?? ['subject' => '', 'body' => '']; + ?> +
    +

    + + +

    +
    + + +
    +
    + + +
    +
    + +
    + + +
    + + +
    + 'duplicate', 'sk_tab' => 'auftraege'], admin_url('admin.php?page=skrift-auftraege'))); + exit; + } + + $jobs[$job_id] = [ + 'id' => $job_id, + 'produkt' => $produkt, + 'format' => $format, + 'menge' => $menge, + 'kuvert' => $kuvert, + 'textservice' => $textservice, + 'signatur' => $signatur, + 'sub_orders' => [], + 'customer_email' => $customer_email, + 'status' => $initial_status, + 'status_reason' => ($initial_status === 'error') ? $initial_reason : '', + 'status_updated_at' => current_time('mysql'), + 'shipping_token' => ($initial_status === 'printing') ? wp_generate_password(32, false) : '', + 'proposed_text' => ($initial_status === 'text_proposal') ? $initial_proposed_text : '', + 'print_approved_at' => ($initial_status === 'submitted') ? current_time('mysql') : '', + 'shipping_approved_at' => '', + 'created_at' => current_time('mysql'), + ]; + + update_option(self::JOBS_KEY, $jobs); + + // Kunden-E-Mail senden (fur den gewahlten Anfangsstatus) + if (!empty($customer_email) && is_email($customer_email)) { + self::send_customer_email($job_id, $initial_status, $initial_reason); + } + + wp_redirect(add_query_arg(['sk_notice' => 'created', 'sk_tab' => 'auftraege'], admin_url('admin.php?page=skrift-auftraege'))); + exit; + } + + // ─── Admin: Status aktualisieren ───────────────────────────────────────────── + + public function handle_update_status(): void { + if (!current_user_can('manage_options')) wp_die('Keine Berechtigung.'); + check_admin_referer('skrift_job_update_status'); + + $job_id = sanitize_text_field($_POST['job_id'] ?? ''); + $new_status = sanitize_text_field($_POST['new_status'] ?? ''); + $reason = sanitize_textarea_field($_POST['status_reason'] ?? ''); + $proposed_text = sanitize_textarea_field($_POST['proposed_text'] ?? ''); + + if (!array_key_exists($new_status, self::STATUSES)) { + wp_redirect(add_query_arg(['sk_notice' => 'error', 'sk_tab' => 'auftraege'], admin_url('admin.php?page=skrift-auftraege'))); + exit; + } + + self::update_status($job_id, $new_status, $reason, $proposed_text); + + wp_redirect(add_query_arg(['sk_notice' => 'status_updated', 'sk_tab' => 'auftraege'], admin_url('admin.php?page=skrift-auftraege'))); + exit; + } + + // ─── Admin: Auftrag loschen ─────────────────────────────────────────────────── + + public function handle_delete_job(): void { + if (!current_user_can('manage_options')) wp_die('Keine Berechtigung.'); + check_admin_referer('skrift_job_delete'); + + $job_id = sanitize_text_field($_POST['job_id'] ?? ''); + $jobs = self::get_jobs(); + unset($jobs[$job_id]); + update_option(self::JOBS_KEY, $jobs); + + wp_redirect(add_query_arg(['sk_notice' => 'deleted', 'sk_tab' => 'auftraege'], admin_url('admin.php?page=skrift-auftraege'))); + exit; + } + + // ─── Admin: Auftrag in Papierkorb verschieben ──────────────────────────────── + + public function handle_trash_job(): void { + if (!current_user_can('manage_options')) wp_die('Keine Berechtigung.'); + check_admin_referer('skrift_job_trash'); + + $job_id = sanitize_text_field($_POST['job_id'] ?? ''); + $jobs = self::get_jobs(); + if (isset($jobs[$job_id])) { + $jobs[$job_id]['deleted_at'] = current_time('mysql'); + update_option(self::JOBS_KEY, $jobs); + } + + wp_redirect(add_query_arg(['sk_notice' => 'trashed', 'sk_tab' => 'auftraege'], admin_url('admin.php?page=skrift-auftraege'))); + exit; + } + + // ─── Admin: Auftrag aus Papierkorb wiederherstellen ────────────────────────── + + public function handle_restore_job(): void { + if (!current_user_can('manage_options')) wp_die('Keine Berechtigung.'); + check_admin_referer('skrift_job_restore'); + + $job_id = sanitize_text_field($_POST['job_id'] ?? ''); + $jobs = self::get_jobs(); + if (isset($jobs[$job_id])) { + $jobs[$job_id]['deleted_at'] = null; + update_option(self::JOBS_KEY, $jobs); + } + + wp_redirect(add_query_arg(['sk_notice' => 'restored', 'sk_tab' => 'auftraege'], admin_url('admin.php?page=skrift-auftraege'))); + exit; + } + + // ─── Admin: Auftrag endgültig löschen ──────────────────────────────────────── + + public function handle_purge_job(): void { + if (!current_user_can('manage_options')) wp_die('Keine Berechtigung.'); + check_admin_referer('skrift_job_purge'); + + $job_id = sanitize_text_field($_POST['job_id'] ?? ''); + $jobs = self::get_jobs(); + + $dir = self::get_job_dir($job_id); + if (is_dir($dir)) { + self::delete_directory($dir); + } + + unset($jobs[$job_id]); + update_option(self::JOBS_KEY, $jobs); + + wp_redirect(add_query_arg(['sk_notice' => 'purged', 'sk_tab' => 'auftraege'], admin_url('admin.php?page=skrift-auftraege'))); + exit; + } + + // ─── Admin: Massenoperation ─────────────────────────────────────────────────── + + public function handle_bulk_job(): void { + if (!current_user_can('manage_options')) wp_die('Keine Berechtigung.'); + check_admin_referer('skrift_job_bulk'); + + $action = sanitize_text_field($_POST['bulk_action'] ?? ''); + $job_ids = array_map('sanitize_text_field', (array) ($_POST['job_ids'] ?? [])); + $sub_ids = array_map('sanitize_text_field', (array) ($_POST['sub_ids'] ?? [])); + $jobs = self::get_jobs(); + + // Oberaufträge + foreach ($job_ids as $job_id) { + if (!isset($jobs[$job_id])) continue; + if ($action === 'trash') { + $jobs[$job_id]['deleted_at'] = current_time('mysql'); + } + } + + // Unteraufträge (Format: "parent_id:sub_id") + foreach ($sub_ids as $entry) { + $parts = explode(':', $entry, 2); + if (count($parts) !== 2) continue; + [$parent_id, $sub_id] = $parts; + if (!isset($jobs[$parent_id])) continue; + if ($action === 'trash') { + $jobs[$parent_id]['sub_orders'] = array_values(array_filter( + $jobs[$parent_id]['sub_orders'] ?? [], + fn($s) => $s['id'] !== $sub_id + )); + } + } + + update_option(self::JOBS_KEY, $jobs); + + wp_redirect(add_query_arg(['sk_notice' => 'bulk_done', 'sk_tab' => 'auftraege'], admin_url('admin.php?page=skrift-auftraege'))); + exit; + } + + // ─── Admin: Globale Einstellungen speichern ────────────────────────────────── + + public function handle_save_settings(): void { + if (!current_user_can('manage_options')) wp_die('Keine Berechtigung.'); + check_admin_referer('skrift_job_settings'); + + $raw_templates = $_POST['mail_templates'] ?? []; + $templates = []; + + foreach (array_keys(self::STATUSES) as $skey) { + $templates[$skey] = [ + 'subject' => sanitize_text_field($raw_templates[$skey]['subject'] ?? ''), + 'body' => sanitize_textarea_field($raw_templates[$skey]['body'] ?? ''), + ]; + } + + $smtp_raw = $_POST['smtp'] ?? []; + $smtp = [ + 'host' => sanitize_text_field($smtp_raw['host'] ?? ''), + 'port' => max(1, (int) ($smtp_raw['port'] ?? 465)), + 'username' => sanitize_text_field($smtp_raw['username'] ?? ''), + 'password' => sanitize_text_field($smtp_raw['password'] ?? ''), + 'from_email' => sanitize_email($smtp_raw['from_email'] ?? ''), + 'from_name' => sanitize_text_field($smtp_raw['from_name'] ?? 'Skrift Handschriftmarketing'), + ]; + + update_option(self::SETTINGS_KEY, [ + 'notification_email' => sanitize_email($_POST['job_global_notification_email'] ?? ''), + 'portal_url' => esc_url_raw($_POST['job_global_portal_url'] ?? ''), + 'redirect_url' => esc_url_raw($_POST['job_global_redirect_url'] ?? ''), + 'smtp' => $smtp, + 'mail_templates' => $templates, + ]); + + wp_redirect(add_query_arg(['sk_notice' => 'settings_saved', 'sk_tab' => 'einstellungen'], admin_url('admin.php?page=skrift-auftraege'))); + exit; + } + + // ─── Hilfsmethoden ─────────────────────────────────────────────────────────── + + public static function get_jobs(): array { + $jobs = get_option(self::JOBS_KEY, []); + foreach ($jobs as &$job) { + // Backward-Kompatibilitat: alte Status-Werte normalisieren + if (!isset($job['status'])) $job['status'] = 'waiting'; + if ($job['status'] === 'open') $job['status'] = 'waiting'; + if ($job['status'] === 'completed') $job['status'] = 'submitted'; + if (!isset($job['customer_email'])) $job['customer_email'] = ''; + if (!isset($job['status_reason'])) $job['status_reason'] = ''; + if (!isset($job['status_updated_at'])) $job['status_updated_at'] = ''; + if (!isset($job['shipping_token'])) $job['shipping_token'] = ''; + if (!isset($job['proposed_text'])) $job['proposed_text'] = ''; + if (!isset($job['print_approved_at'])) $job['print_approved_at'] = ''; + if (!isset($job['shipping_approved_at'])) $job['shipping_approved_at'] = ''; + if (!isset($job['signatur'])) $job['signatur'] = 0; + if (!isset($job['sub_orders'])) $job['sub_orders'] = []; + if (!isset($job['order_number'])) $job['order_number'] = ''; + } + return $jobs; + } + + /** + * Status eines Auftrags aktualisieren und Kunden-E-Mail senden. + */ + public static function update_status(string $job_id, string $new_status, string $reason = '', string $proposed_text = ''): bool { + $jobs = self::get_jobs(); + if (!isset($jobs[$job_id])) return false; + + $jobs[$job_id]['status'] = $new_status; + $jobs[$job_id]['status_reason'] = $reason; + $jobs[$job_id]['status_updated_at'] = current_time('mysql'); + + // Textvorschlag speichern + if ($new_status === 'text_proposal') { + $jobs[$job_id]['proposed_text'] = $proposed_text; + } + + // Druckfreigabe-Timestamp: Kunde hat Daten ubermittelt → Druck kann starten + if ($new_status === 'submitted' && empty($jobs[$job_id]['print_approved_at'])) { + $jobs[$job_id]['print_approved_at'] = current_time('mysql'); + } + + // Shipping-Token fur Versandfreigabe + if ($new_status === 'printing' && empty($jobs[$job_id]['shipping_token'])) { + $jobs[$job_id]['shipping_token'] = wp_generate_password(32, false); + } + + update_option(self::JOBS_KEY, $jobs); + + // Kunden-E-Mail versenden + self::send_customer_email($job_id, $new_status, $reason); + + return true; + } + + /** Backward-Compat-Alias */ + public static function mark_completed(string $job_id): void { + self::update_status($job_id, 'submitted'); + } + + /** + * SMTP-Versand via phpmailer_init konfigurieren. + */ + public function configure_smtp($phpmailer): void { + $settings = self::get_plugin_settings(); + $smtp = $settings['smtp'] ?? []; + + if (empty($smtp['host'])) return; + + $port = (int) ($smtp['port'] ?? 465); + $phpmailer->isSMTP(); + $phpmailer->Host = $smtp['host']; + $phpmailer->SMTPAuth = true; + $phpmailer->Username = $smtp['username']; + $phpmailer->Password = $smtp['password']; + $phpmailer->SMTPSecure = ($port === 587) ? 'tls' : 'ssl'; // 587=STARTTLS, 465=SSL/TLS + $phpmailer->Port = $port; + $phpmailer->CharSet = 'UTF-8'; + + $fromEmail = !empty($smtp['from_email']) ? $smtp['from_email'] : $smtp['username']; + $fromName = !empty($smtp['from_name']) ? $smtp['from_name'] : 'Skrift'; + + $phpmailer->setFrom($fromEmail, $fromName); + } + + /** + * Kunden-E-Mail anhand eines Templates senden. + */ + private static function send_customer_email(string $job_id, string $status, string $reason = ''): void { + $jobs = self::get_jobs(); + if (!isset($jobs[$job_id])) return; + + $job = $jobs[$job_id]; + $to = sanitize_email($job['customer_email'] ?? ''); + if (empty($to) || !is_email($to)) return; + + $settings = self::get_plugin_settings(); + $templates = $settings['mail_templates'] ?? []; + $template = $templates[$status] ?? null; + if (!$template || empty($template['subject'])) return; + + // Portal-URL aufbauen + $portal_url = !empty($settings['portal_url']) ? $settings['portal_url'] : site_url('/'); + $order_link = add_query_arg('nr', rawurlencode($job_id), $portal_url); + + // Versandfreigabe-Link (nur fur 'printing') + $approve_link = ''; + $approve_link_button = ''; + if ($status === 'printing' && !empty($job['shipping_token'])) { + $approve_link = add_query_arg([ + 'nr' => rawurlencode($job_id), + 'sk_approve' => rawurlencode($job['shipping_token']), + ], $portal_url); + + $approve_link_button = '' + . 'Versandfreigabe erteilen'; + } + + // Template-Text: {{approve_link_button}} erst durch Platzhalter ersetzen (vor htmlspecialchars) + $body_raw = $template['body']; + $body_raw = str_replace('{{approve_link_button}}', '[__APPROVE_BTN__]', $body_raw); + + // Restliche Platzhalter ersetzen + $body_raw = str_replace( + ['{{order_id}}', '{{link}}', '{{reason}}', '{{approve_link}}'], + [esc_html($job_id), esc_url($order_link), esc_html($reason), esc_url($approve_link ?: $order_link)], + $body_raw + ); + $subject = str_replace('{{order_id}}', $job_id, $template['subject']); + + // HTML-Body bauen + $html_body = nl2br(htmlspecialchars($body_raw, ENT_QUOTES, 'UTF-8')); + // Approve-Button als echtes HTML einfugen + $html_body = str_replace('[__APPROVE_BTN__]', $approve_link_button, $html_body); + // [rot]...[/rot] Tags als roten, fetten Text rendern (direkt im Template schreibbar) + $html_body = preg_replace( + '/\[rot\](.*?)\[\/rot\]/s', + '$1', + $html_body + ); + + $html = '' + . '
    ' + . 'SKRIFT' + . '
    ' + . $html_body + . '
    ' + . 'Skrift Handschrift-Service | Diese E-Mail wurde automatisch generiert.' + . '
    '; + + wp_mail($to, $subject, $html, ['Content-Type: text/html; charset=UTF-8', 'Cc: hello@skrift.de']); + } + + // ─── Datei-Verwaltung ──────────────────────────────────────────────────────── + + /** + * Gibt das Verzeichnis für Auftragsdateien zurück (kein Trailing-Slash). + */ + public static function get_job_dir(string $job_id): string { + $upload = wp_upload_dir(); + return $upload['basedir'] . '/skrift-auftraege/' . sanitize_file_name($job_id); + } + + /** + * Legt eine .htaccess an, die direkten HTTP-Zugriff auf das Verzeichnis sperrt. + */ + private static function protect_directory(string $dir): void { + $htaccess = $dir . '/.htaccess'; + if (!file_exists($htaccess)) { + file_put_contents($htaccess, "Deny from all\n\n Order allow,deny\n Allow from all\n\n"); + } + } + + /** + * Löscht ein Verzeichnis mit allem Inhalt rekursiv. + */ + private static function delete_directory(string $dir): void { + if (!is_dir($dir)) return; + foreach (array_diff((array) scandir($dir), ['.', '..']) as $entry) { + $path = $dir . '/' . $entry; + is_dir($path) ? self::delete_directory($path) : unlink($path); + } + rmdir($dir); + } + + /** + * Cron-Callback: Aufträge im Status "versendet" nach 3 Monaten löschen. + */ + public function cleanup_old_jobs(): void { + $jobs = self::get_jobs(); + $cutoff = strtotime('-3 months'); + $changed = false; + + foreach ($jobs as $job_id => $job) { + if (($job['status'] ?? '') !== 'shipped') continue; + + // Zeitstempel des Versand-Status als Referenz + $shipped_ts = !empty($job['status_updated_at']) + ? strtotime($job['status_updated_at']) + : (!empty($job['created_at']) ? strtotime($job['created_at']) : 0); + + if ($shipped_ts > 0 && $shipped_ts < $cutoff) { + // Dateien löschen + $dir = self::get_job_dir($job_id); + if (is_dir($dir)) { + self::delete_directory($dir); + } + // Auftrag aus der Liste entfernen + unset($jobs[$job_id]); + $changed = true; + } + } + + if ($changed) { + update_option(self::JOBS_KEY, $jobs); + } + } + + // ─── xlsx-Generator ─────────────────────────────────────────────────────────── + + private static function create_xlsx(array $rows): string { + if (!class_exists('ZipArchive')) return ''; + $tmp = tempnam(sys_get_temp_dir(), 'skrift_xlsx_'); + $zip = new ZipArchive(); + $zip->open($tmp, ZipArchive::CREATE | ZipArchive::OVERWRITE); + $zip->addFromString('[Content_Types].xml', self::xlsx_content_types()); + $zip->addFromString('_rels/.rels', self::xlsx_rels()); + $zip->addFromString('xl/workbook.xml', self::xlsx_workbook()); + $zip->addFromString('xl/_rels/workbook.xml.rels', self::xlsx_workbook_rels()); + $zip->addFromString('xl/worksheets/sheet1.xml', self::xlsx_sheet($rows)); + $zip->addFromString('xl/styles.xml', self::xlsx_styles()); + $zip->close(); + $content = file_get_contents($tmp); + unlink($tmp); + return $content; + } + + private static function xlsx_content_types(): string { + return ' + + + + + + +'; + } + + private static function xlsx_rels(): string { + return ' + + +'; + } + + private static function xlsx_workbook(): string { + return ' + + +'; + } + + private static function xlsx_workbook_rels(): string { + return ' + + + +'; + } + + private static function xlsx_styles(): string { + return ' + + + + + + + + + + + + + + + + + + + +'; + } + + private static function xlsx_sheet(array $rows): string { + $rows_xml = ''; + $row_num = 1; + foreach ($rows as $row) { + $cells_xml = ''; + $col_idx = 0; + foreach ($row as $cell) { + $col = self::col_letter($col_idx); + $ref = $col . $row_num; + $escaped = htmlspecialchars((string) $cell, ENT_XML1, 'UTF-8'); + $style = ($row_num === 1) ? ' s="1"' : ''; + $cells_xml .= "{$escaped}"; + $col_idx++; + } + $rows_xml .= "{$cells_xml}"; + $row_num++; + } + return ' + + ' . $rows_xml . ' +'; + } + + private static function col_letter(int $n): string { + $letter = ''; + do { + $letter = chr(65 + ($n % 26)) . $letter; + $n = intval($n / 26) - 1; + } while ($n >= 0); + return $letter; + } +} + +new Skrift_Job_Manager(); diff --git a/Webseite/skrift-job-manager/skrift-job-manager.php b/Webseite/skrift-job-manager/skrift-job-manager.php new file mode 100644 index 0000000..6418c38 --- /dev/null +++ b/Webseite/skrift-job-manager/skrift-job-manager.php @@ -0,0 +1,104 @@ + damit window.XLSX vor ES6-Modulen verfügbar ist + wp_register_script( + 'skrift-xlsx-js', + 'https://cdn.jsdelivr.net/npm/xlsx@0.18.5/dist/xlsx.full.min.js', + [], + '0.18.5', + false + ); + + wp_register_script( + self::SLUG, + $base . 'assets/js/job-app.js', + ['skrift-xlsx-js'], + self::VERSION, + true + ); + } + + public function add_module_attribute(string $tag, string $handle, string $src): string { + if ($handle !== self::SLUG) return $tag; + return ''; + } + + public function render_shortcode($atts = []): string { + wp_enqueue_style('skrift-konfigurator'); + wp_enqueue_style(self::SLUG); + wp_enqueue_script('skrift-xlsx-js'); + wp_enqueue_script(self::SLUG); + + // Konfigurator-Einstellungen lesen (Backend-URL, API-Key, etc.) + if (class_exists('Skrift_Konfigurator_Admin_Settings')) { + $settings = Skrift_Konfigurator_Admin_Settings::get_settings(); + } else { + $settings = get_option('skrift_konfigurator_settings', []); + } + + $frontend_settings = $settings; + unset($frontend_settings['backend_connection']['api_token']); + unset($frontend_settings['api_security']); + unset($frontend_settings['paypal']['client_secret_sandbox']); + unset($frontend_settings['paypal']['client_secret_live']); + + $job_settings = Skrift_Job_Manager::get_plugin_settings(); + + ob_start(); + ?> + +
    + +
    +