Produktions-Modul, überarbeitete Preislogik und WordPress-Preisrechner
Directus - Neues Modul „Produktion": Board mit Neu/Warteschlange/Im Druck/ Kuvertierung/Versandfertig/Versendet/Archiv, zentraler Suche, Druck-Popup (Maschine, Template, Maße, Nummernbereich, Direktdruck vs. Warteschlange) und Empfängerliste mit Vorschau von Schriftstück und Kuvert. - Interface-Plugins: Auftragsdateien, Drucken, Testdruck, Empfängersuche. - Mailer-Hook für Auftrags- und Statusmails inkl. Signatur und AGB-Anhang. - Auftrag: Empfängerzeilen inline, Produktionsstatus (inkl. storniert), Versanddatum, Brief-/Kuvert-Indikatoren, Insights-Dashboard. - Absender aller Mails ist Skrift (Projektname + email_settings); die Angebotsanfrage nennt jetzt das angefragte Produkt. Preislogik - Eine gemeinsame Multiplikator-Formel; Normalpreis-Menge je Produkt (products.norm_qty). Alte B2B/B2C-Fallback-Felder entfernt. - Mengenrabatt-Staffel (ab X Stück Y %) in den Preis-Einstellungen. - Preis pro Stück ohne einmalige Kosten (unit_net_total). - Einzelversand rechnet Kuvert und Beschriftung nicht mehr doppelt. - Fehlerhafte Grundpreis-Formel legt die Preisauskunft nicht mehr lahm. - Briefnummern 1-basiert: Dateiname entspricht der Briefnummer. app.skrift.de - Direktlinks für Muster, Unterschriftenservice und Follow-ups; Muster als eigene Seite, Kontaktprodukte direkt im Anfrageformular. - Mehrere Adressen in den Stammdaten plus Adressauswahl im Konfigurator. - Verbotene Zeichen werden bei der Eingabe entfernt und gemeldet. - Zeilenüberlauf als Fehlerkasten vor „Weiter"; Mengenfeld leerbar, Standardmenge 100; Checkout-Checkboxen im Kartenstil. Produktion - skrift-agent: Poll-Modell je Maschine, Maß-Übersteuerung, Nummernfilter und automatischer Übergang in die Kuvertierung. WordPress - Neues Plugin „Skrift Preisrechner": Shortcodes inkl./exkl. MwSt, stündlicher Abgleich der Preisdaten in WordPress und ein sicherer Formel-Interpreter, damit Formeländerungen automatisch greifen. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
9
Webseite/skrift-app/.dockerignore
Normal file
9
Webseite/skrift-app/.dockerignore
Normal file
@@ -0,0 +1,9 @@
|
||||
node_modules
|
||||
dist
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
.git
|
||||
.gitignore
|
||||
*.log
|
||||
README.md
|
||||
4
Webseite/skrift-app/.env.example
Normal file
4
Webseite/skrift-app/.env.example
Normal file
@@ -0,0 +1,4 @@
|
||||
# Kopieren zu .env.local (wird nicht committet)
|
||||
|
||||
# Directus-Instanz (Daten, Auth, Bestellungen)
|
||||
VITE_DIRECTUS_URL=https://admin.skrift.de
|
||||
30
Webseite/skrift-app/Dockerfile
Normal file
30
Webseite/skrift-app/Dockerfile
Normal file
@@ -0,0 +1,30 @@
|
||||
# Mehrstufig: Node baut die App, nginx liefert nur noch statische Dateien aus.
|
||||
# Im Endimage ist kein Node und kein node_modules mehr enthalten.
|
||||
|
||||
# ── Build ───────────────────────────────────────────────────
|
||||
FROM node:20-alpine AS build
|
||||
WORKDIR /app
|
||||
|
||||
# Die Directus-URL wird zur BUILD-Zeit ins Bundle geschrieben (Vite-Prinzip),
|
||||
# nicht zur Laufzeit. Daher als Build-Argument.
|
||||
ARG VITE_DIRECTUS_URL
|
||||
ENV VITE_DIRECTUS_URL=$VITE_DIRECTUS_URL
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# ── Auslieferung ────────────────────────────────────────────
|
||||
FROM nginx:1.27-alpine
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
# BusyBox-wget (in nginx:alpine) kennt --spider/--tries nicht zuverlässig –
|
||||
# stattdessen die Startseite nach /dev/null laden. Schlägt nur fehl, wenn
|
||||
# nginx wirklich nicht antwortet.
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD wget -q -O /dev/null http://127.0.0.1/ || exit 1
|
||||
29
Webseite/skrift-app/docker-compose.yml
Normal file
29
Webseite/skrift-app/docker-compose.yml
Normal file
@@ -0,0 +1,29 @@
|
||||
# Skrift-App (Konfigurator + Portal) hinter dem Nginx Proxy Manager.
|
||||
#
|
||||
# Im NPM als Proxy Host anlegen:
|
||||
# Domain Names : app.skrift.de
|
||||
# Scheme : http
|
||||
# Forward Hostname : skrift-app
|
||||
# Forward Port : 80
|
||||
# SSL : Let's-Encrypt-Zertifikat aktivieren
|
||||
#
|
||||
# WICHTIG: VITE_DIRECTUS_URL wird beim BUILD ins Bundle geschrieben.
|
||||
# Nach einer Änderung der URL muss neu gebaut werden (nicht nur neu gestartet).
|
||||
|
||||
services:
|
||||
skrift-app:
|
||||
build:
|
||||
context: .
|
||||
args:
|
||||
VITE_DIRECTUS_URL: ${VITE_DIRECTUS_URL:-https://admin.skrift.de}
|
||||
container_name: skrift-app
|
||||
restart: unless-stopped
|
||||
expose:
|
||||
- "80"
|
||||
networks:
|
||||
- npm
|
||||
|
||||
networks:
|
||||
npm:
|
||||
name: nginx-proxy-manager_default
|
||||
external: true
|
||||
23
Webseite/skrift-app/index.html
Normal file
23
Webseite/skrift-app/index.html
Normal file
@@ -0,0 +1,23 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<!-- Konfigurator/Portal sind Anwendung, kein Suchmaschinen-Inhalt. -->
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
<title>Skrift</title>
|
||||
<meta name="description" content="Handschriftliche Briefe und Postkarten – konfigurieren und Aufträge verwalten." />
|
||||
<meta name="theme-color" content="#1E3A6A" />
|
||||
|
||||
<!-- Icons liegen in public/ – siehe public/README.md -->
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||
<link rel="alternate icon" href="/favicon.ico" sizes="32x32" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
<!-- Schriften werden lokal ausgeliefert (siehe public/fonts) – keine externen Anfragen. -->
|
||||
<link rel="preload" href="/fonts/work-sans-variable.woff2" as="font" type="font/woff2" crossorigin />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
42
Webseite/skrift-app/nginx.conf
Normal file
42
Webseite/skrift-app/nginx.conf
Normal file
@@ -0,0 +1,42 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
gzip on;
|
||||
gzip_types text/css application/javascript application/json image/svg+xml;
|
||||
gzip_min_length 1024;
|
||||
|
||||
# Sicherheits-Header (TLS terminiert der Nginx Proxy Manager davor)
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
|
||||
# Gehashte Assets dürfen dauerhaft gecacht werden.
|
||||
location /assets/ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
# Schriften: lokal ausgeliefert, lange cachebar.
|
||||
location /fonts/ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
types {
|
||||
font/woff2 woff2;
|
||||
font/ttf ttf;
|
||||
}
|
||||
}
|
||||
|
||||
# index.html nie cachen – sonst sehen Nutzer nach einem Deploy die alte App.
|
||||
location = /index.html {
|
||||
add_header Cache-Control "no-store, must-revalidate";
|
||||
}
|
||||
|
||||
# SPA-Fallback: /portal, /login usw. existieren nicht als Dateien.
|
||||
# Ohne diese Zeile führt ein Reload auf einer Unterseite zu 404.
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
1884
Webseite/skrift-app/package-lock.json
generated
Normal file
1884
Webseite/skrift-app/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
25
Webseite/skrift-app/package.json
Normal file
25
Webseite/skrift-app/package.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "skrift-app",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"description": "Skrift – Konfigurator und Kundenportal (eine App).",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.26.2",
|
||||
"xlsx": "^0.18.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.5",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"typescript": "^5.5.4",
|
||||
"vite": "^5.4.2"
|
||||
}
|
||||
}
|
||||
31
Webseite/skrift-app/public/README.md
Normal file
31
Webseite/skrift-app/public/README.md
Normal file
@@ -0,0 +1,31 @@
|
||||
# public/ – statische Dateien
|
||||
|
||||
Alles hier wird **unverändert** unter dem Wurzelpfad ausgeliefert:
|
||||
`public/favicon.svg` → `https://app.skrift.de/favicon.svg`.
|
||||
|
||||
Das ist der richtige Ort für Dateien mit **festem Namen** (Icons, robots.txt).
|
||||
Grafiken, die in Komponenten importiert werden, gehören dagegen nach
|
||||
`src/assets/` – die bekommen beim Build einen Hash und sind damit cache-sicher.
|
||||
|
||||
## Hier ablegen
|
||||
|
||||
| Datei | Zweck | Empfehlung |
|
||||
|---|---|---|
|
||||
| `logo.svg` | Logo in der Kopfzeile | SVG, einfarbig in `#1E3A6A`, Höhe ~28 px optisch |
|
||||
| `favicon.svg` | Browser-Tab (moderne Browser) | quadratisch, schlichte Bildmarke – Schriftzug wird bei 16 px unlesbar |
|
||||
| `favicon.ico` | Rückfall für ältere Browser | 32×32 |
|
||||
| `apple-touch-icon.png` | iOS-Startbildschirm | 180×180, ohne Transparenz |
|
||||
| `fonts/` | Schriften (siehe `fonts/README.md`) | – |
|
||||
|
||||
## Verhalten, solange Dateien fehlen
|
||||
|
||||
- **Logo:** Die Kopfzeile zeigt automatisch den Schriftzug „Skrift" als Text.
|
||||
Sobald `logo.svg` existiert, wird stattdessen das Bild angezeigt – kein
|
||||
Codeänderung nötig.
|
||||
- **Favicon:** Der Browser zeigt sein Standard-Icon. Kein Fehler.
|
||||
|
||||
## Icons erzeugen
|
||||
|
||||
Aus einem quadratischen SVG lassen sich die übrigen Formate schnell ableiten,
|
||||
z. B. mit <https://realfavicongenerator.net> – dabei nur die oben genannten
|
||||
Dateien behalten, den Rest braucht es nicht.
|
||||
46
Webseite/skrift-app/public/favicon.svg
Normal file
46
Webseite/skrift-app/public/favicon.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 16 KiB |
41
Webseite/skrift-app/public/fonts/README.md
Normal file
41
Webseite/skrift-app/public/fonts/README.md
Normal file
@@ -0,0 +1,41 @@
|
||||
# Schriften (lokal, DSGVO-konform)
|
||||
|
||||
Work Sans wird **lokal** ausgeliefert. Es darf **keine** Anfrage an
|
||||
`fonts.googleapis.com` oder `fonts.gstatic.com` gehen – sonst fließt die
|
||||
IP-Adresse des Besuchers ohne Einwilligung an Google.
|
||||
|
||||
## Benötigte Datei
|
||||
|
||||
Wir nutzen die **Variable Font** – eine Datei deckt alle Gewichte (100–900) ab.
|
||||
Abgelegt wird sie in diesem Ordner unter einem dieser Namen:
|
||||
|
||||
```
|
||||
work-sans-variable.woff2 ← bevorzugt (klein)
|
||||
WorkSans-VariableFont_wght.ttf ← Rückfall (funktioniert, aber groß)
|
||||
```
|
||||
|
||||
Beide sind in `src/styles/global.css` als `@font-face` hinterlegt; der Browser
|
||||
nimmt automatisch die erste verfügbare.
|
||||
|
||||
## Empfohlen: TTF nach woff2 umwandeln
|
||||
|
||||
Die Original-TTF von Google ist mehrere hundert KB, als woff2 bleiben davon
|
||||
meist unter 100 KB übrig – bei gleicher Qualität. Einmalig:
|
||||
|
||||
```bash
|
||||
pip install fonttools brotli
|
||||
fonttools ttLib.woff2 compress WorkSans-VariableFont_wght.ttf
|
||||
# Ergebnis umbenennen zu: work-sans-variable.woff2
|
||||
```
|
||||
|
||||
Wer kein Python möchte, kann die woff2-Variante auch direkt beziehen:
|
||||
<https://gwfh.mranftl.com/fonts/work-sans> (Charsets `latin` + `latin-ext`).
|
||||
|
||||
Work Sans steht unter der SIL Open Font License 1.1 – freie Nutzung, auch
|
||||
kommerziell.
|
||||
|
||||
## Prüfen
|
||||
|
||||
Nach dem Start in den DevTools unter *Netzwerk* filtern: Es darf **kein**
|
||||
Request an eine Google-Domain auftauchen. Die Schrift kommt von
|
||||
`/fonts/work-sans-*`.
|
||||
BIN
Webseite/skrift-app/public/fonts/WorkSans-VariableFont_wght.ttf
Normal file
BIN
Webseite/skrift-app/public/fonts/WorkSans-VariableFont_wght.ttf
Normal file
Binary file not shown.
11
Webseite/skrift-app/public/logo.svg
Normal file
11
Webseite/skrift-app/public/logo.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 28 KiB |
58
Webseite/skrift-app/src/App.tsx
Normal file
58
Webseite/skrift-app/src/App.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import { Navigate, Route, Routes } from 'react-router-dom';
|
||||
import AppShell from './components/AppShell';
|
||||
import { WizardProvider } from './lib/wizard';
|
||||
import { DialogsProvider } from './lib/dialogs';
|
||||
import Login from './routes/Login';
|
||||
import Auftraege from './routes/Auftraege';
|
||||
import Stammdaten from './routes/Stammdaten';
|
||||
import Configurator from './routes/Configurator';
|
||||
import MusterSeite from './routes/Muster';
|
||||
import { loadSession } from './lib/api';
|
||||
|
||||
/**
|
||||
* Produkt-Direktlinks (Slug → Produktschlüssel): wählen das Produkt vor, der
|
||||
* Konfigurator startet auf Schritt 1. Mehrere freundliche Slugs erlaubt
|
||||
* (z. B. /followups zeigt auf den Schlüssel follow_ups).
|
||||
*/
|
||||
const PRODUKT_PFADE: Record<string, string> = {
|
||||
briefe: 'briefe',
|
||||
postkarten: 'postkarten',
|
||||
unterschriftenservice: 'unterschriftenservice',
|
||||
follow_ups: 'follow_ups',
|
||||
followups: 'follow_ups',
|
||||
'follow-ups': 'follow_ups',
|
||||
};
|
||||
|
||||
function Geschuetzt({ children }: { children: React.ReactNode }) {
|
||||
return loadSession() ? <>{children}</> : <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<DialogsProvider>
|
||||
<WizardProvider>
|
||||
<AppShell>
|
||||
<Routes>
|
||||
<Route path="/" element={<Configurator />} />
|
||||
<Route path="/login" element={<Login />} />
|
||||
{/* Muster als eigene Seite (kein Popup) – Direktlink /muster. */}
|
||||
<Route path="/muster" element={<MusterSeite />} />
|
||||
<Route
|
||||
path="/auftraege"
|
||||
element={<Geschuetzt><Auftraege /></Geschuetzt>}
|
||||
/>
|
||||
<Route
|
||||
path="/stammdaten"
|
||||
element={<Geschuetzt><Stammdaten /></Geschuetzt>}
|
||||
/>
|
||||
{/* Produkt-Direktlinks – Produktwahl entfällt, Personentyp bleibt. */}
|
||||
{Object.entries(PRODUKT_PFADE).map(([slug, key]) => (
|
||||
<Route key={slug} path={`/${slug}`} element={<Configurator vorwahlProdukt={key} />} />
|
||||
))}
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</AppShell>
|
||||
</WizardProvider>
|
||||
</DialogsProvider>
|
||||
);
|
||||
}
|
||||
497
Webseite/skrift-app/src/components/AppShell.tsx
Normal file
497
Webseite/skrift-app/src/components/AppShell.tsx
Normal file
@@ -0,0 +1,497 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link, NavLink, useLocation, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
ApiError,
|
||||
fetchMyCustomer,
|
||||
fetchProducts,
|
||||
fetchStatusEvents,
|
||||
getSeenAt,
|
||||
loadSession,
|
||||
logout,
|
||||
markNotificationsSeen,
|
||||
sendSampleRequest,
|
||||
type Product,
|
||||
type StatusEvent,
|
||||
} from '../lib/api';
|
||||
import { STATUS_LABEL } from '../lib/status';
|
||||
import { useDialogs } from '../lib/dialogs';
|
||||
import { useWizard } from '../lib/wizard';
|
||||
import OfferForm from './OfferForm';
|
||||
import {
|
||||
IconBell,
|
||||
IconCheck,
|
||||
IconChevronDown,
|
||||
IconClose,
|
||||
IconHelp,
|
||||
IconList,
|
||||
IconMail,
|
||||
IconMenu,
|
||||
IconOffer,
|
||||
IconPen,
|
||||
IconPhone,
|
||||
IconUser,
|
||||
} from './Icons';
|
||||
|
||||
const KONTAKT = { mail: 'hello@skrift.de', tel: '+49 6872 2153' };
|
||||
/** Routen, unter denen der Konfigurator läuft (Start + Direktlinks). */
|
||||
const KONFIGURATOR_PFADE = ['/', '/briefe', '/postkarten', '/unterschriftenservice', '/follow_ups', '/followups', '/follow-ups'];
|
||||
const LAENDER = ['Deutschland', 'Österreich', 'Schweiz', 'Luxemburg', 'Belgien', 'Niederlande', 'Frankreich'];
|
||||
const istEmail = (e: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(e.trim());
|
||||
|
||||
function Wordmark() {
|
||||
const [logoFehlt, setLogoFehlt] = useState(false);
|
||||
return (
|
||||
<Link to="/" className="sk-wordmark" aria-label="Skrift – Startseite">
|
||||
{logoFehlt ? 'Skrift' : <img src="/logo.svg" alt="Skrift" className="sk-logo" onError={() => setLogoFehlt(true)} />}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
/** Konto-Dropdown in der Kopfleiste: Name/Rolle + Abmelden (bzw. Anmelden). */
|
||||
function UserMenu() {
|
||||
const navigate = useNavigate();
|
||||
const angemeldet = !!loadSession();
|
||||
const [offen, setOffen] = useState(false);
|
||||
const [kunde, setKunde] = useState<{ name: string; email: string; rolle: string } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!angemeldet) return;
|
||||
fetchMyCustomer()
|
||||
.then((c) => {
|
||||
if (!c) return;
|
||||
setKunde({
|
||||
name: [c.first_name, c.last_name].filter(Boolean).join(' ') || c.email,
|
||||
email: c.email,
|
||||
rolle: c.person_type === 'unternehmen' ? 'Unternehmen' : 'Privatkunde',
|
||||
});
|
||||
})
|
||||
.catch(() => { /* Anzeige ist unkritisch */ });
|
||||
}, [angemeldet]);
|
||||
|
||||
if (!angemeldet) {
|
||||
return (
|
||||
<Link to="/login" className="sk-btn sk-btn--ghost">
|
||||
Anmelden
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ position: 'relative' }}>
|
||||
<button className="sk-user" onClick={() => setOffen((v) => !v)} aria-expanded={offen}>
|
||||
<span className="sk-user-avatar"><IconUser size={16} /></span>
|
||||
<span className="sk-user-name">{kunde?.name ?? 'Konto'}</span>
|
||||
<IconChevronDown size={16} className="sk-user-caret" />
|
||||
</button>
|
||||
|
||||
{offen && (
|
||||
<>
|
||||
<div className="sk-menu-backdrop" onClick={() => setOffen(false)} />
|
||||
<div className="sk-popover sk-popover--menu">
|
||||
<div className="sk-menu-head">
|
||||
<div className="sk-menu-name">{kunde?.name}</div>
|
||||
<div className="sk-menu-sub">{kunde?.email}</div>
|
||||
<div className="sk-menu-sub">{kunde?.rolle}</div>
|
||||
</div>
|
||||
<button
|
||||
className="sk-menu-item"
|
||||
onClick={() => { logout(); setOffen(false); navigate('/login', { replace: true }); }}
|
||||
>
|
||||
Abmelden
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Schlichtes Overlay für Hilfe und Angebotsanfrage. */
|
||||
function Dialog({ title, onClose, children }: { title: string; onClose: () => void; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="sk-overlay" role="dialog" aria-modal="true" aria-label={title} onClick={onClose}>
|
||||
<div className="sk-dialog" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="sk-dialog-head">
|
||||
<h2>{title}</h2>
|
||||
<button className="sk-icon-btn" onClick={onClose} aria-label="Schließen">
|
||||
<IconClose />
|
||||
</button>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Hilfe({ onClose }: { onClose: () => void }) {
|
||||
return (
|
||||
<Dialog title="Hilfe & Kontakt" onClose={onClose}>
|
||||
<p className="sk-note">Sie erreichen uns direkt – wir melden uns werktags am selben Tag.</p>
|
||||
<div className="sk-contact">
|
||||
<a href={`mailto:${KONTAKT.mail}`} className="sk-contact-row">
|
||||
<IconMail />
|
||||
<span>{KONTAKT.mail}</span>
|
||||
</a>
|
||||
<a href={`tel:${KONTAKT.tel.replace(/\s/g, '')}`} className="sk-contact-row">
|
||||
<IconPhone />
|
||||
<span>{KONTAKT.tel}</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="sk-legal-links">
|
||||
<a href="https://skrift.de/agb" target="_blank" rel="noreferrer">AGB</a>
|
||||
<a href="https://skrift.de/datenschutz" target="_blank" rel="noreferrer">Datenschutz</a>
|
||||
<a href="https://skrift.de/impressum" target="_blank" rel="noreferrer">Impressum</a>
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function Angebot({ onClose, vorbelegung }: { onClose: () => void; vorbelegung?: string }) {
|
||||
// vorbelegung = gewähltes Kontakt-Produkt; wird separat übermittelt, nicht im Text.
|
||||
return (
|
||||
<Dialog title="Individuelles Angebot anfordern" onClose={onClose}>
|
||||
<OfferForm produkt={vorbelegung} onClose={onClose} />
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
/** Muster bestellen – kostenlos, mit Produktwahl und Lieferadresse.
|
||||
* variant='page' rendert als eigene Seite (Direktlink /muster) statt als Popup. */
|
||||
export function Muster({ onClose = () => {}, variant = 'dialog' }: { onClose?: () => void; variant?: 'dialog' | 'page' }) {
|
||||
const [produkte, setProdukte] = useState<Product[]>([]);
|
||||
const [form, setForm] = useState({
|
||||
product: '', name: '', email: '', phone: '',
|
||||
street: '', zip: '', city: '', country: 'Deutschland',
|
||||
});
|
||||
const [datenschutz, setDatenschutz] = useState(false);
|
||||
const [status, setStatus] = useState<'formular' | 'sendet' | 'fertig'>('formular');
|
||||
const [fehler, setFehler] = useState<string[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchProducts()
|
||||
.then((ps) => setProdukte(ps.filter((p) => p.type === 'letter' || p.type === 'postcard')))
|
||||
.catch(() => setProdukte([]));
|
||||
if (loadSession()) {
|
||||
fetchMyCustomer()
|
||||
.then((c) => c && setForm((f) => ({
|
||||
...f,
|
||||
name: [c.first_name, c.last_name].filter(Boolean).join(' ') || f.name,
|
||||
email: c.email || f.email,
|
||||
phone: c.phone || f.phone,
|
||||
})))
|
||||
.catch(() => { /* optional */ });
|
||||
}
|
||||
}, []);
|
||||
|
||||
const set = (k: keyof typeof form) => (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
|
||||
const roh = e.target.value;
|
||||
setForm({ ...form, [k]: k === 'phone' ? roh.replace(/[^0-9+ ]/g, '') : k === 'zip' ? roh.replace(/[^0-9]/g, '') : roh });
|
||||
};
|
||||
|
||||
async function absenden(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!istEmail(form.email)) { setFehler(['Bitte geben Sie eine gültige E-Mail-Adresse an.']); return; }
|
||||
setFehler([]);
|
||||
setStatus('sendet');
|
||||
try {
|
||||
await sendSampleRequest({
|
||||
product: form.product,
|
||||
name: form.name.trim(),
|
||||
email: form.email.trim(),
|
||||
phone: form.phone.trim() || undefined,
|
||||
street: form.street.trim(),
|
||||
zip: form.zip.trim(),
|
||||
city: form.city.trim(),
|
||||
country: form.country,
|
||||
});
|
||||
setStatus('fertig');
|
||||
} catch (err) {
|
||||
setFehler(err instanceof ApiError ? (err.details.length ? err.details : [err.message]) : ['Anfrage fehlgeschlagen.']);
|
||||
setStatus('formular');
|
||||
}
|
||||
}
|
||||
|
||||
// Rahmen je nach Modus: Popup (Dialog) oder eigene Seite (sk-view).
|
||||
const wrap = (titel: string, inhalt: React.ReactNode) =>
|
||||
variant === 'page'
|
||||
? <div className="sk-view"><h1>{titel}</h1>{inhalt}</div>
|
||||
: <Dialog title={titel} onClose={onClose}>{inhalt}</Dialog>;
|
||||
|
||||
if (status === 'fertig') {
|
||||
return wrap('Muster ist unterwegs', (
|
||||
<>
|
||||
<p>Vielen Dank – Ihr kostenloses Muster wird vorbereitet und verschickt.</p>
|
||||
<button className="sk-btn sk-btn--primary" style={{ marginTop: 'var(--sk-space-4)' }} onClick={onClose}>
|
||||
{variant === 'page' ? 'Zur Startseite' : 'Schließen'}
|
||||
</button>
|
||||
</>
|
||||
));
|
||||
}
|
||||
|
||||
const feld = (k: keyof typeof form, label: string, opt = false, type = 'text') => (
|
||||
<div className="sk-field">
|
||||
<label className={`sk-label${opt ? '' : ' sk-label--required'}`} htmlFor={`m-${k}`}>
|
||||
{label} {opt && <span className="sk-note" style={{ fontWeight: 400 }}>(optional)</span>}
|
||||
</label>
|
||||
<input id={`m-${k}`} className="sk-input" type={type} required={!opt}
|
||||
value={form[k]} onChange={set(k)} />
|
||||
</div>
|
||||
);
|
||||
|
||||
const gueltig = form.product && form.name.trim() && istEmail(form.email) && form.phone.trim()
|
||||
&& form.street.trim() && form.zip.trim() && form.city.trim() && datenschutz;
|
||||
|
||||
return wrap('Muster bestellen', (
|
||||
<>
|
||||
<p className="sk-note">Wir schicken Ihnen kostenlos ein Handschrift-Muster zum Anfassen.</p>
|
||||
<form style={{ marginTop: 'var(--sk-space-4)' }} onSubmit={absenden}>
|
||||
<div className="sk-field">
|
||||
<label className="sk-label sk-label--required" htmlFor="m-product">
|
||||
Von welchem Produkt möchten Sie ein Muster?
|
||||
</label>
|
||||
<select id="m-product" className="sk-input" required value={form.product} onChange={set('product')}>
|
||||
<option value="" disabled>Bitte wählen …</option>
|
||||
{produkte.map((p) => <option key={p.key} value={p.key}>{p.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
{feld('name', 'Name')}
|
||||
{feld('email', 'E-Mail-Adresse', false, 'email')}
|
||||
{feld('phone', 'Telefon', false, 'tel')}
|
||||
{feld('street', 'Straße und Hausnummer')}
|
||||
{feld('zip', 'PLZ')}
|
||||
{feld('city', 'Stadt')}
|
||||
<div className="sk-field">
|
||||
<label className="sk-label sk-label--required" htmlFor="m-country">Land</label>
|
||||
<select id="m-country" className="sk-input" value={form.country} onChange={set('country')}>
|
||||
{LAENDER.map((l) => <option key={l} value={l}>{l}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<label className="sk-check" style={{ marginBottom: 'var(--sk-space-4)' }}>
|
||||
<input type="checkbox" checked={datenschutz} onChange={(e) => setDatenschutz(e.target.checked)} />
|
||||
<span>
|
||||
Ich habe die{' '}
|
||||
<a href="https://skrift.de/datenschutz" target="_blank" rel="noreferrer">Datenschutzerklärung</a>{' '}
|
||||
zur Kenntnis genommen und bin mit der Verarbeitung meiner Daten zur Bearbeitung meiner
|
||||
Anfrage einverstanden.
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{fehler.length > 0 && (
|
||||
<ul className="sk-error" style={{ margin: '0 0 var(--sk-space-3)', paddingLeft: '1.1em' }}>
|
||||
{fehler.map((f) => <li key={f}>{f}</li>)}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<button className="sk-btn sk-btn--primary sk-btn--block" disabled={status === 'sendet' || !gueltig}>
|
||||
{status === 'sendet' ? 'Wird gesendet …' : 'Muster anfordern'}
|
||||
</button>
|
||||
</form>
|
||||
</>
|
||||
));
|
||||
}
|
||||
|
||||
function Sidebar({ onNavigate }: { onNavigate?: () => void }) {
|
||||
const { steps, current, goto } = useWizard();
|
||||
const location = useLocation();
|
||||
const { open } = useDialogs();
|
||||
const angemeldet = !!loadSession();
|
||||
|
||||
// Ablauf nur im Konfigurator zeigen (Start + Direktlinks /briefe, /postkarten) –
|
||||
// nicht in Aufträgen/Stammdaten.
|
||||
const zeigeAblauf = KONFIGURATOR_PFADE.includes(location.pathname) && steps.length > 0;
|
||||
|
||||
const item = ({ isActive }: { isActive: boolean }) => `sk-nav-item${isActive ? ' sk-nav-item--active' : ''}`;
|
||||
|
||||
return (
|
||||
<aside className="sk-sidebar">
|
||||
<nav className="sk-nav">
|
||||
<NavLink to="/" className={item} end onClick={onNavigate}>
|
||||
<IconPen />
|
||||
<span>Neuer Auftrag</span>
|
||||
</NavLink>
|
||||
<NavLink to="/auftraege" className={item} onClick={onNavigate}>
|
||||
<IconList />
|
||||
<span>Aufträge</span>
|
||||
</NavLink>
|
||||
<NavLink to="/stammdaten" className={item} onClick={onNavigate}>
|
||||
<IconUser />
|
||||
<span>Stammdaten</span>
|
||||
</NavLink>
|
||||
</nav>
|
||||
|
||||
{zeigeAblauf && (
|
||||
<div className="sk-nav-section sk-nav-section--divider">
|
||||
<div className="sk-nav-label">Ablauf</div>
|
||||
<ol className="sk-process">
|
||||
{steps.map((label, i) => {
|
||||
const erledigt = i < current;
|
||||
const klasse = `sk-process-item${
|
||||
i === current ? ' sk-process-item--current' : erledigt ? ' sk-process-item--done' : ''
|
||||
}`;
|
||||
const inhalt = (
|
||||
<>
|
||||
<span className="sk-process-mark">{erledigt ? <IconCheck size={14} /> : i + 1}</span>
|
||||
<span>{label}</span>
|
||||
</>
|
||||
);
|
||||
// Erledigte Schritte sind anklickbar (Rücksprung); der aktuelle und
|
||||
// kommende Schritte bleiben passiv.
|
||||
return (
|
||||
<li key={label}>
|
||||
{erledigt ? (
|
||||
<button
|
||||
type="button"
|
||||
className={`${klasse} sk-process-item--clickable`}
|
||||
onClick={() => { goto(i); onNavigate?.(); }}
|
||||
title="Zu diesem Schritt zurückspringen"
|
||||
>
|
||||
{inhalt}
|
||||
</button>
|
||||
) : (
|
||||
<div className={klasse}>{inhalt}</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!angemeldet && (
|
||||
<p className="sk-nav-hint">
|
||||
Melden Sie sich an, um Aufträge und Stammdaten zu sehen.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="sk-sidebar-foot sk-nav-section--divider">
|
||||
<NavLink to="/muster" className="sk-nav-item sk-nav-item--quiet" onClick={onNavigate}>
|
||||
<IconMail />
|
||||
<span>Muster bestellen</span>
|
||||
</NavLink>
|
||||
<button className="sk-nav-item sk-nav-item--quiet" onClick={() => { open('angebot'); onNavigate?.(); }}>
|
||||
<IconOffer />
|
||||
<span>Individuelles Angebot</span>
|
||||
</button>
|
||||
<button className="sk-nav-item sk-nav-item--quiet" onClick={() => { open('hilfe'); onNavigate?.(); }}>
|
||||
<IconHelp />
|
||||
<span>Hilfe</span>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Benachrichtigungen speisen sich aus dem Statusverlauf der eigenen Aufträge –
|
||||
* dafür braucht es keine eigene Collection. Als „gelesen" gilt alles, was älter
|
||||
* ist als der zuletzt geöffnete Zeitpunkt (lokal gespeichert).
|
||||
*/
|
||||
function Notifications() {
|
||||
const [events, setEvents] = useState<StatusEvent[]>([]);
|
||||
const [offen, setOffen] = useState(false);
|
||||
const [seenAt, setSeenAt] = useState(getSeenAt);
|
||||
|
||||
useEffect(() => {
|
||||
fetchStatusEvents()
|
||||
.then(setEvents)
|
||||
.catch(() => {
|
||||
/* Benachrichtigungen sind nicht kritisch */
|
||||
});
|
||||
}, []);
|
||||
|
||||
const ungelesen = events.filter((e) => new Date(e.timestamp).getTime() > seenAt).length;
|
||||
|
||||
function umschalten() {
|
||||
const next = !offen;
|
||||
setOffen(next);
|
||||
if (next && ungelesen > 0) {
|
||||
markNotificationsSeen();
|
||||
setSeenAt(Date.now());
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
className="sk-icon-btn"
|
||||
aria-label={ungelesen ? `${ungelesen} neue Benachrichtigungen` : 'Benachrichtigungen'}
|
||||
onClick={umschalten}
|
||||
>
|
||||
<IconBell />
|
||||
{ungelesen > 0 && <span className="sk-dot" aria-hidden="true" />}
|
||||
</button>
|
||||
|
||||
{offen && (
|
||||
<div className="sk-popover">
|
||||
{events.length === 0 ? (
|
||||
<p className="sk-note">Keine Benachrichtigungen.</p>
|
||||
) : (
|
||||
<ul className="sk-notif-list">
|
||||
{events.slice(0, 5).map((e) => (
|
||||
<li key={e.id} className="sk-notif">
|
||||
<div className="sk-notif-title">
|
||||
{e.order?.order_number ?? 'Auftrag'} · {STATUS_LABEL[e.status] ?? e.status}
|
||||
</div>
|
||||
{e.note && <div className="sk-notif-note">{e.note}</div>}
|
||||
<div className="sk-notif-time">
|
||||
{new Intl.DateTimeFormat('de-DE', { dateStyle: 'short', timeStyle: 'short' }).format(
|
||||
new Date(e.timestamp),
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Topbar({ onMenu }: { onMenu: () => void }) {
|
||||
const angemeldet = !!loadSession();
|
||||
return (
|
||||
<header className="sk-topbar">
|
||||
<div className="sk-topbar-left">
|
||||
{/* Menü-Umschalter nur auf schmalen Geräten sichtbar (CSS). */}
|
||||
<button className="sk-icon-btn sk-menu-toggle" aria-label="Menü öffnen" onClick={onMenu}>
|
||||
<IconMenu />
|
||||
</button>
|
||||
<Wordmark />
|
||||
</div>
|
||||
|
||||
<div className="sk-topbar-right">
|
||||
{angemeldet && <Notifications />}
|
||||
<UserMenu />
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AppShell({ children }: { children: React.ReactNode }) {
|
||||
const [drawerOffen, setDrawerOffen] = useState(false);
|
||||
const location = useLocation();
|
||||
const { dialog, vorbelegung, close } = useDialogs();
|
||||
|
||||
// Beim Seitenwechsel das mobile Menü schließen.
|
||||
useEffect(() => { setDrawerOffen(false); }, [location.pathname]);
|
||||
|
||||
return (
|
||||
<div className="sk-app">
|
||||
<Topbar onMenu={() => setDrawerOffen(true)} />
|
||||
|
||||
<div className={`sk-sidebar-wrap${drawerOffen ? ' sk-sidebar-wrap--open' : ''}`}>
|
||||
<Sidebar onNavigate={() => setDrawerOffen(false)} />
|
||||
</div>
|
||||
{drawerOffen && <div className="sk-backdrop" onClick={() => setDrawerOffen(false)} />}
|
||||
|
||||
<main className="sk-content">{children}</main>
|
||||
|
||||
{/* Dialoge zentral – von Seitenleiste und Konfigurator aus geöffnet. */}
|
||||
{dialog === 'hilfe' && <Hilfe onClose={close} />}
|
||||
{dialog === 'angebot' && <Angebot onClose={close} vorbelegung={vorbelegung} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
111
Webseite/skrift-app/src/components/Icons.tsx
Normal file
111
Webseite/skrift-app/src/components/Icons.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* Single-Line-Icons als Inline-SVG.
|
||||
*
|
||||
* Bewusst ohne Icon-Bibliothek: keine externe Anfrage (DSGVO), kein zusätzliches
|
||||
* Bundle, und eine einheitliche Strichstärke über alle Symbole.
|
||||
*/
|
||||
|
||||
type Props = { size?: number; className?: string };
|
||||
|
||||
const base = (size: number) => ({
|
||||
width: size,
|
||||
height: size,
|
||||
viewBox: '0 0 24 24',
|
||||
fill: 'none',
|
||||
stroke: 'currentColor',
|
||||
strokeWidth: 1.5,
|
||||
strokeLinecap: 'round' as const,
|
||||
strokeLinejoin: 'round' as const,
|
||||
'aria-hidden': true,
|
||||
focusable: false,
|
||||
});
|
||||
|
||||
export const IconPen = ({ size = 18, className }: Props) => (
|
||||
<svg {...base(size)} className={className}>
|
||||
<path d="M12 20h9" />
|
||||
<path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const IconList = ({ size = 18, className }: Props) => (
|
||||
<svg {...base(size)} className={className}>
|
||||
<path d="M8 6h13M8 12h13M8 18h13" />
|
||||
<path d="M3 6h.01M3 12h.01M3 18h.01" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const IconUser = ({ size = 18, className }: Props) => (
|
||||
<svg {...base(size)} className={className}>
|
||||
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
|
||||
<circle cx="12" cy="7" r="4" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const IconBell = ({ size = 18, className }: Props) => (
|
||||
<svg {...base(size)} className={className}>
|
||||
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9" />
|
||||
<path d="M13.7 21a2 2 0 0 1-3.4 0" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const IconHelp = ({ size = 18, className }: Props) => (
|
||||
<svg {...base(size)} className={className}>
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<path d="M9.1 9a3 3 0 0 1 5.8 1c0 2-3 3-3 3" />
|
||||
<path d="M12 17h.01" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const IconOffer = ({ size = 18, className }: Props) => (
|
||||
<svg {...base(size)} className={className}>
|
||||
<path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8Z" />
|
||||
<path d="M14 3v5h5" />
|
||||
<path d="M12 11v6M9 14h6" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const IconCheck = ({ size = 18, className }: Props) => (
|
||||
<svg {...base(size)} className={className}>
|
||||
<path d="M20 6 9 17l-5-5" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const IconMail = ({ size = 18, className }: Props) => (
|
||||
<svg {...base(size)} className={className}>
|
||||
<rect x="3" y="5" width="18" height="14" rx="2" />
|
||||
<path d="m3 7 9 6 9-6" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const IconPhone = ({ size = 18, className }: Props) => (
|
||||
<svg {...base(size)} className={className}>
|
||||
<path d="M22 16.9v3a2 2 0 0 1-2.2 2 19.8 19.8 0 0 1-8.6-3.1 19.5 19.5 0 0 1-6-6A19.8 19.8 0 0 1 2 4.2 2 2 0 0 1 4 2h3a2 2 0 0 1 2 1.7c.1.9.4 1.8.7 2.6a2 2 0 0 1-.5 2.1L8.1 9.6a16 16 0 0 0 6 6l1.2-1.1a2 2 0 0 1 2.1-.5c.8.3 1.7.6 2.6.7a2 2 0 0 1 1.7 2Z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const IconClose = ({ size = 18, className }: Props) => (
|
||||
<svg {...base(size)} className={className}>
|
||||
<path d="M18 6 6 18M6 6l12 12" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const IconBuilding = ({ size = 18, className }: Props) => (
|
||||
<svg {...base(size)} className={className}>
|
||||
<path d="M3 21h18" />
|
||||
<path d="M5 21V5a2 2 0 0 1 2-2h6a2 2 0 0 1 2 2v16" />
|
||||
<path d="M19 21V9a2 2 0 0 0-2-2h-2" />
|
||||
<path d="M9 7h2M9 11h2M9 15h2" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const IconMenu = ({ size = 18, className }: Props) => (
|
||||
<svg {...base(size)} className={className}>
|
||||
<path d="M3 6h18M3 12h18M3 18h18" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const IconChevronDown = ({ size = 18, className }: Props) => (
|
||||
<svg {...base(size)} className={className}>
|
||||
<path d="m6 9 6 6 6-6" />
|
||||
</svg>
|
||||
);
|
||||
135
Webseite/skrift-app/src/components/OfferForm.tsx
Normal file
135
Webseite/skrift-app/src/components/OfferForm.tsx
Normal file
@@ -0,0 +1,135 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { ApiError, fetchMyCustomer, loadSession, sendOfferRequest } from '../lib/api';
|
||||
|
||||
const istEmail = (e: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(e.trim());
|
||||
|
||||
/**
|
||||
* Anfrageformular „Individuelles Angebot".
|
||||
*
|
||||
* Wird an zwei Stellen genutzt: im Konfigurator als eigene Seite (nach der Wahl
|
||||
* eines Anfrageprodukts) und in der Seitenleiste als Dialog. Deshalb rendert es
|
||||
* nur den Inhalt – die Umrahmung (Karte bzw. Overlay) kommt von außen.
|
||||
*
|
||||
* `onClose` blendet im Dialog den Schließen-Button ein; inline (im Konfigurator)
|
||||
* bleibt er weg.
|
||||
*/
|
||||
export default function OfferForm({ produkt, onClose }: { produkt?: string; onClose?: () => void }) {
|
||||
const gewaehltesProdukt = produkt?.trim() || '';
|
||||
const [form, setForm] = useState({ name: '', email: '', phone: '', quantity: '', message: '' });
|
||||
const [datenschutz, setDatenschutz] = useState(false);
|
||||
const [status, setStatus] = useState<'formular' | 'sendet' | 'fertig'>('formular');
|
||||
const [fehler, setFehler] = useState<string[]>([]);
|
||||
|
||||
// Bei angemeldeten Kunden Name und E-Mail vorausfüllen.
|
||||
useEffect(() => {
|
||||
if (!loadSession()) return;
|
||||
fetchMyCustomer()
|
||||
.then((c) => {
|
||||
if (!c) return;
|
||||
setForm((f) => ({
|
||||
...f,
|
||||
name: [c.first_name, c.last_name].filter(Boolean).join(' ') || f.name,
|
||||
email: c.email || f.email,
|
||||
phone: c.phone || f.phone,
|
||||
}));
|
||||
})
|
||||
.catch(() => { /* Vorausfüllen ist optional */ });
|
||||
}, []);
|
||||
|
||||
const set = (k: keyof typeof form) => (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
const roh = e.target.value;
|
||||
setForm({ ...form, [k]: k === 'phone' ? roh.replace(/[^0-9+ ]/g, '') : roh });
|
||||
};
|
||||
|
||||
async function absenden(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!istEmail(form.email)) { setFehler(['Bitte geben Sie eine gültige E-Mail-Adresse an.']); return; }
|
||||
if (!form.phone.trim()) { setFehler(['Bitte geben Sie eine Telefonnummer an.']); return; }
|
||||
setFehler([]);
|
||||
setStatus('sendet');
|
||||
try {
|
||||
await sendOfferRequest({
|
||||
name: form.name.trim(),
|
||||
email: form.email.trim(),
|
||||
phone: form.phone.trim() || undefined,
|
||||
quantity: form.quantity ? Number(form.quantity) : null,
|
||||
message: form.message.trim(),
|
||||
product: gewaehltesProdukt || undefined,
|
||||
});
|
||||
setStatus('fertig');
|
||||
} catch (err) {
|
||||
setFehler(err instanceof ApiError ? (err.details.length ? err.details : [err.message]) : ['Anfrage konnte nicht gesendet werden.']);
|
||||
setStatus('formular');
|
||||
}
|
||||
}
|
||||
|
||||
if (status === 'fertig') {
|
||||
return (
|
||||
<>
|
||||
<p>Vielen Dank – wir melden uns werktags innerhalb eines Tages mit einem Angebot bei Ihnen.</p>
|
||||
{onClose && (
|
||||
<button className="sk-btn sk-btn--primary" style={{ marginTop: 'var(--sk-space-4)' }} onClick={onClose}>
|
||||
Schließen
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<p className="sk-note">
|
||||
Größere Mengen, Sonderformate, Unterschriftenservice oder automatisierte Follow-ups – schildern Sie
|
||||
kurz Ihr Vorhaben.
|
||||
</p>
|
||||
{gewaehltesProdukt && (
|
||||
<div className="sk-menu-head" style={{ borderRadius: 'var(--sk-radius)', border: '1px solid var(--sk-border)', marginTop: 'var(--sk-space-3)' }}>
|
||||
<div className="sk-menu-sub">Ihre Anfrage betrifft</div>
|
||||
<div className="sk-menu-name">{gewaehltesProdukt}</div>
|
||||
</div>
|
||||
)}
|
||||
<form style={{ marginTop: 'var(--sk-space-4)' }} onSubmit={absenden}>
|
||||
<div className="sk-field">
|
||||
<label className="sk-label" htmlFor="a-name">Name</label>
|
||||
<input id="a-name" className="sk-input" value={form.name} onChange={set('name')} required />
|
||||
</div>
|
||||
<div className="sk-field">
|
||||
<label className="sk-label" htmlFor="a-mail">E-Mail</label>
|
||||
<input id="a-mail" className="sk-input" type="email" value={form.email} onChange={set('email')} required />
|
||||
</div>
|
||||
<div className="sk-field">
|
||||
<label className="sk-label sk-label--required" htmlFor="a-tel">Telefon</label>
|
||||
<input id="a-tel" className="sk-input" type="tel" value={form.phone} onChange={set('phone')} required />
|
||||
</div>
|
||||
<div className="sk-field">
|
||||
<label className="sk-label" htmlFor="a-menge">Ungefähre Menge <span className="sk-note">(optional)</span></label>
|
||||
<input id="a-menge" className="sk-input" type="number" min={1} value={form.quantity} onChange={set('quantity')} />
|
||||
</div>
|
||||
<div className="sk-field">
|
||||
<label className="sk-label" htmlFor="a-text">Ihr Vorhaben</label>
|
||||
<textarea id="a-text" className="sk-input" rows={4} value={form.message} onChange={set('message')} required />
|
||||
</div>
|
||||
|
||||
<label className="sk-check" style={{ marginBottom: 'var(--sk-space-4)' }}>
|
||||
<input type="checkbox" checked={datenschutz} onChange={(e) => setDatenschutz(e.target.checked)} required />
|
||||
<span>
|
||||
Ich habe die{' '}
|
||||
<a href="https://skrift.de/datenschutz" target="_blank" rel="noreferrer">Datenschutzerklärung</a>{' '}
|
||||
zur Kenntnis genommen und bin mit der Verarbeitung meiner Daten zur Bearbeitung meiner
|
||||
Anfrage einverstanden.
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{fehler.length > 0 && (
|
||||
<ul className="sk-error" style={{ margin: '0 0 var(--sk-space-3)', paddingLeft: '1.1em' }}>
|
||||
{fehler.map((f) => <li key={f}>{f}</li>)}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<button className="sk-btn sk-btn--primary sk-btn--block" disabled={status === 'sendet' || !datenschutz || !form.phone.trim()}>
|
||||
{status === 'sendet' ? 'Wird gesendet …' : 'Anfrage senden'}
|
||||
</button>
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
}
|
||||
164
Webseite/skrift-app/src/components/PreviewPanel.tsx
Normal file
164
Webseite/skrift-app/src/components/PreviewPanel.tsx
Normal file
@@ -0,0 +1,164 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
/**
|
||||
* Live-Vorschau neben dem Formular.
|
||||
*
|
||||
* Die Vorschau ist absichtlich kontingentiert: Jede Generierung kostet einen
|
||||
* Aufruf der Scriptalizer-API (Tageslimit). Der Zähler ist daher Teil der
|
||||
* Bedienung und kein Beiwerk.
|
||||
*
|
||||
* Schriftstück und zugehöriger Umschlag gehören zusammen und werden gemeinsam
|
||||
* gezeigt (Schriftstück oben, Umschlag unten). Die Pfeile blättern je Empfänger.
|
||||
*/
|
||||
|
||||
export type PreviewDoc = { art: 'brief' | 'umschlag'; svg: string };
|
||||
|
||||
/** Ein Empfänger-Dokument: Schriftstück + optional zugehöriger Umschlag. */
|
||||
type Gruppe = { brief?: PreviewDoc; umschlag?: PreviewDoc };
|
||||
|
||||
type Props = {
|
||||
titel: string;
|
||||
format: string;
|
||||
docs: PreviewDoc[];
|
||||
gesamtDokumente: number;
|
||||
verbleibend: number;
|
||||
maximal: number;
|
||||
laedt: boolean;
|
||||
fehler: string | null;
|
||||
onGenerate: () => void;
|
||||
bereit: boolean;
|
||||
};
|
||||
|
||||
/** Flache [brief, umschlag, brief, …]-Liste zu Empfänger-Gruppen bündeln. */
|
||||
function gruppiere(docs: PreviewDoc[]): Gruppe[] {
|
||||
const gruppen: Gruppe[] = [];
|
||||
for (const d of docs) {
|
||||
if (d.art === 'umschlag') {
|
||||
const letzte = gruppen[gruppen.length - 1];
|
||||
if (letzte && letzte.brief && !letzte.umschlag) letzte.umschlag = d;
|
||||
else gruppen.push({ umschlag: d });
|
||||
} else {
|
||||
gruppen.push({ brief: d });
|
||||
}
|
||||
}
|
||||
return gruppen;
|
||||
}
|
||||
|
||||
export default function PreviewPanel({
|
||||
titel, format, docs, gesamtDokumente, verbleibend, maximal, laedt, fehler, onGenerate, bereit,
|
||||
}: Props) {
|
||||
const [index, setIndex] = useState(0);
|
||||
const [zoomSvg, setZoomSvg] = useState<string | null>(null);
|
||||
// Die Zoom-Fläche wird auf den Inhaltsbereich begrenzt (unterhalb der
|
||||
// Preiszeile, rechts der Seitenleiste), statt das ganze Fenster zu belegen.
|
||||
const [zoomBox, setZoomBox] = useState<React.CSSProperties>({});
|
||||
|
||||
const gruppen = useMemo(() => gruppiere(docs), [docs]);
|
||||
|
||||
function messeBox(): React.CSSProperties {
|
||||
if (typeof window === 'undefined' || window.innerWidth <= 900) return {};
|
||||
const content = document.querySelector('.sk-content');
|
||||
if (!content) return {};
|
||||
const c = content.getBoundingClientRect();
|
||||
const bar = document.querySelector('.sk-pricebar');
|
||||
const top = Math.max(c.top, bar ? bar.getBoundingClientRect().bottom : c.top);
|
||||
return {
|
||||
top: `${top}px`, left: `${c.left}px`, width: `${c.width}px`,
|
||||
height: `${Math.max(0, c.bottom - top)}px`, right: 'auto', bottom: 'auto',
|
||||
};
|
||||
}
|
||||
function openZoom(svg: string) { setZoomBox(messeBox()); setZoomSvg(svg); }
|
||||
|
||||
// Nach einer neuen Generierung wieder vorn beginnen.
|
||||
useEffect(() => { setIndex(0); }, [docs]);
|
||||
|
||||
// Vergrößerte Ansicht: Escape schließt, Resize misst die Fläche neu.
|
||||
useEffect(() => {
|
||||
if (!zoomSvg) return;
|
||||
const zu = (e: KeyboardEvent) => e.key === 'Escape' && setZoomSvg(null);
|
||||
const neu = () => setZoomBox(messeBox());
|
||||
window.addEventListener('keydown', zu);
|
||||
window.addEventListener('resize', neu);
|
||||
return () => { window.removeEventListener('keydown', zu); window.removeEventListener('resize', neu); };
|
||||
}, [zoomSvg]);
|
||||
|
||||
const aktuell = gruppen[index] ?? null;
|
||||
const anzahl = gruppen.length || gesamtDokumente;
|
||||
|
||||
/** Ein einzelnes Dokument (Schriftstück oder Umschlag) mit Beschriftung. */
|
||||
const DocBox = ({ label, svg }: { label: string; svg: string }) => (
|
||||
<figure
|
||||
className="sk-preview-doc"
|
||||
onClick={() => openZoom(svg)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
title="Zum Vergrößern klicken"
|
||||
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') openZoom(svg); }}
|
||||
>
|
||||
<figcaption className="sk-preview-doc-label">{label}</figcaption>
|
||||
{/* Das SVG stammt aus unserer eigenen Generierung, nicht aus Nutzereingaben. */}
|
||||
<div className="sk-preview-doc-sheet" dangerouslySetInnerHTML={{ __html: svg }} />
|
||||
</figure>
|
||||
);
|
||||
|
||||
return (
|
||||
<aside className="sk-preview">
|
||||
<div className="sk-preview-head">
|
||||
<div>
|
||||
<div className="sk-preview-title">Vorschau: {titel || '—'}</div>
|
||||
<div className="sk-preview-sub">{format || '—'}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{laedt ? (
|
||||
<div className="sk-preview-sheet"><p className="sk-note">Vorschau wird erzeugt …</p></div>
|
||||
) : aktuell ? (
|
||||
<div className="sk-preview-docs">
|
||||
{aktuell.brief && <DocBox label={`Schriftstück${format ? ` · ${format}` : ''}`} svg={aktuell.brief.svg} />}
|
||||
{aktuell.umschlag && <DocBox label="Umschlag" svg={aktuell.umschlag.svg} />}
|
||||
</div>
|
||||
) : (
|
||||
<div className="sk-preview-sheet">
|
||||
<p className="sk-note" style={{ textAlign: 'center' }}>Noch keine Vorschau erzeugt.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{anzahl > 0 && (
|
||||
<div className="sk-preview-pager">
|
||||
<button className="sk-pager-btn" aria-label="Vorheriges Dokument"
|
||||
disabled={index === 0 || gruppen.length <= 1}
|
||||
onClick={() => setIndex((i) => Math.max(0, i - 1))}>‹</button>
|
||||
<span className="sk-pager-label">
|
||||
Dokument {gruppen.length ? index + 1 : 0} von {anzahl}
|
||||
</span>
|
||||
<button className="sk-pager-btn" aria-label="Nächstes Dokument"
|
||||
disabled={index >= gruppen.length - 1 || gruppen.length <= 1}
|
||||
onClick={() => setIndex((i) => Math.min(gruppen.length - 1, i + 1))}>›</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{fehler && <p className="sk-error">{fehler}</p>}
|
||||
|
||||
<div className="sk-preview-quota">
|
||||
Verbleibende Vorschau-Anfragen: {verbleibend}/{maximal}
|
||||
</div>
|
||||
|
||||
<button className="sk-btn sk-btn--ghost sk-btn--block" onClick={onGenerate}
|
||||
disabled={!bereit || laedt || verbleibend <= 0}
|
||||
title={!bereit ? 'Bitte zuerst Text angeben' : undefined}>
|
||||
{laedt ? 'Wird erzeugt …' : 'Vorschau generieren'}
|
||||
</button>
|
||||
|
||||
{zoomSvg && (
|
||||
<div className="sk-lightbox" style={zoomBox} onClick={() => setZoomSvg(null)} role="dialog" aria-modal="true"
|
||||
aria-label="Vergrößerte Vorschau">
|
||||
<div className="sk-lightbox-sheet" onClick={(e) => e.stopPropagation()}
|
||||
dangerouslySetInnerHTML={{ __html: zoomSvg }} />
|
||||
<button className="sk-lightbox-close" onClick={() => setZoomSvg(null)} aria-label="Schließen">
|
||||
Schließen
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
548
Webseite/skrift-app/src/lib/api.ts
Normal file
548
Webseite/skrift-app/src/lib/api.ts
Normal file
@@ -0,0 +1,548 @@
|
||||
/**
|
||||
* Directus-Client.
|
||||
*
|
||||
* Bewusst ohne SDK: Wir brauchen vor allem unsere eigenen Endpunkte
|
||||
* (/skrift-auth, /skrift-orders) plus einfache Item-Abfragen – ein schlanker
|
||||
* fetch-Wrapper ist überschaubarer und hält uns von SDK-Versionswechseln fern.
|
||||
*/
|
||||
|
||||
const BASE = (import.meta.env.VITE_DIRECTUS_URL ?? '').replace(/\/$/, '');
|
||||
|
||||
const STORAGE_KEY = 'skrift.session';
|
||||
|
||||
export type Session = {
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
/** Ablaufzeitpunkt als Unix-ms. */
|
||||
expires_at: number;
|
||||
};
|
||||
|
||||
export function loadSession(): Session | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
return raw ? (JSON.parse(raw) as Session) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function saveSession(s: Session | null) {
|
||||
if (s) localStorage.setItem(STORAGE_KEY, JSON.stringify(s));
|
||||
else localStorage.removeItem(STORAGE_KEY);
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
status: number;
|
||||
/** Einzelne Validierungsmeldungen, falls der Endpunkt mehrere liefert. */
|
||||
details: string[];
|
||||
constructor(message: string, status: number, details: string[] = []) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
this.details = details;
|
||||
}
|
||||
}
|
||||
|
||||
async function parse(res: Response) {
|
||||
const text = await res.text();
|
||||
let data: any = null;
|
||||
try {
|
||||
data = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
/* keine JSON-Antwort */
|
||||
}
|
||||
if (!res.ok) {
|
||||
// Directus liefert errors als Objekte ({message}), unsere eigenen
|
||||
// Endpunkte als reine Strings – beides muss ankommen.
|
||||
const roh = Array.isArray(data?.errors) ? data.errors : [];
|
||||
const details: string[] = roh
|
||||
.map((e: unknown) => (typeof e === 'string' ? e : (e as { message?: string })?.message))
|
||||
.filter((m: unknown): m is string => typeof m === 'string' && m.length > 0);
|
||||
|
||||
const basis = details[0] ?? data?.error ?? data?.message ?? res.statusText;
|
||||
// Manche eigenen Endpunkte hängen einen Diagnose-Grund an (data.detail).
|
||||
const msg = typeof data?.detail === 'string' ? `${basis} (${data.detail})` : basis;
|
||||
throw new ApiError(msg, res.status, details);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
/** Erneuert die Session, wenn das Access-Token abgelaufen ist. */
|
||||
async function ensureFreshToken(): Promise<Session | null> {
|
||||
const session = loadSession();
|
||||
if (!session) return null;
|
||||
// 30 s Puffer, damit ein Request nicht mitten im Ablauf landet.
|
||||
if (Date.now() < session.expires_at - 30_000) return session;
|
||||
|
||||
try {
|
||||
const res = await fetch(`${BASE}/auth/refresh`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ refresh_token: session.refresh_token, mode: 'json' }),
|
||||
});
|
||||
const data = await parse(res);
|
||||
const next: Session = {
|
||||
access_token: data.data?.access_token ?? data.access_token,
|
||||
refresh_token: data.data?.refresh_token ?? data.refresh_token,
|
||||
expires_at: Date.now() + (data.data?.expires ?? data.expires ?? 900_000),
|
||||
};
|
||||
saveSession(next);
|
||||
return next;
|
||||
} catch {
|
||||
saveSession(null);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
type RequestOptions = { method?: string; body?: unknown; auth?: boolean };
|
||||
|
||||
async function request(path: string, { method = 'GET', body, auth = true }: RequestOptions = {}) {
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
|
||||
if (auth) {
|
||||
const session = await ensureFreshToken();
|
||||
if (session) headers.Authorization = `Bearer ${session.access_token}`;
|
||||
}
|
||||
|
||||
const res = await fetch(BASE + path, {
|
||||
method,
|
||||
headers,
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
return parse(res);
|
||||
}
|
||||
|
||||
/* ── Auth (passwortlos, siehe Extension skrift-auth) ──────────────────────── */
|
||||
|
||||
export async function requestLoginCode(email: string) {
|
||||
return request('/skrift-auth/request-code', { method: 'POST', body: { email }, auth: false });
|
||||
}
|
||||
|
||||
export async function verifyLoginCode(email: string, code: string): Promise<Session> {
|
||||
const data = await request('/skrift-auth/verify-code', {
|
||||
method: 'POST',
|
||||
body: { email, code },
|
||||
auth: false,
|
||||
});
|
||||
const session: Session = {
|
||||
access_token: data.access_token,
|
||||
refresh_token: data.refresh_token,
|
||||
expires_at: Date.now() + (data.expires ?? 900_000),
|
||||
};
|
||||
saveSession(session);
|
||||
return session;
|
||||
}
|
||||
|
||||
export function logout() {
|
||||
saveSession(null);
|
||||
}
|
||||
|
||||
/* ── Daten ────────────────────────────────────────────────────────────────── */
|
||||
|
||||
export type Order = {
|
||||
id: number;
|
||||
order_number: string;
|
||||
status: string;
|
||||
gross_total: number | null;
|
||||
entries_count: number | null;
|
||||
date_created: string | null;
|
||||
product?: { name: string } | null;
|
||||
};
|
||||
|
||||
/** Aufträge des angemeldeten Kunden – die Einschränkung erfolgt in Directus. */
|
||||
export async function fetchMyOrders(): Promise<Order[]> {
|
||||
const data = await request(
|
||||
'/items/orders?fields=id,order_number,status,gross_total,entries_count,date_created,product.name' +
|
||||
'&sort=-date_created&limit=100',
|
||||
);
|
||||
return data?.data ?? [];
|
||||
}
|
||||
|
||||
export async function fetchMe() {
|
||||
const data = await request('/users/me?fields=id,email,first_name,last_name');
|
||||
return data?.data ?? null;
|
||||
}
|
||||
|
||||
/* ── Stammdaten ───────────────────────────────────────────────────────────── */
|
||||
|
||||
export type CustomerAddress = {
|
||||
label?: string;
|
||||
street?: string;
|
||||
zip?: string;
|
||||
city?: string;
|
||||
country?: string;
|
||||
};
|
||||
export type Customer = {
|
||||
/** Directus-User-ID (UUID) – Kunde ist das Login-Konto selbst. */
|
||||
id: string;
|
||||
email: string;
|
||||
person_type: 'privat' | 'unternehmen' | null;
|
||||
first_name: string | null;
|
||||
last_name: string | null;
|
||||
company: string | null;
|
||||
phone: string | null;
|
||||
/** Mehrere hinterlegte Adressen (Rechnung/Lieferung). */
|
||||
addresses: CustomerAddress[] | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Profil des angemeldeten Kunden. Kunde = Login-Konto (directus_users),
|
||||
* daher direkt über /users/me – kein separater „customers"-Datensatz mehr.
|
||||
*/
|
||||
export async function fetchMyCustomer(): Promise<Customer | null> {
|
||||
const data = await request(
|
||||
'/users/me?fields=id,email,person_type,first_name,last_name,company,phone,addresses',
|
||||
);
|
||||
return data?.data ?? null;
|
||||
}
|
||||
|
||||
/** person_type fehlt bewusst – die Einstufung ändert nur der Betreiber. */
|
||||
export async function saveMyCustomer(c: Customer) {
|
||||
return request('/users/me', {
|
||||
method: 'PATCH',
|
||||
body: {
|
||||
first_name: c.first_name,
|
||||
last_name: c.last_name,
|
||||
company: c.company,
|
||||
phone: c.phone,
|
||||
addresses: c.addresses ?? [],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/* ── Katalog (öffentlich lesbar – auch für Gäste) ─────────────────────────── */
|
||||
|
||||
export type Product = {
|
||||
id: number;
|
||||
key: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
type: 'letter' | 'postcard' | 'sample' | 'contact';
|
||||
pricing_mode: 'formel' | 'auf_anfrage';
|
||||
/** Für wen das Produkt sichtbar ist (im Admin pflegbar). */
|
||||
visibility: 'alle' | 'privat' | 'unternehmen';
|
||||
/** Eingabeart im Konfigurator: freier Text oder Datei-Upload. */
|
||||
input_mode: 'text' | 'datei';
|
||||
};
|
||||
|
||||
export type Format = { id: number; key: string; name: string; product: number };
|
||||
|
||||
/**
|
||||
* Produkt-Schlüssel, die für den angemeldeten Kunden individuell freigeschaltet
|
||||
* sind (z. B. Unterschriftenservice). Nur mit Login sinnvoll; sonst leer.
|
||||
*/
|
||||
export async function fetchMyUnlockedProducts(): Promise<string[]> {
|
||||
if (!loadSession()) return [];
|
||||
try {
|
||||
const data = await request('/items/customer_products?fields=product.key&limit=-1');
|
||||
return (data?.data ?? [])
|
||||
.map((r: { product?: { key?: string } }) => r.product?.key)
|
||||
.filter((k: unknown): k is string => typeof k === 'string');
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchProducts(): Promise<Product[]> {
|
||||
// fields=* damit ein noch fehlendes Feld (z. B. visibility vor dem Bootstrap)
|
||||
// den Aufruf nicht scheitern lässt.
|
||||
const data = await request(
|
||||
'/items/products?fields=*&filter[active][_eq]=true&sort=sort&limit=-1',
|
||||
{ auth: false },
|
||||
);
|
||||
return (data?.data ?? []).map((p: Product) => ({ ...p, visibility: p.visibility ?? 'alle', input_mode: p.input_mode ?? 'text' }));
|
||||
}
|
||||
|
||||
export async function fetchFormats(): Promise<Format[]> {
|
||||
const data = await request(
|
||||
'/items/formats?fields=id,key,name,product&filter[active][_eq]=true&limit=-1',
|
||||
{ auth: false },
|
||||
);
|
||||
return data?.data ?? [];
|
||||
}
|
||||
|
||||
/* ── Preis & Bestellung (Extension skrift-orders) ─────────────────────────── */
|
||||
|
||||
export type QuoteLine = { label: string; amount: number; per_unit?: boolean };
|
||||
export type Quote = {
|
||||
lines: QuoteLine[];
|
||||
net_total: number;
|
||||
/** Netto-Summe nur der pro-Stück-Positionen (ohne einmalige Kosten). */
|
||||
unit_net_total: number;
|
||||
vat_amount: number;
|
||||
gross_total: number;
|
||||
mwst_percent: number;
|
||||
paypal_allowed: boolean;
|
||||
};
|
||||
|
||||
export type QuoteInput = {
|
||||
product: string;
|
||||
format?: string;
|
||||
quantity: number;
|
||||
shipping_type: 'einzeln' | 'sammel';
|
||||
needs_envelope: boolean;
|
||||
envelope_labeling?: 'empfaenger' | 'freitext' | 'keine';
|
||||
motif_mode?: string;
|
||||
addons?: string[];
|
||||
voucher_code?: string;
|
||||
/** Optional: bekannte Kunden-E-Mail, damit hinterlegte Kundenrabatte greifen. */
|
||||
email?: string;
|
||||
/** Segment für den Mengen-Multiplikator (B2B/B2C). */
|
||||
person_type?: 'privat' | 'unternehmen';
|
||||
};
|
||||
|
||||
/** Preis serverseitig berechnen lassen – der Client rechnet nie selbst. */
|
||||
export async function fetchQuote(input: QuoteInput): Promise<Quote> {
|
||||
return request('/skrift-orders/quote', { method: 'POST', body: input, auth: false });
|
||||
}
|
||||
|
||||
/* ── Motive ───────────────────────────────────────────────────────────────── */
|
||||
|
||||
export type Motif = {
|
||||
id: number;
|
||||
name: string;
|
||||
image: string | null;
|
||||
orientation: 'quer' | 'hochkant' | null;
|
||||
};
|
||||
|
||||
export async function fetchMotifs(): Promise<Motif[]> {
|
||||
const data = await request(
|
||||
'/items/motifs?fields=id,name,image,orientation&filter[active][_eq]=true&sort=sort&limit=-1',
|
||||
{ auth: false },
|
||||
);
|
||||
return data?.data ?? [];
|
||||
}
|
||||
|
||||
/** Öffentliche URL einer Directus-Datei. */
|
||||
export const assetUrl = (id: string) => `${BASE}/assets/${id}`;
|
||||
|
||||
/* ── Preis-Positionen (für Aufpreise an den Optionen) ─────────────────────── */
|
||||
|
||||
export type PriceItem = {
|
||||
key: string;
|
||||
label: string;
|
||||
category: string;
|
||||
price: number;
|
||||
unit: 'einmalig' | 'pro_stueck' | 'prozent';
|
||||
};
|
||||
|
||||
export async function fetchPriceItems(): Promise<PriceItem[]> {
|
||||
const data = await request(
|
||||
'/items/price_items?fields=key,label,category,price,unit&filter[active][_eq]=true&limit=-1',
|
||||
{ auth: false },
|
||||
);
|
||||
return data?.data ?? [];
|
||||
}
|
||||
|
||||
/* ── Vorschau ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
export type PreviewDoc = { art: 'brief' | 'umschlag'; svg: string };
|
||||
/** Ein Schriftstück, dessen Text nicht auf das Format passt (Zeilen-Überlauf). */
|
||||
export type PreviewOverflow = { index: number; lineCount: number; lineLimit: number };
|
||||
export type PreviewResult = {
|
||||
docs: PreviewDoc[];
|
||||
remaining: number;
|
||||
limit: number;
|
||||
/** true, wenn mindestens ein Schriftstück zu lang für das Format ist. */
|
||||
has_overflow?: boolean;
|
||||
overflow?: PreviewOverflow[];
|
||||
/** Vorschau-Session – bei der Bestellung mitschicken, damit gezeigte
|
||||
* Dokumente 1:1 übernommen werden. */
|
||||
session?: string;
|
||||
};
|
||||
|
||||
export async function fetchPreviewQuota(kennung: string): Promise<{ remaining: number; limit: number }> {
|
||||
const p = kennung.includes('@') ? `email=${encodeURIComponent(kennung)}` : `client_id=${encodeURIComponent(kennung)}`;
|
||||
return request(`/skrift-orders/preview/quota?${p}`, { auth: false });
|
||||
}
|
||||
|
||||
export type PreviewInput = {
|
||||
email?: string;
|
||||
client_id?: string;
|
||||
text: string;
|
||||
font: string;
|
||||
format?: string;
|
||||
realistic?: boolean;
|
||||
envelope_labeling?: string;
|
||||
envelope_text?: string;
|
||||
entries: unknown[];
|
||||
};
|
||||
|
||||
export async function generatePreview(input: PreviewInput): Promise<PreviewResult> {
|
||||
return request('/skrift-orders/preview', { method: 'POST', body: input, auth: false });
|
||||
}
|
||||
|
||||
/**
|
||||
* Reine Zeilen-/Überlauf-Prüfung (beim „Weiter" vom Text-Schritt) – verbraucht
|
||||
* KEIN Vorschau-Kontingent und liefert keine SVGs.
|
||||
*/
|
||||
export async function validateText(
|
||||
input: PreviewInput,
|
||||
): Promise<{ has_overflow?: boolean; overflow?: PreviewOverflow[] }> {
|
||||
return request('/skrift-orders/preview', { method: 'POST', body: { ...input, validate: true }, auth: false });
|
||||
}
|
||||
|
||||
/* ── Bestellung absenden ──────────────────────────────────────────────────── */
|
||||
|
||||
export type OrderInput = {
|
||||
email: string;
|
||||
first_name?: string;
|
||||
last_name?: string;
|
||||
company?: string;
|
||||
phone?: string;
|
||||
person_type: 'privat' | 'unternehmen';
|
||||
product: string;
|
||||
format?: string;
|
||||
font: string;
|
||||
shipping_type: 'einzeln' | 'sammel';
|
||||
needs_envelope: boolean;
|
||||
envelope_labeling?: string;
|
||||
motif_mode?: string;
|
||||
text_template?: string;
|
||||
text_briefing?: string;
|
||||
/** Hochgeladenes Dokument (Datei-Produkte, z. B. zu unterschreibendes PDF). */
|
||||
source_file?: string;
|
||||
/** Rechnungs-/Lieferadresse als Snapshot (für Bestätigung & Rechnung). */
|
||||
billing?: Record<string, string>;
|
||||
shipping_address?: Record<string, string> | null;
|
||||
/** Vorschau-Session (gezeigte Dokumente werden 1:1 übernommen). */
|
||||
preview_session?: string;
|
||||
addons?: string[];
|
||||
voucher_code?: string;
|
||||
payment_method: 'paypal' | 'rechnung';
|
||||
source?: string;
|
||||
entries: unknown[];
|
||||
/** Motiv aus dem Katalog (ID) bzw. hochgeladene Datei (Directus-Datei-ID). */
|
||||
motif?: number;
|
||||
motif_upload?: string;
|
||||
};
|
||||
|
||||
export type OrderResult = {
|
||||
order_number: string;
|
||||
net_total: number;
|
||||
vat_amount: number;
|
||||
gross_total: number;
|
||||
payment_method: 'paypal' | 'rechnung';
|
||||
paypal_allowed: boolean;
|
||||
};
|
||||
|
||||
export async function createOrder(input: OrderInput): Promise<OrderResult> {
|
||||
return request('/skrift-orders', { method: 'POST', body: input, auth: false });
|
||||
}
|
||||
|
||||
/** Vermerkt eine erfolgreiche PayPal-Zahlung am Auftrag. */
|
||||
export async function confirmPayment(order_number: string, payment_ref: string) {
|
||||
return request('/skrift-orders/payment', {
|
||||
method: 'POST',
|
||||
body: { order_number, payment_ref },
|
||||
auth: false,
|
||||
});
|
||||
}
|
||||
|
||||
/* ── Öffentliche Einstellungen (Preisformel, PayPal-Client-ID) ─────────────── */
|
||||
|
||||
export type PublicSettings = {
|
||||
mwst_percent: number;
|
||||
/** Erlaubte Zeichen als Regex-Klasse (z. B. "\\x20-\\x7EÄÖÜ…"). */
|
||||
char_whitelist: string | null;
|
||||
paypal_limit_net: number;
|
||||
paypal_enabled: boolean;
|
||||
paypal_mode: 'sandbox' | 'live';
|
||||
paypal_client_id_sandbox: string | null;
|
||||
paypal_client_id_live: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Bewusst `fields=*`: Wenn einzelne Felder (z. B. die PayPal-Spalten) noch
|
||||
* nicht angelegt sind, würde eine explizite Feldliste den ganzen Aufruf
|
||||
* scheitern lassen.
|
||||
*/
|
||||
export async function fetchSettings(): Promise<PublicSettings | null> {
|
||||
const data = await request('/items/pricing_settings?fields=*', { auth: false });
|
||||
return data?.data ?? null;
|
||||
}
|
||||
|
||||
/** Die zum Modus passende öffentliche Client-ID. */
|
||||
export const paypalClientId = (s: PublicSettings | null) =>
|
||||
!s || !s.paypal_enabled
|
||||
? null
|
||||
: (s.paypal_mode === 'live' ? s.paypal_client_id_live : s.paypal_client_id_sandbox) || null;
|
||||
|
||||
/* ── Datei-Upload (Motiv) ─────────────────────────────────────────────────── */
|
||||
|
||||
/** Lädt eine Datei nach Directus und liefert deren ID. */
|
||||
export async function uploadFile(datei: File): Promise<string> {
|
||||
const form = new FormData();
|
||||
form.append('file', datei);
|
||||
|
||||
const session = await ensureFreshToken();
|
||||
const res = await fetch(`${BASE}/files`, {
|
||||
method: 'POST',
|
||||
headers: session ? { Authorization: `Bearer ${session.access_token}` } : undefined,
|
||||
body: form,
|
||||
});
|
||||
const data = await parse(res);
|
||||
return data?.data?.id;
|
||||
}
|
||||
|
||||
/* ── Angebotsanfrage ──────────────────────────────────────────────────────── */
|
||||
|
||||
export type OfferRequest = {
|
||||
name: string;
|
||||
email: string;
|
||||
phone?: string;
|
||||
quantity?: number | null;
|
||||
message: string;
|
||||
/** Bei Anfrage aus einem Kontakt-Produkt: dessen Name. */
|
||||
product?: string;
|
||||
};
|
||||
|
||||
export async function sendOfferRequest(input: OfferRequest) {
|
||||
return request('/skrift-orders/offer-request', { method: 'POST', body: input, auth: false });
|
||||
}
|
||||
|
||||
/* ── Musterbestellung (kostenlos) ─────────────────────────────────────────── */
|
||||
|
||||
export type SampleRequest = {
|
||||
product: string;
|
||||
name: string;
|
||||
email: string;
|
||||
phone?: string;
|
||||
street: string;
|
||||
zip: string;
|
||||
city: string;
|
||||
country: string;
|
||||
};
|
||||
|
||||
export async function sendSampleRequest(input: SampleRequest) {
|
||||
return request('/skrift-orders/sample-request', { method: 'POST', body: input, auth: false });
|
||||
}
|
||||
|
||||
/* ── Statusverlauf & Benachrichtigungen ───────────────────────────────────── */
|
||||
|
||||
export type StatusEvent = {
|
||||
id: number;
|
||||
status: string;
|
||||
note: string | null;
|
||||
timestamp: string;
|
||||
order: { id: number; order_number: string } | null;
|
||||
};
|
||||
|
||||
/** Statusverlauf – Directus liefert nur Einträge zu eigenen Aufträgen. */
|
||||
export async function fetchStatusEvents(orderId?: number): Promise<StatusEvent[]> {
|
||||
const filter = orderId ? `&filter[order][_eq]=${orderId}` : '';
|
||||
const data = await request(
|
||||
`/items/status_history?fields=id,status,note,timestamp,order.id,order.order_number` +
|
||||
`&sort=-timestamp&limit=30${filter}`,
|
||||
);
|
||||
return data?.data ?? [];
|
||||
}
|
||||
|
||||
const SEEN_KEY = 'skrift.notifications.seen';
|
||||
|
||||
export function getSeenAt(): number {
|
||||
const raw = localStorage.getItem(SEEN_KEY);
|
||||
return raw ? Number(raw) : 0;
|
||||
}
|
||||
export function markNotificationsSeen() {
|
||||
localStorage.setItem(SEEN_KEY, String(Date.now()));
|
||||
}
|
||||
37
Webseite/skrift-app/src/lib/dialogs.tsx
Normal file
37
Webseite/skrift-app/src/lib/dialogs.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { createContext, useContext, useState } from 'react';
|
||||
|
||||
/**
|
||||
* Zentrale Dialog-Steuerung: Hilfe, Individuelles Angebot und Muster bestellen
|
||||
* werden an einer Stelle (AppShell) gerendert, aber von überall geöffnet
|
||||
* (Seitenleiste, Konfigurator, Kontakt-Produkte).
|
||||
*/
|
||||
export type DialogName = 'hilfe' | 'angebot' | 'muster' | null;
|
||||
|
||||
type Ctx = {
|
||||
dialog: DialogName;
|
||||
/** Optionaler Vorbelegungstext (z. B. gewähltes Kontakt-Produkt). */
|
||||
vorbelegung: string;
|
||||
open: (d: Exclude<DialogName, null>, vorbelegung?: string) => void;
|
||||
close: () => void;
|
||||
};
|
||||
|
||||
const DialogContext = createContext<Ctx>({ dialog: null, vorbelegung: '', open: () => {}, close: () => {} });
|
||||
|
||||
export function DialogsProvider({ children }: { children: React.ReactNode }) {
|
||||
const [dialog, setDialog] = useState<DialogName>(null);
|
||||
const [vorbelegung, setVorbelegung] = useState('');
|
||||
return (
|
||||
<DialogContext.Provider
|
||||
value={{
|
||||
dialog,
|
||||
vorbelegung,
|
||||
open: (d, v = '') => { setVorbelegung(v); setDialog(d); },
|
||||
close: () => setDialog(null),
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</DialogContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export const useDialogs = () => useContext(DialogContext);
|
||||
109
Webseite/skrift-app/src/lib/excel.ts
Normal file
109
Webseite/skrift-app/src/lib/excel.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Excel-Import und -Vorlage für Empfängerdaten.
|
||||
*
|
||||
* Die Vorlage wird im Browser erzeugt – so bleibt sie automatisch synchron mit
|
||||
* den erwarteten Spalten und muss nicht als Datei gepflegt werden.
|
||||
*
|
||||
* SheetJS wird bewusst erst bei Bedarf nachgeladen (dynamischer Import): Die
|
||||
* Bibliothek ist größer als die restliche App und wird nur von wenigen Kunden
|
||||
* gebraucht – sie gehört nicht ins Start-Bundle.
|
||||
*/
|
||||
|
||||
export type ExcelEntry = {
|
||||
salutation: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
street: string;
|
||||
house_no: string;
|
||||
zip: string;
|
||||
city: string;
|
||||
country: string;
|
||||
free_text: string;
|
||||
placeholders: Record<string, string>;
|
||||
};
|
||||
|
||||
/** Feste Spalten; alles Weitere wird als freier Platzhalter übernommen. */
|
||||
const SPALTEN: Record<string, keyof ExcelEntry> = {
|
||||
anrede: 'salutation',
|
||||
vorname: 'first_name',
|
||||
nachname: 'last_name',
|
||||
name: 'last_name',
|
||||
strasse: 'street',
|
||||
straße: 'street',
|
||||
nr: 'house_no',
|
||||
hausnummer: 'house_no',
|
||||
plz: 'zip',
|
||||
ort: 'city',
|
||||
stadt: 'city',
|
||||
land: 'country',
|
||||
};
|
||||
|
||||
const norm = (s: string) => s.trim().toLowerCase().replace(/\s+/g, '');
|
||||
|
||||
export const VORLAGEN_SPALTEN = [
|
||||
'Anrede', 'Vorname', 'Nachname', 'Strasse', 'Nr', 'PLZ', 'Ort', 'Land',
|
||||
];
|
||||
|
||||
/** Spalten der Freitext-Variante: fünf frei beschreibbare Zeilen. */
|
||||
export const FREITEXT_SPALTEN = ['Zeile 1', 'Zeile 2', 'Zeile 3', 'Zeile 4', 'Zeile 5'];
|
||||
|
||||
/** Erzeugt die Vorlagendatei und stößt den Download an. */
|
||||
export async function ladeVorlageHerunter(zusatzSpalten: string[] = [], freitext = false) {
|
||||
const XLSX = await import('xlsx');
|
||||
const basis = freitext ? FREITEXT_SPALTEN : VORLAGEN_SPALTEN;
|
||||
const kopf = [...basis, ...zusatzSpalten];
|
||||
const beispielBasis = freitext
|
||||
? ['Erika Mustermann', 'Hauptstraße 1', '10115 Berlin', '', '']
|
||||
: ['Frau', 'Erika', 'Mustermann', 'Hauptstraße', '1', '10115', 'Berlin', 'DE'];
|
||||
const beispiel = [...beispielBasis, ...zusatzSpalten.map(() => 'Beispielwert')];
|
||||
|
||||
const blatt = XLSX.utils.aoa_to_sheet([kopf, beispiel]);
|
||||
blatt['!cols'] = kopf.map(() => ({ wch: 16 }));
|
||||
const mappe = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(mappe, blatt, 'Empfaenger');
|
||||
XLSX.writeFile(mappe, 'skrift-empfaenger-vorlage.xlsx');
|
||||
}
|
||||
|
||||
/** Liest eine hochgeladene Datei ein und wandelt sie in Empfängerzeilen. */
|
||||
export async function leseEmpfaengerDatei(datei: File): Promise<ExcelEntry[]> {
|
||||
const XLSX = await import('xlsx');
|
||||
const puffer = await datei.arrayBuffer();
|
||||
const mappe = XLSX.read(puffer, { type: 'array' });
|
||||
const blatt = mappe.Sheets[mappe.SheetNames[0]];
|
||||
if (!blatt) throw new Error('Die Datei enthält kein Tabellenblatt.');
|
||||
|
||||
const zeilen = XLSX.utils.sheet_to_json<Record<string, unknown>>(blatt, { defval: '' });
|
||||
if (!zeilen.length) throw new Error('Die Datei enthält keine Datenzeilen.');
|
||||
|
||||
return zeilen.map((zeile) => {
|
||||
const eintrag: ExcelEntry = {
|
||||
salutation: '', first_name: '', last_name: '', street: '',
|
||||
house_no: '', zip: '', city: '', country: 'DE',
|
||||
free_text: '', placeholders: {},
|
||||
};
|
||||
|
||||
// Freitext-Vorlage: „Zeile 1..5" werden zur mehrzeiligen Adresse.
|
||||
const freitextZeilen: string[] = [];
|
||||
|
||||
for (const [spalte, wert] of Object.entries(zeile)) {
|
||||
const text = String(wert ?? '').trim();
|
||||
const zeilenTreffer = /^zeile\s*(\d+)$/i.exec(spalte.trim());
|
||||
const feld = SPALTEN[norm(spalte)];
|
||||
|
||||
if (zeilenTreffer) {
|
||||
freitextZeilen[Number(zeilenTreffer[1]) - 1] = text;
|
||||
} else if (feld && feld !== 'placeholders') {
|
||||
(eintrag[feld] as string) = text;
|
||||
} else if (text) {
|
||||
// Unbekannte Spalten werden zu Platzhaltern – so lassen sich
|
||||
// beliebige eigene Felder im Text verwenden.
|
||||
eintrag.placeholders[spalte.trim()] = text;
|
||||
}
|
||||
}
|
||||
|
||||
if (freitextZeilen.length) {
|
||||
eintrag.free_text = freitextZeilen.map((z) => z ?? '').join('\n').replace(/\n+$/, '');
|
||||
}
|
||||
return eintrag;
|
||||
});
|
||||
}
|
||||
86
Webseite/skrift-app/src/lib/paypal.ts
Normal file
86
Webseite/skrift-app/src/lib/paypal.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* PayPal-Anbindung – clientseitig, wie im bisherigen Konfigurator.
|
||||
*
|
||||
* Es wird ausschließlich die öffentliche Client-ID verwendet; das Secret bleibt
|
||||
* außen vor. Die Zahlung wird über `actions.order.create/capture` direkt gegen
|
||||
* PayPal abgewickelt, die Referenz danach in Directus vermerkt.
|
||||
*/
|
||||
|
||||
let geladen: Promise<void> | null = null;
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
paypal?: {
|
||||
Buttons: (config: Record<string, unknown>) => {
|
||||
render: (el: HTMLElement) => Promise<void>;
|
||||
};
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** Lädt das PayPal-SDK genau einmal nach. */
|
||||
export function ladePayPal(clientId: string): Promise<void> {
|
||||
if (window.paypal) return Promise.resolve();
|
||||
if (geladen) return geladen;
|
||||
|
||||
geladen = new Promise<void>((resolve, reject) => {
|
||||
const script = document.createElement('script');
|
||||
script.src =
|
||||
`https://www.paypal.com/sdk/js?client-id=${encodeURIComponent(clientId)}` +
|
||||
'¤cy=EUR&intent=capture&disable-funding=venmo,paylater,credit';
|
||||
script.async = true;
|
||||
script.onload = () => resolve();
|
||||
script.onerror = () => {
|
||||
geladen = null;
|
||||
reject(new Error('PayPal konnte nicht geladen werden.'));
|
||||
};
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
|
||||
return geladen;
|
||||
}
|
||||
|
||||
export type PayPalOptions = {
|
||||
betrag: number;
|
||||
beschreibung: string;
|
||||
onApproved: (referenz: string) => void | Promise<void>;
|
||||
onError: (meldung: string) => void;
|
||||
};
|
||||
|
||||
/** Rendert die PayPal-Buttons in das übergebene Element. */
|
||||
export async function rendereButtons(el: HTMLElement, o: PayPalOptions) {
|
||||
if (!window.paypal) throw new Error('PayPal ist nicht geladen.');
|
||||
el.innerHTML = '';
|
||||
|
||||
await window.paypal
|
||||
.Buttons({
|
||||
style: { shape: 'rect', layout: 'vertical', color: 'blue', label: 'paypal' },
|
||||
|
||||
createOrder: (_data: unknown, actions: any) =>
|
||||
actions.order.create({
|
||||
intent: 'CAPTURE',
|
||||
purchase_units: [
|
||||
{
|
||||
amount: { currency_code: 'EUR', value: o.betrag.toFixed(2) },
|
||||
description: o.beschreibung.slice(0, 127),
|
||||
},
|
||||
],
|
||||
application_context: {
|
||||
brand_name: 'Skrift',
|
||||
locale: 'de-DE',
|
||||
shipping_preference: 'NO_SHIPPING',
|
||||
user_action: 'PAY_NOW',
|
||||
},
|
||||
}),
|
||||
|
||||
onApprove: (_data: unknown, actions: any) =>
|
||||
actions.order.capture().then((details: any) => {
|
||||
const capture = details?.purchase_units?.[0]?.payments?.captures?.[0];
|
||||
if (capture?.status === 'COMPLETED') return o.onApproved(capture.id);
|
||||
o.onError('Die Zahlung wurde nicht abgeschlossen.');
|
||||
}),
|
||||
|
||||
onError: () => o.onError('Bei der Zahlung ist ein Fehler aufgetreten.'),
|
||||
})
|
||||
.render(el);
|
||||
}
|
||||
18
Webseite/skrift-app/src/lib/status.ts
Normal file
18
Webseite/skrift-app/src/lib/status.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
/** Anzeigetexte für die Auftragsstatus aus Directus – an einer Stelle gepflegt. */
|
||||
export const STATUS_LABEL: Record<string, string> = {
|
||||
entwurf: 'Entwurf',
|
||||
vorbereitet: 'Für Sie vorbereitet',
|
||||
wartet_auf_zahlung: 'Wartet auf Zahlung',
|
||||
bezahlt: 'Bezahlt',
|
||||
material_erwartet: 'Material erwartet',
|
||||
material_eingegangen: 'Material eingegangen',
|
||||
in_queue: 'In der Warteschlange',
|
||||
in_produktion: 'In Produktion',
|
||||
gedruckt: 'Geschrieben',
|
||||
versendet: 'Versendet',
|
||||
abgeschlossen: 'Abgeschlossen',
|
||||
storniert: 'Storniert',
|
||||
fehler: 'Fehler',
|
||||
};
|
||||
|
||||
export const statusText = (s: string) => STATUS_LABEL[s] ?? s;
|
||||
46
Webseite/skrift-app/src/lib/wizard.tsx
Normal file
46
Webseite/skrift-app/src/lib/wizard.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { createContext, useCallback, useContext, useMemo, useRef, useState } from 'react';
|
||||
|
||||
/**
|
||||
* Der Konfigurator meldet seine Schritte hierher, damit die Seitenleiste den
|
||||
* Fortschritt anzeigen kann – ohne dass Shell und Wizard voneinander wissen
|
||||
* müssen. Umgekehrt darf die Seitenleiste über `goto` zu einem bereits
|
||||
* erledigten Schritt zurückspringen; der Konfigurator registriert dafür seinen
|
||||
* Handler (nur Rückwärtssprünge sind erlaubt).
|
||||
*/
|
||||
export type WizardState = { steps: string[]; current: number };
|
||||
|
||||
type Ctx = WizardState & {
|
||||
publish: (s: WizardState) => void;
|
||||
/** Vom Konfigurator aufgerufen: setzt den aktiven Sprung-Handler. */
|
||||
registerGoto: (fn: (i: number) => void) => void;
|
||||
/** Von der Seitenleiste aufgerufen: springe zu Schritt i. */
|
||||
goto: (i: number) => void;
|
||||
};
|
||||
|
||||
const WizardContext = createContext<Ctx>({
|
||||
steps: [], current: 0, publish: () => {}, registerGoto: () => {}, goto: () => {},
|
||||
});
|
||||
|
||||
export function WizardProvider({ children }: { children: React.ReactNode }) {
|
||||
const [state, setState] = useState<WizardState>({ steps: [], current: 0 });
|
||||
const gotoRef = useRef<(i: number) => void>(() => {});
|
||||
|
||||
const publish = useCallback((next: WizardState) => {
|
||||
setState((prev) =>
|
||||
prev.current === next.current && prev.steps.join('|') === next.steps.join('|')
|
||||
? prev // unnötige Renders vermeiden
|
||||
: next,
|
||||
);
|
||||
}, []);
|
||||
const registerGoto = useCallback((fn: (i: number) => void) => { gotoRef.current = fn; }, []);
|
||||
const goto = useCallback((i: number) => gotoRef.current(i), []);
|
||||
|
||||
const value = useMemo<Ctx>(
|
||||
() => ({ ...state, publish, registerGoto, goto }),
|
||||
[state, publish, registerGoto, goto],
|
||||
);
|
||||
|
||||
return <WizardContext.Provider value={value}>{children}</WizardContext.Provider>;
|
||||
}
|
||||
|
||||
export const useWizard = () => useContext(WizardContext);
|
||||
14
Webseite/skrift-app/src/main.tsx
Normal file
14
Webseite/skrift-app/src/main.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import App from './App';
|
||||
import './styles/tokens.css';
|
||||
import './styles/global.css';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
123
Webseite/skrift-app/src/routes/Auftraege.tsx
Normal file
123
Webseite/skrift-app/src/routes/Auftraege.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { ApiError, fetchMyOrders, fetchStatusEvents, type Order, type StatusEvent } from '../lib/api';
|
||||
import { statusText } from '../lib/status';
|
||||
import { IconChevronDown } from '../components/Icons';
|
||||
|
||||
const euro = (v: number | null) =>
|
||||
v == null ? '—' : new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(v);
|
||||
|
||||
const datum = (v: string | null) =>
|
||||
v ? new Intl.DateTimeFormat('de-DE', { dateStyle: 'medium' }).format(new Date(v)) : '';
|
||||
|
||||
const zeitpunkt = (v: string) =>
|
||||
new Intl.DateTimeFormat('de-DE', { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(v));
|
||||
|
||||
/** Statusverlauf eines Auftrags – wird erst bei Bedarf geladen. */
|
||||
function Verlauf({ orderId }: { orderId: number }) {
|
||||
const [events, setEvents] = useState<StatusEvent[] | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchStatusEvents(orderId)
|
||||
.then(setEvents)
|
||||
.catch(() => setEvents([]));
|
||||
}, [orderId]);
|
||||
|
||||
if (events === null) return <p className="sk-note">Verlauf wird geladen …</p>;
|
||||
if (events.length === 0) return <p className="sk-note">Noch kein Verlauf vorhanden.</p>;
|
||||
|
||||
return (
|
||||
<ol className="sk-timeline">
|
||||
{events.map((e) => (
|
||||
<li key={e.id} className="sk-timeline-item">
|
||||
<span className="sk-timeline-mark" />
|
||||
<div>
|
||||
<div className="sk-timeline-body">{statusText(e.status)}</div>
|
||||
{e.note && <div className="sk-timeline-time">{e.note}</div>}
|
||||
<div className="sk-timeline-time">{zeitpunkt(e.timestamp)}</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Auftraege() {
|
||||
const navigate = useNavigate();
|
||||
const [orders, setOrders] = useState<Order[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [offen, setOffen] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchMyOrders()
|
||||
.then(setOrders)
|
||||
.catch((err) => {
|
||||
if (err instanceof ApiError && (err.status === 401 || err.status === 403)) {
|
||||
navigate('/login', { replace: true });
|
||||
return;
|
||||
}
|
||||
setError(err instanceof ApiError ? err.message : 'Aufträge konnten nicht geladen werden.');
|
||||
});
|
||||
}, [navigate]);
|
||||
|
||||
return (
|
||||
<div className="sk-view">
|
||||
<h1>Aufträge</h1>
|
||||
<p className="sk-note" style={{ margin: 'var(--sk-space-2) 0 var(--sk-space-5)' }}>
|
||||
Alle Ihre Aufträge mit aktuellem Stand. Klicken Sie einen Auftrag an, um den Verlauf zu sehen.
|
||||
</p>
|
||||
|
||||
{error && <p className="sk-error">{error}</p>}
|
||||
{orders === null && !error && <p className="sk-note">Wird geladen …</p>}
|
||||
|
||||
{orders && orders.length === 0 && (
|
||||
<div className="sk-empty">
|
||||
<p style={{ marginBottom: 'var(--sk-space-4)' }}>Sie haben noch keine Aufträge.</p>
|
||||
<Link to="/" className="sk-btn sk-btn--primary">
|
||||
Ersten Auftrag anlegen
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{orders && orders.length > 0 && (
|
||||
<ul className="sk-order-list">
|
||||
{orders.map((o) => (
|
||||
<li key={o.id}>
|
||||
<button
|
||||
className="sk-order sk-order--clickable"
|
||||
aria-expanded={offen === o.id}
|
||||
onClick={() => setOffen(offen === o.id ? null : o.id)}
|
||||
>
|
||||
<span>
|
||||
<span className="sk-order-no" style={{ display: 'block' }}>
|
||||
{o.order_number}
|
||||
</span>
|
||||
<span className="sk-order-meta">
|
||||
{[o.product?.name, o.entries_count ? `${o.entries_count} Stück` : null, datum(o.date_created)]
|
||||
.filter(Boolean)
|
||||
.join(' · ')}
|
||||
</span>
|
||||
</span>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 'var(--sk-space-4)' }}>
|
||||
<span className="sk-chip">{statusText(o.status)}</span>
|
||||
<span className="sk-order-total">{euro(o.gross_total)}</span>
|
||||
<IconChevronDown
|
||||
size={18}
|
||||
className={`sk-order-caret${offen === o.id ? ' sk-order-caret--open' : ''}`}
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{offen === o.id && (
|
||||
<div className="sk-card" style={{ marginTop: 'var(--sk-space-2)' }}>
|
||||
<h3 style={{ fontSize: 'var(--sk-size-sm)' }}>Verlauf</h3>
|
||||
<Verlauf orderId={o.id} />
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1830
Webseite/skrift-app/src/routes/Configurator.tsx
Normal file
1830
Webseite/skrift-app/src/routes/Configurator.tsx
Normal file
File diff suppressed because it is too large
Load Diff
121
Webseite/skrift-app/src/routes/Login.tsx
Normal file
121
Webseite/skrift-app/src/routes/Login.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { requestLoginCode, verifyLoginCode, ApiError } from '../lib/api';
|
||||
|
||||
/**
|
||||
* Passwortloser Login: E-Mail eingeben → Code per Mail → Code eingeben.
|
||||
* Die Antwort auf "Code anfordern" ist bewusst immer positiv – der Server
|
||||
* verrät nicht, ob es zu einer Adresse ein Konto gibt.
|
||||
*/
|
||||
export default function Login() {
|
||||
const navigate = useNavigate();
|
||||
const [step, setStep] = useState<'email' | 'code'>('email');
|
||||
const [email, setEmail] = useState('');
|
||||
const [code, setCode] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function submitEmail(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setBusy(true);
|
||||
try {
|
||||
await requestLoginCode(email.trim());
|
||||
setStep('code');
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : 'Anfrage fehlgeschlagen.');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function submitCode(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setBusy(true);
|
||||
try {
|
||||
await verifyLoginCode(email.trim(), code.trim());
|
||||
navigate('/auftraege', { replace: true });
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : 'Anmeldung fehlgeschlagen.');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="sk-view" style={{ maxWidth: 'var(--sk-form-width)' }}>
|
||||
<h1>Anmelden</h1>
|
||||
|
||||
{step === 'email' ? (
|
||||
<form className="sk-card" style={{ marginTop: 'var(--sk-space-5)' }} onSubmit={submitEmail}>
|
||||
<p className="sk-note" style={{ marginBottom: 'var(--sk-space-5)' }}>
|
||||
Sofern Sie bereits eine Bestellung bei uns aufgegeben haben, senden wir Ihnen einen
|
||||
Anmeldecode an Ihre E-Mail-Adresse. Kein Passwort nötig.
|
||||
</p>
|
||||
|
||||
<div className="sk-field">
|
||||
<label className="sk-label" htmlFor="email">
|
||||
E-Mail-Adresse
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
className="sk-input"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button className="sk-btn sk-btn--primary sk-btn--block" disabled={busy}>
|
||||
{busy ? 'Wird gesendet …' : 'Code anfordern'}
|
||||
</button>
|
||||
{error && <p className="sk-error">{error}</p>}
|
||||
</form>
|
||||
) : (
|
||||
<form className="sk-card" style={{ marginTop: 'var(--sk-space-5)' }} onSubmit={submitCode}>
|
||||
<p className="sk-note" style={{ marginBottom: 'var(--sk-space-5)' }}>
|
||||
Wir haben einen Code an <strong>{email}</strong> gesendet. Er ist 10 Minuten gültig.
|
||||
</p>
|
||||
|
||||
<div className="sk-field">
|
||||
<label className="sk-label" htmlFor="code">
|
||||
Anmeldecode
|
||||
</label>
|
||||
<input
|
||||
id="code"
|
||||
className="sk-input sk-input--code"
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
maxLength={6}
|
||||
pattern="\d{6}"
|
||||
required
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value.replace(/\D/g, ''))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button className="sk-btn sk-btn--primary sk-btn--block" disabled={busy || code.length !== 6}>
|
||||
{busy ? 'Wird geprüft …' : 'Anmelden'}
|
||||
</button>
|
||||
{error && <p className="sk-error">{error}</p>}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="sk-btn sk-btn--ghost sk-btn--block"
|
||||
style={{ marginTop: 'var(--sk-space-3)' }}
|
||||
onClick={() => {
|
||||
setStep('email');
|
||||
setCode('');
|
||||
setError(null);
|
||||
}}
|
||||
>
|
||||
Andere Adresse verwenden
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
8
Webseite/skrift-app/src/routes/Muster.tsx
Normal file
8
Webseite/skrift-app/src/routes/Muster.tsx
Normal file
@@ -0,0 +1,8 @@
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Muster } from '../components/AppShell';
|
||||
|
||||
/** Musteranfrage als eigene Seite (Direktlink /muster) – kein Popup. */
|
||||
export default function MusterSeite() {
|
||||
const navigate = useNavigate();
|
||||
return <Muster variant="page" onClose={() => navigate('/')} />;
|
||||
}
|
||||
129
Webseite/skrift-app/src/routes/Stammdaten.tsx
Normal file
129
Webseite/skrift-app/src/routes/Stammdaten.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { fetchMyCustomer, saveMyCustomer, type Customer, type CustomerAddress } from '../lib/api';
|
||||
|
||||
const LAENDER = ['Deutschland', 'Österreich', 'Schweiz', 'Luxemburg', 'Belgien', 'Niederlande', 'Frankreich'];
|
||||
const leereAdresse = (): CustomerAddress => ({ label: '', street: '', zip: '', city: '', country: 'Deutschland' });
|
||||
|
||||
/** Stammdaten des angemeldeten Kunden – füllen später den Konfigurator vor. */
|
||||
export default function Stammdaten() {
|
||||
const [c, setC] = useState<Customer | null>(null);
|
||||
const [status, setStatus] = useState<'laden' | 'bereit' | 'speichert' | 'gespeichert'>('laden');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchMyCustomer()
|
||||
.then((data) => {
|
||||
setC(data);
|
||||
setStatus('bereit');
|
||||
})
|
||||
.catch(() => {
|
||||
setError('Stammdaten konnten nicht geladen werden.');
|
||||
setStatus('bereit');
|
||||
});
|
||||
}, []);
|
||||
|
||||
async function speichern(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!c) return;
|
||||
setStatus('speichert');
|
||||
setError(null);
|
||||
try {
|
||||
await saveMyCustomer(c);
|
||||
setStatus('gespeichert');
|
||||
setTimeout(() => setStatus('bereit'), 2500);
|
||||
} catch {
|
||||
setError('Speichern fehlgeschlagen.');
|
||||
setStatus('bereit');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Adressen (mehrere möglich) ──
|
||||
const adressen = c?.addresses ?? [];
|
||||
const setAdressen = (next: CustomerAddress[]) => c && setC({ ...c, addresses: next });
|
||||
const setAdrFeld = (i: number, k: keyof CustomerAddress, v: string) =>
|
||||
setAdressen(adressen.map((a, idx) => (idx === i ? { ...a, [k]: v } : a)));
|
||||
const addAdresse = () => setAdressen([...adressen, leereAdresse()]);
|
||||
const removeAdresse = (i: number) => setAdressen(adressen.filter((_, idx) => idx !== i));
|
||||
|
||||
const feld = (k: keyof Customer, label: string, type = 'text') => (
|
||||
<div className="sk-field">
|
||||
<label className="sk-label" htmlFor={k}>
|
||||
{label}
|
||||
</label>
|
||||
<input
|
||||
id={k}
|
||||
className="sk-input"
|
||||
type={type}
|
||||
value={(c?.[k] as string) ?? ''}
|
||||
onChange={(e) => c && setC({ ...c, [k]: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="sk-view">
|
||||
<h1>Stammdaten</h1>
|
||||
<p className="sk-note" style={{ margin: 'var(--sk-space-2) 0 var(--sk-space-5)' }}>
|
||||
Diese Angaben füllen wir bei neuen Aufträgen automatisch für Sie aus.
|
||||
</p>
|
||||
|
||||
{status === 'laden' && <p className="sk-note">Wird geladen …</p>}
|
||||
{error && <p className="sk-error">{error}</p>}
|
||||
|
||||
{c && (
|
||||
<form className="sk-card" onSubmit={speichern}>
|
||||
{/* Die Einstufung legt Skrift fest – sie bestimmt die Preisdarstellung. */}
|
||||
<div className="sk-field">
|
||||
<label className="sk-label">Sie bestellen als</label>
|
||||
<input
|
||||
className="sk-input"
|
||||
value={c.person_type === 'unternehmen' ? 'Unternehmen' : 'Privatperson'}
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
|
||||
{feld('first_name', 'Vorname')}
|
||||
{feld('last_name', 'Nachname')}
|
||||
{c.person_type === 'unternehmen' && feld('company', 'Firma')}
|
||||
{feld('phone', 'Telefon', 'tel')}
|
||||
|
||||
<div className="sk-field">
|
||||
<label className="sk-label">E-Mail</label>
|
||||
<input className="sk-input" value={c.email} disabled />
|
||||
</div>
|
||||
|
||||
{/* Adressen – beliebig viele; im Konfigurator wählbar. */}
|
||||
<div className="sk-field" style={{ marginBottom: 0 }}>
|
||||
<label className="sk-label">Adressen</label>
|
||||
{adressen.length === 0 && <p className="sk-note">Noch keine Adresse hinterlegt.</p>}
|
||||
{adressen.map((a, i) => (
|
||||
<div key={i} className="sk-check--card" style={{ padding: 'var(--sk-space-4)', marginBottom: 'var(--sk-space-3)', border: '1px solid var(--sk-border)', borderRadius: 'var(--sk-radius, 10px)' }}>
|
||||
<div style={{ display: 'flex', gap: 'var(--sk-space-2)', marginBottom: 'var(--sk-space-2)' }}>
|
||||
<input className="sk-input" placeholder="Bezeichnung (z. B. Rechnung, Lieferung)"
|
||||
value={a.label ?? ''} onChange={(e) => setAdrFeld(i, 'label', e.target.value)} />
|
||||
<button type="button" className="sk-btn sk-btn--ghost" onClick={() => removeAdresse(i)}>Entfernen</button>
|
||||
</div>
|
||||
<input className="sk-input" style={{ marginBottom: 'var(--sk-space-2)' }} placeholder="Straße & Hausnummer"
|
||||
value={a.street ?? ''} onChange={(e) => setAdrFeld(i, 'street', e.target.value)} />
|
||||
<div style={{ display: 'flex', gap: 'var(--sk-space-2)', marginBottom: 'var(--sk-space-2)' }}>
|
||||
<input className="sk-input" placeholder="PLZ" value={a.zip ?? ''}
|
||||
onChange={(e) => setAdrFeld(i, 'zip', e.target.value.replace(/[^0-9]/g, ''))} style={{ flex: '0 0 30%' }} />
|
||||
<input className="sk-input" placeholder="Ort" value={a.city ?? ''}
|
||||
onChange={(e) => setAdrFeld(i, 'city', e.target.value)} />
|
||||
</div>
|
||||
<select className="sk-input" value={a.country ?? 'Deutschland'} onChange={(e) => setAdrFeld(i, 'country', e.target.value)}>
|
||||
{LAENDER.map((l) => <option key={l} value={l}>{l}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
))}
|
||||
<button type="button" className="sk-btn sk-btn--ghost" onClick={addAdresse}>+ Adresse hinzufügen</button>
|
||||
</div>
|
||||
|
||||
<button className="sk-btn sk-btn--primary" style={{ marginTop: 'var(--sk-space-5)' }} disabled={status === 'speichert'}>
|
||||
{status === 'speichert' ? 'Wird gespeichert …' : status === 'gespeichert' ? 'Gespeichert' : 'Speichern'}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1137
Webseite/skrift-app/src/styles/global.css
Normal file
1137
Webseite/skrift-app/src/styles/global.css
Normal file
File diff suppressed because it is too large
Load Diff
86
Webseite/skrift-app/src/styles/tokens.css
Normal file
86
Webseite/skrift-app/src/styles/tokens.css
Normal file
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* Skrift – Design-Tokens
|
||||
*
|
||||
* Übernommen aus dem bestehenden System (skrift-configurator/configurator.css
|
||||
* und skrift-job-manager/job-app.css), damit App, Konfigurator und
|
||||
* Auftragsmanager identisch aussehen. Werte hier nicht „verbessern" – sie sind
|
||||
* die gemeinsame Grundlage.
|
||||
*/
|
||||
|
||||
:root {
|
||||
/* ── Farben (1:1 aus configurator.css) ──────────────────── */
|
||||
--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-accent-hover: #1a3258;
|
||||
--sk-success: #059669;
|
||||
--sk-warning: #f59e0b;
|
||||
--sk-error: #dc2626;
|
||||
|
||||
/* Navigationsflächen dieser App.
|
||||
Vorschlag: tiefes Marken-Navy statt des grellen Royalblaus – ruhiger und
|
||||
markenkonsistent (identisch mit --sk-accent). Alternative: #0f172a (Slate).*/
|
||||
--sk-nav: #1e3a6a;
|
||||
--sk-nav-text: #ffffff;
|
||||
--sk-nav-text-dim: rgba(255, 255, 255, 0.82);
|
||||
--sk-nav-text-faint: rgba(255, 255, 255, 0.68);
|
||||
|
||||
/* Aliasse für bestehende Klassennamen in dieser App */
|
||||
--sk-primary: var(--sk-accent);
|
||||
--sk-primary-hover: var(--sk-accent-hover);
|
||||
--sk-text-muted: var(--sk-muted);
|
||||
--sk-text-on-primary: #ffffff;
|
||||
--sk-white: var(--sk-card);
|
||||
--sk-bg-1: var(--sk-bg);
|
||||
--sk-bg-2: rgba(15, 23, 42, 0.04);
|
||||
--sk-bg-3: #c9b7a8;
|
||||
--sk-danger: var(--sk-error);
|
||||
|
||||
/* ── Radius & Abstände ──────────────────────────────────── */
|
||||
--sk-radius-sm: 6px;
|
||||
--sk-radius: 8px;
|
||||
--sk-radius-lg: 8px; /* im Bestandssystem gibt es nur einen Radius */
|
||||
--sk-gap: 18px;
|
||||
|
||||
--sk-space-1: 4px;
|
||||
--sk-space-2: 8px;
|
||||
--sk-space-3: 12px;
|
||||
--sk-space-4: 16px;
|
||||
--sk-space-5: 18px;
|
||||
--sk-space-6: 24px;
|
||||
--sk-space-7: 32px;
|
||||
--sk-space-8: 48px;
|
||||
|
||||
/* ── Typografie ─────────────────────────────────────────── */
|
||||
/* Work Sans als Familie, Größen aus dem Bestandssystem:
|
||||
Bedienelemente 14px, Hilfetexte 12px, Kartentitel 16px.
|
||||
(Die früher genannten 18px galten dem Fließtext und ließen die
|
||||
Oberfläche „herangezoomt" wirken.) */
|
||||
--sk-font: 'Work Sans', system-ui, -apple-system, 'Segoe UI', Roboto, Arial, sans-serif;
|
||||
|
||||
--sk-size-base: 14px;
|
||||
--sk-size-xs: 12px;
|
||||
--sk-size-sm: 13px;
|
||||
--sk-size-md: 14px;
|
||||
--sk-size-lg: 16px;
|
||||
--sk-size-xl: 20px;
|
||||
--sk-size-2xl: 24px;
|
||||
--sk-leading: 1.5;
|
||||
--sk-leading-tight: 1.3;
|
||||
|
||||
--sk-weight-normal: 400;
|
||||
--sk-weight-medium: 500;
|
||||
--sk-weight-semibold: 600;
|
||||
--sk-weight-bold: 700;
|
||||
--sk-weight-price: 750;
|
||||
|
||||
/* ── Layout ─────────────────────────────────────────────── */
|
||||
--sk-app-width: 1400px;
|
||||
--sk-sidebar-width: 248px;
|
||||
--sk-read-width: 1200px; /* wie .sk-job-app */
|
||||
--sk-form-width: 520px;
|
||||
}
|
||||
10
Webseite/skrift-app/src/vite-env.d.ts
vendored
Normal file
10
Webseite/skrift-app/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
/** URL der Directus-Instanz (Daten, Auth, Bestellungen). */
|
||||
readonly VITE_DIRECTUS_URL: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
20
Webseite/skrift-app/tsconfig.json
Normal file
20
Webseite/skrift-app/tsconfig.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
8
Webseite/skrift-app/vite.config.ts
Normal file
8
Webseite/skrift-app/vite.config.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: { port: 5173 },
|
||||
build: { outDir: 'dist', sourcemap: false },
|
||||
});
|
||||
155
Webseite/skrift-preisrechner/formel.php
Normal file
155
Webseite/skrift-preisrechner/formel.php
Normal file
@@ -0,0 +1,155 @@
|
||||
<?php
|
||||
/**
|
||||
* Kleiner, sicherer Formel-Interpreter (kein eval).
|
||||
* Unterstützt: Zahlen, Variablen, + - * /, Klammern, unäres Minus,
|
||||
* Vergleiche (>= <= > < == !=), Ternär (a ? b : c) und die Funktionen
|
||||
* sqrt, pow, abs, min, max, round, floor, ceil.
|
||||
*
|
||||
* Damit rechnet das Plugin mit GENAU DER Formel, die in Directus hinterlegt
|
||||
* ist – Änderungen an der Formel wirken automatisch mit.
|
||||
*/
|
||||
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
/** Zerlegt den Ausdruck in Tokens. */
|
||||
function skrift_formel_tokens($ausdruck) {
|
||||
$s = (string) $ausdruck;
|
||||
$s = str_replace('Math.', '', $s); // Math.sqrt(...) → sqrt(...)
|
||||
$s = preg_replace('/%(\w+)%/', '$1', $s); // %qty% → qty
|
||||
$tokens = array();
|
||||
$pos = 0; $len = strlen($s);
|
||||
while ($pos < $len) {
|
||||
$c = $s[$pos];
|
||||
if (ctype_space($c)) { $pos++; continue; }
|
||||
if (ctype_digit($c) || ($c === '.' && $pos + 1 < $len && ctype_digit($s[$pos + 1]))) {
|
||||
$start = $pos;
|
||||
while ($pos < $len && (ctype_digit($s[$pos]) || $s[$pos] === '.')) $pos++;
|
||||
$tokens[] = array('num', (float) substr($s, $start, $pos - $start));
|
||||
continue;
|
||||
}
|
||||
if (ctype_alpha($c) || $c === '_') {
|
||||
$start = $pos;
|
||||
while ($pos < $len && (ctype_alnum($s[$pos]) || $s[$pos] === '_')) $pos++;
|
||||
$tokens[] = array('id', substr($s, $start, $pos - $start));
|
||||
continue;
|
||||
}
|
||||
$zwei = substr($s, $pos, 2);
|
||||
if (in_array($zwei, array('>=', '<=', '==', '!='), true)) { $tokens[] = array('op', $zwei); $pos += 2; continue; }
|
||||
$tokens[] = array('op', $c); $pos++;
|
||||
}
|
||||
return $tokens;
|
||||
}
|
||||
|
||||
function skrift_formel_ist($t, $i, $wert) {
|
||||
return $i < count($t) && $t[$i][0] === 'op' && $t[$i][1] === $wert;
|
||||
}
|
||||
|
||||
function skrift_formel_fn($name, $args) {
|
||||
$a0 = isset($args[0]) ? (float) $args[0] : 0.0;
|
||||
switch ($name) {
|
||||
case 'sqrt': return $a0 < 0 ? 0.0 : sqrt($a0);
|
||||
case 'pow': return pow($a0, isset($args[1]) ? (float) $args[1] : 0.0);
|
||||
case 'abs': return abs($a0);
|
||||
case 'min': return empty($args) ? 0.0 : min($args);
|
||||
case 'max': return empty($args) ? 0.0 : max($args);
|
||||
case 'round': return round($a0);
|
||||
case 'floor': return floor($a0);
|
||||
case 'ceil': return ceil($a0);
|
||||
}
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
function skrift_formel_primary(&$i, $t, $v) {
|
||||
if ($i >= count($t)) return 0.0;
|
||||
$tk = $t[$i];
|
||||
if ($tk[0] === 'num') { $i++; return $tk[1]; }
|
||||
if ($tk[0] === 'id') {
|
||||
$name = $tk[1]; $i++;
|
||||
if (skrift_formel_ist($t, $i, '(')) {
|
||||
$i++; $args = array();
|
||||
if (!skrift_formel_ist($t, $i, ')')) {
|
||||
$args[] = skrift_formel_ternary($i, $t, $v);
|
||||
while (skrift_formel_ist($t, $i, ',')) { $i++; $args[] = skrift_formel_ternary($i, $t, $v); }
|
||||
}
|
||||
if (skrift_formel_ist($t, $i, ')')) $i++;
|
||||
return skrift_formel_fn($name, $args);
|
||||
}
|
||||
return isset($v[$name]) ? (float) $v[$name] : 0.0;
|
||||
}
|
||||
if ($tk[0] === 'op' && $tk[1] === '(') {
|
||||
$i++; $r = skrift_formel_ternary($i, $t, $v);
|
||||
if (skrift_formel_ist($t, $i, ')')) $i++;
|
||||
return $r;
|
||||
}
|
||||
$i++; return 0.0;
|
||||
}
|
||||
|
||||
function skrift_formel_unary(&$i, $t, $v) {
|
||||
if (skrift_formel_ist($t, $i, '-')) { $i++; return -skrift_formel_unary($i, $t, $v); }
|
||||
if (skrift_formel_ist($t, $i, '+')) { $i++; return skrift_formel_unary($i, $t, $v); }
|
||||
return skrift_formel_primary($i, $t, $v);
|
||||
}
|
||||
|
||||
function skrift_formel_mul(&$i, $t, $v) {
|
||||
$l = skrift_formel_unary($i, $t, $v);
|
||||
while (skrift_formel_ist($t, $i, '*') || skrift_formel_ist($t, $i, '/')) {
|
||||
$op = $t[$i][1]; $i++;
|
||||
$r = skrift_formel_unary($i, $t, $v);
|
||||
$l = $op === '*' ? $l * $r : ($r == 0 ? 0.0 : $l / $r);
|
||||
}
|
||||
return $l;
|
||||
}
|
||||
|
||||
function skrift_formel_add(&$i, $t, $v) {
|
||||
$l = skrift_formel_mul($i, $t, $v);
|
||||
while (skrift_formel_ist($t, $i, '+') || skrift_formel_ist($t, $i, '-')) {
|
||||
$op = $t[$i][1]; $i++;
|
||||
$r = skrift_formel_mul($i, $t, $v);
|
||||
$l = $op === '+' ? $l + $r : $l - $r;
|
||||
}
|
||||
return $l;
|
||||
}
|
||||
|
||||
function skrift_formel_compare(&$i, $t, $v) {
|
||||
$l = skrift_formel_add($i, $t, $v);
|
||||
while ($i < count($t) && $t[$i][0] === 'op'
|
||||
&& in_array($t[$i][1], array('>=', '<=', '==', '!=', '>', '<'), true)) {
|
||||
$op = $t[$i][1]; $i++;
|
||||
$r = skrift_formel_add($i, $t, $v);
|
||||
switch ($op) {
|
||||
case '>=': $l = $l >= $r; break;
|
||||
case '<=': $l = $l <= $r; break;
|
||||
case '>': $l = $l > $r; break;
|
||||
case '<': $l = $l < $r; break;
|
||||
case '==': $l = $l == $r; break;
|
||||
case '!=': $l = $l != $r; break;
|
||||
}
|
||||
}
|
||||
return $l;
|
||||
}
|
||||
|
||||
function skrift_formel_ternary(&$i, $t, $v) {
|
||||
$c = skrift_formel_compare($i, $t, $v);
|
||||
if (skrift_formel_ist($t, $i, '?')) {
|
||||
$i++;
|
||||
$a = skrift_formel_ternary($i, $t, $v);
|
||||
if (skrift_formel_ist($t, $i, ':')) $i++;
|
||||
$b = skrift_formel_ternary($i, $t, $v);
|
||||
return $c ? $a : $b;
|
||||
}
|
||||
return $c;
|
||||
}
|
||||
|
||||
/** Wertet den Ausdruck aus. Bei Problemen wird $fallback zurückgegeben. */
|
||||
function skrift_formel_eval($ausdruck, $vars, $fallback = 1.0) {
|
||||
if (!is_string($ausdruck) || trim($ausdruck) === '') return $fallback;
|
||||
try {
|
||||
$t = skrift_formel_tokens($ausdruck);
|
||||
$i = 0;
|
||||
$r = (float) skrift_formel_ternary($i, $t, $vars);
|
||||
if (!is_finite($r)) return $fallback;
|
||||
return $r;
|
||||
} catch (Throwable $e) {
|
||||
return $fallback;
|
||||
}
|
||||
}
|
||||
416
Webseite/skrift-preisrechner/skrift-preisrechner.php
Normal file
416
Webseite/skrift-preisrechner/skrift-preisrechner.php
Normal file
@@ -0,0 +1,416 @@
|
||||
<?php
|
||||
/**
|
||||
* Plugin Name: Skrift Preisrechner
|
||||
* Description: Preisrechner für Briefe/Postkarten per Shortcode. Holt Preisdaten stündlich per WP-Cron aus dem Skrift-Backend (öffentliche Read-API, ohne Token) und speichert sie in WordPress. Die Anzeige rechnet ohne Netzwerkzugriff – dadurch ohne Verzögerung. Shortcodes: [skrift_preis_inkl] und [skrift_preis_exkl].
|
||||
* Version: 2.0.0
|
||||
* Author: Skrift
|
||||
*
|
||||
* Sicherheit: Es werden ausschließlich ÖFFENTLICHE Werte gelesen (Basispreis,
|
||||
* Formel-Parameter, MwSt, Porto) – keine Secrets, kein API-Token.
|
||||
*/
|
||||
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
require_once __DIR__ . '/formel.php';
|
||||
|
||||
const SKRIFT_PREIS_OPTION = 'skrift_preis_daten';
|
||||
const SKRIFT_PREIS_CRON = 'skrift_preis_sync';
|
||||
/** Nach dieser Zeit gelten gespeicherte Daten als veraltet (Cron-Ausfall). */
|
||||
const SKRIFT_PREIS_MAX_ALTER = 7200; // 2 Stunden
|
||||
|
||||
/** Basis-URL des Skrift-Backends (Directus). Per Filter überschreibbar. */
|
||||
function skrift_preis_backend_url() {
|
||||
return rtrim(apply_filters('skrift_preis_backend_url', 'https://admin.skrift.de'), '/');
|
||||
}
|
||||
|
||||
/**
|
||||
* Holt die Preisdaten frisch aus Directus und legt sie in einer WP-Option ab.
|
||||
* Wird stündlich per Cron aufgerufen – nicht im normalen Seitenaufruf.
|
||||
* Bei Fehlern bleiben die zuletzt gespeicherten Daten erhalten.
|
||||
*/
|
||||
function skrift_preis_sync() {
|
||||
$base = skrift_preis_backend_url();
|
||||
$fehler = null;
|
||||
$hole = function ($pfad) use ($base, &$fehler) {
|
||||
$res = wp_remote_get($base . $pfad, array('timeout' => 10));
|
||||
if (is_wp_error($res)) {
|
||||
if ($fehler === null) $fehler = 'Backend nicht erreichbar (' . $res->get_error_message() . ')';
|
||||
return null;
|
||||
}
|
||||
$code = wp_remote_retrieve_response_code($res);
|
||||
if ($code !== 200) {
|
||||
if ($fehler === null) $fehler = 'Backend antwortete mit HTTP ' . $code . ' auf ' . $pfad;
|
||||
return null;
|
||||
}
|
||||
$json = json_decode(wp_remote_retrieve_body($res), true);
|
||||
if (!is_array($json) || !array_key_exists('data', $json)) {
|
||||
if ($fehler === null) $fehler = 'Unerwartete Antwort auf ' . $pfad;
|
||||
return null;
|
||||
}
|
||||
return $json['data'];
|
||||
};
|
||||
|
||||
$produkte = $hole('/items/products?fields=key,name,base_price,norm_qty,pricing_mode,type&filter[active][_eq]=true&limit=-1');
|
||||
$settings = $hole('/items/pricing_settings?fields=*');
|
||||
$items = $hole('/items/price_items?fields=key,price,unit,category&filter[active][_eq]=true&limit=-1');
|
||||
|
||||
// Nur bei Erfolg überschreiben – sonst bleiben die alten Werte stehen.
|
||||
if ($fehler === null && is_array($produkte) && !empty($produkte)) {
|
||||
update_option(SKRIFT_PREIS_OPTION, array(
|
||||
'produkte' => $produkte,
|
||||
'settings' => is_array($settings) ? $settings : array(),
|
||||
'items' => is_array($items) ? $items : array(),
|
||||
'zeit' => time(),
|
||||
'fehler' => null,
|
||||
), false); // kein autoload – wird nur bei Bedarf geladen
|
||||
return true;
|
||||
}
|
||||
|
||||
// Fehler am vorhandenen Datensatz vermerken (für die Diagnose in der Ausgabe).
|
||||
$alt = get_option(SKRIFT_PREIS_OPTION);
|
||||
if (is_array($alt)) {
|
||||
$alt['fehler'] = $fehler;
|
||||
update_option(SKRIFT_PREIS_OPTION, $alt, false);
|
||||
} else {
|
||||
update_option(SKRIFT_PREIS_OPTION, array(
|
||||
'produkte' => array(), 'settings' => array(), 'items' => array(),
|
||||
'zeit' => 0, 'fehler' => $fehler,
|
||||
), false);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
add_action(SKRIFT_PREIS_CRON, 'skrift_preis_sync');
|
||||
|
||||
/** Stündlichen Cron beim Aktivieren einplanen und sofort einmal synchronisieren. */
|
||||
register_activation_hook(__FILE__, function () {
|
||||
if (!wp_next_scheduled(SKRIFT_PREIS_CRON)) {
|
||||
wp_schedule_event(time() + 60, 'hourly', SKRIFT_PREIS_CRON);
|
||||
}
|
||||
skrift_preis_sync();
|
||||
});
|
||||
register_deactivation_hook(__FILE__, function () {
|
||||
$ts = wp_next_scheduled(SKRIFT_PREIS_CRON);
|
||||
if ($ts) wp_unschedule_event($ts, SKRIFT_PREIS_CRON);
|
||||
});
|
||||
|
||||
/** Sicherheitsnetz: fehlt der Cron-Eintrag (z. B. nach Migration), neu planen. */
|
||||
add_action('init', function () {
|
||||
if (!wp_next_scheduled(SKRIFT_PREIS_CRON)) {
|
||||
wp_schedule_event(time() + 60, 'hourly', SKRIFT_PREIS_CRON);
|
||||
}
|
||||
// Manuelles Aktualisieren als Admin: ?skrift_preis_refresh=1
|
||||
if (isset($_GET['skrift_preis_refresh']) && current_user_can('manage_options')) {
|
||||
skrift_preis_sync();
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Liefert die gespeicherten Preisdaten. Kein HTTP im Normalfall.
|
||||
* Nur wenn noch nie synchronisiert wurde (oder die Daten deutlich veraltet
|
||||
* sind), wird einmalig nachgeholt.
|
||||
*/
|
||||
function skrift_preis_daten() {
|
||||
$d = get_option(SKRIFT_PREIS_OPTION);
|
||||
$leer = !is_array($d) || empty($d['produkte']);
|
||||
$alt = is_array($d) && isset($d['zeit']) && (time() - (int) $d['zeit']) > SKRIFT_PREIS_MAX_ALTER;
|
||||
if ($leer || $alt) {
|
||||
skrift_preis_sync();
|
||||
$d = get_option(SKRIFT_PREIS_OPTION);
|
||||
}
|
||||
if (!is_array($d)) {
|
||||
$d = array('produkte' => array(), 'settings' => array(), 'items' => array(), 'zeit' => 0, 'fehler' => 'Keine Daten.');
|
||||
}
|
||||
return $d;
|
||||
}
|
||||
|
||||
/** Einen Preis-Positionswert (z. B. porto_inland) aus dem Katalog lesen. */
|
||||
function skrift_preis_item($items, $key) {
|
||||
foreach ($items as $it) {
|
||||
if (isset($it['key']) && $it['key'] === $key) return (float) $it['price'];
|
||||
}
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
/** Ein Produkt per Schlüssel finden. */
|
||||
function skrift_preis_produkt($produkte, $key) {
|
||||
foreach ($produkte as $p) {
|
||||
if (isset($p['key']) && $p['key'] === $key) return $p;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Die für die Rechnung nötigen Parameter eines Produkts – Grundlage sowohl für
|
||||
* die PHP-Rechnung als auch für die identische Rechnung im Browser.
|
||||
*/
|
||||
function skrift_preis_parameter($daten, $produktKey, $typ = 'privat') {
|
||||
$s = $daten['settings'];
|
||||
$p = skrift_preis_produkt($daten['produkte'], $produktKey);
|
||||
if (!$p) return null;
|
||||
|
||||
// Normalpreis-Menge kommt vom Produkt; ohne Wert kein Kleinmengen-Aufschlag.
|
||||
$norm = isset($p['norm_qty']) ? (float) $p['norm_qty'] : 0.0;
|
||||
// Eine gemeinsame Formel.
|
||||
$formel = !empty($s['multiplier_formula']) ? $s['multiplier_formula'] : '';
|
||||
|
||||
$tiers = array();
|
||||
if (isset($s['quantity_discounts']) && is_array($s['quantity_discounts'])) {
|
||||
foreach ($s['quantity_discounts'] as $t) {
|
||||
$tiers[] = array(
|
||||
'min_qty' => isset($t['min_qty']) ? (int) $t['min_qty'] : 0,
|
||||
'percent' => isset($t['percent']) ? (float) $t['percent'] : 0.0,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return array(
|
||||
'base' => isset($p['base_price']) ? (float) $p['base_price'] : 0.0,
|
||||
'norm' => $norm,
|
||||
'formel' => $formel,
|
||||
'mwst' => isset($s['mwst_percent']) ? (float) $s['mwst_percent'] : 19.0,
|
||||
'tiers' => $tiers,
|
||||
'porto' => skrift_preis_item($daten['items'], 'porto_inland'),
|
||||
'name' => isset($p['name']) ? $p['name'] : $produktKey,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Kernrechnung in PHP (spiegelt die Standard-Formel des Backends):
|
||||
* grund = base * menge * multiplikator
|
||||
* multiplik. = menge >= norm ? 1 : 2 - sqrt(menge / norm)
|
||||
* danach Mengenrabatt-Staffel, dann / menge = Preis pro Stück (netto).
|
||||
*/
|
||||
function skrift_preis_rechne_param($par, $menge, $mitKuvert) {
|
||||
if (!$par) return null;
|
||||
$menge = max(1, (int) $menge);
|
||||
|
||||
// Multiplikator exakt mit der in Directus hinterlegten Formel berechnen.
|
||||
// Ohne Normalpreis-Menge am Produkt greift kein Kleinmengen-Aufschlag.
|
||||
$mult = 1.0;
|
||||
if ($par['norm'] > 0) {
|
||||
$mult = skrift_formel_eval($par['formel'], array(
|
||||
'qty' => $menge, 'menge' => $menge,
|
||||
'norm' => $par['norm'], 'base_price' => $par['base'],
|
||||
), 1.0);
|
||||
}
|
||||
if (!is_finite($mult) || $mult <= 0) $mult = 1.0;
|
||||
|
||||
$grund = $par['base'] * $menge * $mult;
|
||||
|
||||
$besteMin = -1; $bestePercent = 0.0;
|
||||
foreach ($par['tiers'] as $t) {
|
||||
if ($menge >= $t['min_qty'] && $t['percent'] > 0 && $t['min_qty'] > $besteMin) {
|
||||
$besteMin = $t['min_qty']; $bestePercent = $t['percent'];
|
||||
}
|
||||
}
|
||||
if ($bestePercent > 0) $grund *= (1 - $bestePercent / 100);
|
||||
|
||||
$netto = $grund / $menge;
|
||||
if ($mitKuvert) $netto += $par['porto'];
|
||||
|
||||
return array(
|
||||
'netto' => round($netto, 2),
|
||||
'brutto' => round($netto * (1 + $par['mwst'] / 100), 2),
|
||||
'mwst' => $par['mwst'],
|
||||
);
|
||||
}
|
||||
|
||||
/** REST-Endpunkt bleibt bestehen (PHP-Rechnung), z. B. für eigene Integrationen. */
|
||||
add_action('rest_api_init', function () {
|
||||
register_rest_route('skrift/v1', '/preis', array(
|
||||
'methods' => 'GET',
|
||||
'permission_callback' => '__return_true',
|
||||
'callback' => function (WP_REST_Request $req) {
|
||||
$daten = skrift_preis_daten();
|
||||
$par = skrift_preis_parameter(
|
||||
$daten,
|
||||
sanitize_text_field($req->get_param('produkt')),
|
||||
$req->get_param('typ') === 'unternehmen' ? 'unternehmen' : 'privat'
|
||||
);
|
||||
$r = skrift_preis_rechne_param(
|
||||
$par,
|
||||
(int) $req->get_param('menge'),
|
||||
$req->get_param('kuvert') === '1' || $req->get_param('kuvert') === 'true'
|
||||
);
|
||||
if (!$r) {
|
||||
$grund = !empty($daten['fehler']) ? $daten['fehler'] : 'Produkt nicht gefunden.';
|
||||
return new WP_Error('skrift_preis', $grund, array('status' => 404));
|
||||
}
|
||||
return rest_ensure_response($r);
|
||||
},
|
||||
));
|
||||
});
|
||||
|
||||
/**
|
||||
* Darstellung. $mitMwst bestimmt, WELCHER Preis gezeigt wird – der Besucher
|
||||
* wählt das bewusst nicht selbst.
|
||||
*
|
||||
* Die Parameter stehen im Seitenquelltext; die Neuberechnung passiert dadurch
|
||||
* sofort im Browser (kein Netzwerkzugriff, keine Latenz) mit exakt derselben
|
||||
* Formel wie in PHP.
|
||||
*/
|
||||
function skrift_preis_render($atts, $mitMwst) {
|
||||
$a = shortcode_atts(array('produkt' => 'briefe', 'typ' => 'privat', 'menge' => 100), $atts);
|
||||
$daten = skrift_preis_daten();
|
||||
$par = skrift_preis_parameter($daten, $a['produkt'], $a['typ']);
|
||||
|
||||
if (!$par) {
|
||||
if (!empty($daten['fehler'])) {
|
||||
return '<p><strong>Preisrechner nicht verfügbar.</strong><br><span style="color:#64748b;font-size:13px;">'
|
||||
. esc_html($daten['fehler']) . '</span></p>';
|
||||
}
|
||||
$bekannt = array();
|
||||
foreach ($daten['produkte'] as $pp) { if (isset($pp['key'])) $bekannt[] = $pp['key']; }
|
||||
return '<p><strong>Preisrechner:</strong> Produkt „' . esc_html($a['produkt']) . '" nicht gefunden.'
|
||||
. (count($bekannt) ? '<br><span style="color:#64748b;font-size:13px;">Verfügbar: '
|
||||
. esc_html(implode(', ', $bekannt)) . '</span>' : '') . '</p>';
|
||||
}
|
||||
|
||||
$startMenge = max(1, (int) $a['menge']);
|
||||
$start = skrift_preis_rechne_param($par, $startMenge, false);
|
||||
$startWert = $mitMwst ? $start['brutto'] : $start['netto'];
|
||||
$hinweis = $mitMwst
|
||||
? 'inkl. ' . rtrim(rtrim(number_format($par['mwst'], 1, ',', '.'), '0'), ',') . ' % MwSt.'
|
||||
: 'zzgl. MwSt.';
|
||||
|
||||
$id = 'skpreis_' . wp_rand(1000, 9999);
|
||||
|
||||
ob_start(); ?>
|
||||
<div class="sk-preisrechner" id="<?php echo esc_attr($id); ?>" style="width:100%;box-sizing:border-box;color:#1A1A1A;font-family:var(--e-global-typography-text-font-family, inherit);font-weight:var(--e-global-typography-text-font-weight, inherit);">
|
||||
<label style="display:block;font-size:0.875em;margin-bottom:8px;color:#1A1A1A;">Anzahl</label>
|
||||
<input type="number" min="1" value="<?php echo esc_attr($startMenge); ?>" class="skp-menge"
|
||||
style="width:100%;box-sizing:border-box;padding:12px 14px;border:1px solid #d8dde5;border-radius:8px;font-family:inherit;font-size:1em;color:#1A1A1A;margin-bottom:18px;">
|
||||
<label style="display:flex;align-items:flex-start;gap:10px;font-size:0.95em;color:#1A1A1A;margin-bottom:20px;cursor:pointer;">
|
||||
<input type="checkbox" class="skp-kuvert" style="margin-top:4px;flex:0 0 auto;">
|
||||
<span>Mit handbeschriebenem Kuvert und Direktversand an die Empfänger</span>
|
||||
</label>
|
||||
<div style="border-top:1px solid #e6e6e6;padding-top:16px;display:flex;justify-content:space-between;align-items:baseline;gap:16px;">
|
||||
<span style="font-size:1em;">Preis pro Stück</span>
|
||||
<strong class="skp-wert" style="font-size:1.5em;font-family:var(--e-global-typography-primary-font-family, inherit);white-space:nowrap;"><?php echo esc_html(number_format($startWert, 2, ',', '.')); ?> €</strong>
|
||||
</div>
|
||||
<div style="text-align:right;font-size:0.8em;opacity:.65;margin-top:4px;"><?php echo esc_html($hinweis); ?></div>
|
||||
</div>
|
||||
<script>
|
||||
(function () {
|
||||
var wrap = document.getElementById(<?php echo wp_json_encode($id); ?>);
|
||||
if (!wrap) return;
|
||||
var par = <?php echo wp_json_encode($par); ?>;
|
||||
var mitMwst = <?php echo $mitMwst ? 'true' : 'false'; ?>;
|
||||
var menge = wrap.querySelector('.skp-menge');
|
||||
var kuvert = wrap.querySelector('.skp-kuvert');
|
||||
var wert = wrap.querySelector('.skp-wert');
|
||||
|
||||
// Formel-Interpreter (Spiegel der PHP-Fassung) – wertet die in Directus
|
||||
// hinterlegte Formel aus, damit Änderungen automatisch mitwirken.
|
||||
function formelEval(ausdruck, vars) {
|
||||
if (!ausdruck) return 1;
|
||||
var s = String(ausdruck).replace(/Math\./g, '').replace(/%(\w+)%/g, '$1');
|
||||
var T = [], i = 0;
|
||||
while (i < s.length) {
|
||||
var c = s[i];
|
||||
if (/\s/.test(c)) { i++; continue; }
|
||||
if (/[0-9]/.test(c) || (c === '.' && /[0-9]/.test(s[i + 1] || ''))) {
|
||||
var st = i; while (i < s.length && /[0-9.]/.test(s[i])) i++;
|
||||
T.push(['num', parseFloat(s.slice(st, i))]); continue;
|
||||
}
|
||||
if (/[A-Za-z_]/.test(c)) {
|
||||
var sa = i; while (i < s.length && /[A-Za-z0-9_]/.test(s[i])) i++;
|
||||
T.push(['id', s.slice(sa, i)]); continue;
|
||||
}
|
||||
var zwei = s.substr(i, 2);
|
||||
if (['>=', '<=', '==', '!='].indexOf(zwei) >= 0) { T.push(['op', zwei]); i += 2; continue; }
|
||||
T.push(['op', c]); i++;
|
||||
}
|
||||
var k = 0;
|
||||
function ist(w) { return k < T.length && T[k][0] === 'op' && T[k][1] === w; }
|
||||
function fn(n, a) {
|
||||
var x = a[0] || 0;
|
||||
switch (n) {
|
||||
case 'sqrt': return x < 0 ? 0 : Math.sqrt(x);
|
||||
case 'pow': return Math.pow(x, a[1] || 0);
|
||||
case 'abs': return Math.abs(x);
|
||||
case 'min': return Math.min.apply(null, a);
|
||||
case 'max': return Math.max.apply(null, a);
|
||||
case 'round': return Math.round(x);
|
||||
case 'floor': return Math.floor(x);
|
||||
case 'ceil': return Math.ceil(x);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
function primary() {
|
||||
if (k >= T.length) return 0;
|
||||
var t = T[k];
|
||||
if (t[0] === 'num') { k++; return t[1]; }
|
||||
if (t[0] === 'id') {
|
||||
var n = t[1]; k++;
|
||||
if (ist('(')) {
|
||||
k++; var a = [];
|
||||
if (!ist(')')) { a.push(ternary()); while (ist(',')) { k++; a.push(ternary()); } }
|
||||
if (ist(')')) k++;
|
||||
return fn(n, a);
|
||||
}
|
||||
return vars[n] !== undefined ? Number(vars[n]) : 0;
|
||||
}
|
||||
if (ist('(')) { k++; var r = ternary(); if (ist(')')) k++; return r; }
|
||||
k++; return 0;
|
||||
}
|
||||
function unary() { if (ist('-')) { k++; return -unary(); } if (ist('+')) { k++; return unary(); } return primary(); }
|
||||
function mul() { var l = unary(); while (ist('*') || ist('/')) { var o = T[k][1]; k++; var r = unary(); l = o === '*' ? l * r : (r === 0 ? 0 : l / r); } return l; }
|
||||
function add() { var l = mul(); while (ist('+') || ist('-')) { var o = T[k][1]; k++; var r = mul(); l = o === '+' ? l + r : l - r; } return l; }
|
||||
function cmp() {
|
||||
var l = add();
|
||||
while (k < T.length && T[k][0] === 'op' && ['>=', '<=', '==', '!=', '>', '<'].indexOf(T[k][1]) >= 0) {
|
||||
var o = T[k][1]; k++; var r = add();
|
||||
l = o === '>=' ? l >= r : o === '<=' ? l <= r : o === '>' ? l > r : o === '<' ? l < r : o === '==' ? l == r : l != r;
|
||||
}
|
||||
return l;
|
||||
}
|
||||
function ternary() {
|
||||
var c = cmp();
|
||||
if (ist('?')) { k++; var a = ternary(); if (ist(':')) k++; var b = ternary(); return c ? a : b; }
|
||||
return c;
|
||||
}
|
||||
var out = Number(ternary());
|
||||
return isFinite(out) ? out : 1;
|
||||
}
|
||||
|
||||
function berechne(m, mitKuvert) {
|
||||
m = Math.max(1, parseInt(m, 10) || 1);
|
||||
var mult = par.norm > 0
|
||||
? formelEval(par.formel, { qty: m, menge: m, norm: par.norm, base_price: par.base })
|
||||
: 1;
|
||||
if (!isFinite(mult) || mult <= 0) mult = 1;
|
||||
var grund = par.base * m * mult;
|
||||
var besteMin = -1, bestePercent = 0;
|
||||
(par.tiers || []).forEach(function (t) {
|
||||
if (m >= t.min_qty && t.percent > 0 && t.min_qty > besteMin) { besteMin = t.min_qty; bestePercent = t.percent; }
|
||||
});
|
||||
if (bestePercent > 0) grund *= (1 - bestePercent / 100);
|
||||
var netto = grund / m;
|
||||
if (mitKuvert) netto += par.porto;
|
||||
return mitMwst ? netto * (1 + par.mwst / 100) : netto;
|
||||
}
|
||||
function fmt(v) { return v.toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + ' €'; }
|
||||
function aktualisiere() { wert.textContent = fmt(berechne(menge.value, kuvert.checked)); }
|
||||
|
||||
menge.addEventListener('input', aktualisiere);
|
||||
kuvert.addEventListener('change', aktualisiere);
|
||||
// Erst beim Verlassen des Feldes auf 1 zurücksetzen – währenddessen darf es leer sein.
|
||||
menge.addEventListener('blur', function () {
|
||||
var n = parseInt(menge.value, 10);
|
||||
if (!(n >= 1)) { menge.value = '1'; aktualisiere(); }
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
<?php
|
||||
return ob_get_clean();
|
||||
}
|
||||
|
||||
/** [skrift_preis_inkl produkt="briefe"] – Preis inkl. MwSt. */
|
||||
add_shortcode('skrift_preis_inkl', function ($atts) { return skrift_preis_render($atts, true); });
|
||||
|
||||
/** [skrift_preis_exkl produkt="briefe"] – Preis exkl. MwSt. */
|
||||
add_shortcode('skrift_preis_exkl', function ($atts) { return skrift_preis_render($atts, false); });
|
||||
|
||||
/** Alt-Shortcode bleibt gültig und zeigt den Bruttopreis. */
|
||||
add_shortcode('skrift_preis', function ($atts) { return skrift_preis_render($atts, true); });
|
||||
Reference in New Issue
Block a user