Public-API, mehrseitige Schriftstücke, manuelle Aufträge & Mailer

Public-API (neu, directus-extension-skrift-api):
- Versionierter /v1-Namespace: POST /v1/orders, GET /v1/orders/:nr (Status
  pending/processing/shipped/cancelled), POST /v1/files (nur PDF, ≤80 MB).
- Auth über X-Api-Key (Authorization ist von Directus reserviert), SHA-256-Hash.
- Produkt-gebunden; Kuvert-only-Produkttyp; Kuvertformat automatisch (A4→DIN Lang, sonst C6);
  kein Platzhalter, kein Kunden-multipage/font_scale; Versandart englisch (single/bulk).
- Selbst gehostete Swagger-UI-Doku (/docs) + OpenAPI (/v1/openapi.json), englisch/technisch.
- Key-Hook (directus-extension-skrift-apikey): erzeugt Key automatisch, zeigt ihn
  einmalig in key_plain.

Mehrseitige Schriftstücke:
- generateLetterPages (Auto-Fluss, [[seitenumbruch]], Leerseiten-Filter).
- Preview/Order-Generierung schreiben letter_NNN_pM.svg; Seiten-Übersicht im Neuauftrag.
- Agent (skrift-agent): Seiten je Brief absteigend an den Plotter (Stapel-Reihenfolge).
- countLetterPages + /api/order/pagecount für die Seiten-Vorschau.

Manuelle Aufträge:
- Schriftgröße (Brief/Kuvert), Signatur auf dem Brief mit X/Y/Größe-Feinjustierung,
  realistische Handschrift bis in den finalen Druck durchgereicht.
- Spalten-Einfügen in der Empfängertabelle (Excel-Spalte je Zelle).

Mailer: Text-Alternative + Reply-To (Zustellbarkeit).

App: Freitext in Musteranfrage, Konfigurator-Reset, Lightbox-Seitenverhältnis +
Bild-Download, Sticky-Vorschau-Fix, Tab-Eingabe + Rechtsbündigkeit, Leerzeilen-Trim.

Bootstrap: neue Felder (font_scale_*, signatures_manual, multi_page, page_count,
realistic, shipping_day, api_client) + Collections api_clients, Produkttyp envelope.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Lucas Orth
2026-08-18 10:01:53 +02:00
parent db419bf8eb
commit 1d5c1a14d4
24 changed files with 1327 additions and 121 deletions

View File

@@ -123,6 +123,17 @@ function Dialog({ title, onClose, children }: { title: string; onClose: () => vo
);
}
/** Konfigurator samt lokalem Cache zurücksetzen (Login bleibt erhalten). */
function konfiguratorZuruecksetzen() {
if (!window.confirm('Konfigurator zurücksetzen? Alle Eingaben und der lokale Zwischenspeicher werden gelöscht (die Anmeldung bleibt bestehen).')) return;
try {
Object.keys(localStorage)
.filter((k) => k.startsWith('skrift.') && k !== 'skrift.session')
.forEach((k) => localStorage.removeItem(k));
} catch { /* localStorage nicht verfügbar – dann reicht der Reload */ }
window.location.reload();
}
function Hilfe({ onClose }: { onClose: () => void }) {
return (
<Dialog title="Hilfe & Kontakt" onClose={onClose}>
@@ -138,6 +149,14 @@ function Hilfe({ onClose }: { onClose: () => void }) {
</a>
</div>
{/* Rettungsanker bei hängengebliebenem Zustand (z. B. falscher Zeilen-Hinweis). */}
<div className="sk-help-reset">
<p className="sk-note">Zeigt der Konfigurator unerwartetes Verhalten (z. B. einen falschen Zeilen-Hinweis)? Setzen Sie ihn hier zurück:</p>
<button type="button" className="sk-btn sk-btn--ghost" onClick={konfiguratorZuruecksetzen}>
Konfigurator zurücksetzen
</button>
</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>
@@ -162,7 +181,7 @@ export function Muster({ onClose = () => {}, variant = 'dialog' }: { onClose?: (
const [produkte, setProdukte] = useState<Product[]>([]);
const [form, setForm] = useState({
product: '', name: '', email: '', phone: '',
street: '', zip: '', city: '', country: 'Deutschland',
street: '', zip: '', city: '', country: 'Deutschland', message: '',
});
const [datenschutz, setDatenschutz] = useState(false);
const [status, setStatus] = useState<'formular' | 'sendet' | 'fertig'>('formular');
@@ -184,7 +203,7 @@ export function Muster({ onClose = () => {}, variant = 'dialog' }: { onClose?: (
}
}, []);
const set = (k: keyof typeof form) => (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
const set = (k: keyof typeof form) => (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) => {
const roh = e.target.value;
setForm({ ...form, [k]: k === 'phone' ? roh.replace(/[^0-9+ ]/g, '') : k === 'zip' ? roh.replace(/[^0-9]/g, '') : roh });
};
@@ -204,6 +223,7 @@ export function Muster({ onClose = () => {}, variant = 'dialog' }: { onClose?: (
zip: form.zip.trim(),
city: form.city.trim(),
country: form.country,
message: form.message.trim() || undefined,
});
setStatus('fertig');
} catch (err) {
@@ -267,6 +287,14 @@ export function Muster({ onClose = () => {}, variant = 'dialog' }: { onClose?: (
{LAENDER.map((l) => <option key={l} value={l}>{l}</option>)}
</select>
</div>
<div className="sk-field">
<label className="sk-label" htmlFor="m-message">
Nachricht <span className="sk-note" style={{ fontWeight: 400 }}>(optional)</span>
</label>
<textarea id="m-message" className="sk-input" rows={3} maxLength={1000}
placeholder="Wünsche oder Fragen zum Muster?"
value={form.message} onChange={set('message')} />
</div>
<label className="sk-check" style={{ marginBottom: 'var(--sk-space-4)' }}>
<input type="checkbox" checked={datenschutz} onChange={(e) => setDatenschutz(e.target.checked)} />

View File

@@ -69,6 +69,48 @@ export default function PreviewPanel({
}
function openZoom(svg: string) { setZoomBox(messeBox()); setZoomSvg(svg); }
/** Seitenverhältnis (Breite/Höhe) aus der viewBox – sperrt die Lightbox-Abmessungen. */
function seitenverhaeltnis(svg: string): string {
const m = svg.match(/viewBox="0 0 ([\d.]+) ([\d.]+)"/);
return m ? `${m[1]} / ${m[2]}` : '210 / 297';
}
/** Musterbild der großen Vorschau als PNG herunterladen (SVG → Canvas). */
function bildSpeichern() {
if (!zoomSvg) return;
const m = zoomSvg.match(/viewBox="0 0 ([\d.]+) ([\d.]+)"/);
const w = Math.round(m ? Number(m[1]) : 794);
const h = Math.round(m ? Number(m[2]) : 1123);
const scale = 2; // für ein scharfes Musterbild
const url = URL.createObjectURL(new Blob([zoomSvg], { type: 'image/svg+xml;charset=utf-8' }));
const img = new Image();
img.onload = () => {
const canvas = document.createElement('canvas');
canvas.width = w * scale;
canvas.height = h * scale;
const ctx = canvas.getContext('2d');
if (ctx) {
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
canvas.toBlob((png) => {
if (png) {
const a = document.createElement('a');
a.href = URL.createObjectURL(png);
a.download = 'skrift-musterbild.png';
a.click();
URL.revokeObjectURL(a.href);
}
URL.revokeObjectURL(url);
}, 'image/png');
} else {
URL.revokeObjectURL(url);
}
};
img.onerror = () => URL.revokeObjectURL(url);
img.src = url;
}
// Nach einer neuen Generierung wieder vorn beginnen.
useEffect(() => { setIndex(0); }, [docs]);
@@ -152,11 +194,17 @@ export default function PreviewPanel({
{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()}
<div className="sk-lightbox-sheet" style={{ aspectRatio: seitenverhaeltnis(zoomSvg) }}
onClick={(e) => e.stopPropagation()}
dangerouslySetInnerHTML={{ __html: zoomSvg }} />
<button className="sk-lightbox-close" onClick={() => setZoomSvg(null)} aria-label="Schließen">
Schließen
</button>
<div className="sk-lightbox-actions">
<button className="sk-lightbox-close" onClick={(e) => { e.stopPropagation(); bildSpeichern(); }}>
Bild speichern
</button>
<button className="sk-lightbox-close" onClick={() => setZoomSvg(null)} aria-label="Schließen">
Schließen
</button>
</div>
</div>
)}
</aside>

View File

@@ -521,6 +521,7 @@ export type SampleRequest = {
zip: string;
city: string;
country: string;
message?: string;
};
export async function sendSampleRequest(input: SampleRequest) {

View File

@@ -1684,6 +1684,12 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri
const en = ta.selectionEnd ?? s;
setDocText(aktDocText.slice(0, s) + '\t' + aktDocText.slice(en));
requestAnimationFrame(() => { ta.selectionStart = ta.selectionEnd = s + 1; });
}}
onBlur={() => {
// Leerzeilen am Ende automatisch entfernen (zählen sonst als
// Zeilen mit und können das Zeilenlimit fälschlich sprengen).
const beschnitten = aktDocText.replace(/(?:\r?\n[ \t]*)+$/, '');
if (beschnitten !== aktDocText) setDocText(beschnitten);
}} />
<p className={textZuLang ? 'sk-error' : 'sk-note'} style={{ marginTop: 'var(--sk-space-2)' }}>
{aktDocText.length}/{maxText} Zeichen

View File

@@ -366,7 +366,9 @@ button { font-family: inherit; }
.sk-pricebar {
position: sticky;
top: 0;
z-index: 10;
/* Muss ÜBER der (ebenfalls sticky) Vorschau liegen, sonst schiebt sich die
Vorschau beim Scrollen über die Preiszeile (Kopfzeile). */
z-index: 30;
background: var(--sk-white);
border-bottom: 1px solid var(--sk-border);
padding: var(--sk-space-3) var(--sk-space-6);
@@ -416,13 +418,14 @@ button { font-family: inherit; }
.sk-preview {
position: sticky;
top: calc(var(--sk-space-6) + 40px);
/* Unterhalb der sticky Preisleiste parken, damit beide sich nicht überlagern. */
top: 84px;
border: 1px solid var(--sk-border);
border-radius: var(--sk-radius-lg);
padding: var(--sk-space-4);
background: var(--sk-white);
/* Über den (sticky) Tabellenzellen, damit die Platzhalter-Tabelle nicht in
die Vorschau-Spalte hineinmalt. */
die Vorschau-Spalte hineinmalt – aber UNTER der Preisleiste (z-index 30). */
z-index: 20;
}
.sk-preview-head {
@@ -691,22 +694,25 @@ button { font-family: inherit; }
background: var(--sk-white);
border-radius: var(--sk-radius);
padding: var(--sk-space-4);
/* Auf den Inhalt schrumpfen, damit die Seite als klar abgesetztes Blatt
schwebt und nicht über die halbe Fläche „ausläuft". */
box-sizing: border-box;
/* Das Seitenverhältnis wird inline aus der viewBox gesetzt (aspectRatio).
Zusammen mit beiden Grenzen skaliert das Blatt IMMER proportional in die
jeweils kleinere Grenze – nie verzerrt, egal welche Fenstergröße. */
max-width: min(92vw, 720px);
max-height: 80vh;
width: auto;
height: auto;
overflow: hidden;
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.45);
}
/* Über die Höhe begrenzen: Das SVG trägt sein A4-Seitenverhältnis (mm) selbst,
daher skaliert es proportional und die ganze Seite bleibt sichtbar – kein
Überlaufen mehr wie bei width:100%. */
.sk-lightbox-sheet svg {
display: block;
width: auto;
height: auto;
max-width: 100%;
max-height: 78vh;
width: 100%;
height: 100%;
object-fit: contain;
}
/* Aktionszeile (Bild speichern / Schließen) unter dem Blatt. */
.sk-lightbox-actions { display: flex; gap: var(--sk-space-3); }
.sk-lightbox-close {
font: inherit;
font-size: var(--sk-size-sm);
@@ -1085,6 +1091,18 @@ textarea.sk-input { resize: vertical; }
font-size: 13px;
}
/* Reset-Hinweis + Button: eigener, abgesetzter Block mit Luft (sonst gequetscht). */
.sk-help-reset {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--sk-space-3);
margin-top: var(--sk-space-5);
padding-top: var(--sk-space-4);
border-top: 1px solid var(--sk-border);
}
.sk-help-reset .sk-note { margin: 0; }
/* Kontakt-Produkt: sieht aus wie eine Option, führt aber zum Angebot. */
.sk-option--contact { cursor: pointer; }
.sk-option--contact .sk-option-price-unit { color: var(--sk-accent); font-weight: 600; }