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:
@@ -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)} />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -521,6 +521,7 @@ export type SampleRequest = {
|
||||
zip: string;
|
||||
city: string;
|
||||
country: string;
|
||||
message?: string;
|
||||
};
|
||||
|
||||
export async function sendSampleRequest(input: SampleRequest) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/appshell.tsx","./src/components/icons.tsx","./src/components/offerform.tsx","./src/components/previewpanel.tsx","./src/lib/api.ts","./src/lib/dialogs.tsx","./src/lib/excel.ts","./src/lib/paypal.ts","./src/lib/status.ts","./src/lib/wizard.tsx","./src/routes/auftraege.tsx","./src/routes/configurator.tsx","./src/routes/login.tsx","./src/routes/stammdaten.tsx"],"version":"5.9.3"}
|
||||
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/appshell.tsx","./src/components/icons.tsx","./src/components/offerform.tsx","./src/components/previewpanel.tsx","./src/lib/api.ts","./src/lib/dialogs.tsx","./src/lib/excel.ts","./src/lib/paypal.ts","./src/lib/status.ts","./src/lib/wizard.tsx","./src/routes/auftraege.tsx","./src/routes/configurator.tsx","./src/routes/login.tsx","./src/routes/muster.tsx","./src/routes/stammdaten.tsx"],"version":"5.9.3"}
|
||||
Reference in New Issue
Block a user