13 Commits
v1.1.1 ... main

Author SHA1 Message Date
s4luorth
83182503e3 Textmengencheck ohne Abfragelimit
All checks were successful
Build & Deploy / deploy (push) Successful in 21s
generatePreview aus /textmenge sendet check:true -> Backend rendert unbegrenzt
und ohne Vorschau-Kontingent-Verbrauch.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-30 11:50:34 +02:00
s4luorth
ffef7a50de Textmengencheck: Ausrichtungs-Dropdown (hoch/quer) + robustes Laden
All checks were successful
Build & Deploy / deploy (push) Successful in 23s
Formate werden zu Familien (family) gruppiert; hat eine Familie mehrere
Ausrichtungen (orientation hoch/quer), erscheint ein zweites Dropdown.
fetchCheckFormats faellt auf aktive Formate zurueck, falls show_check
(vor dem Directus-Bootstrap) noch fehlt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-30 11:04:37 +02:00
s4luorth
ffb4de7fba Textmengencheck: A5 + DIN lang via show_check
All checks were successful
Build & Deploy / deploy (push) Successful in 21s
Neuer fetchCheckFormats() laedt Formate mit show_check=true (Text-Seiten-
Formate A4/A5/DIN-lang/A6), unabhaengig von der oeffentlichen Bestellbarkeit.
Textmenge nutzt das statt fetchFormats - so erscheinen A5 (hoch/quer) und
DIN lang (komplett beschrieben) im Check, Adress-Postkarten bleiben aussen vor.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-30 10:54:16 +02:00
s4luorth
dd9ab3736b Konfigurator: Sammelbestellung ohne Empfängerliste > 25 wieder bestellbar
All checks were successful
Build & Deploy / deploy (push) Successful in 22s
Die neue Mengen-Sperre (entries.length === quantity) hätte identische
Sammelkarten ohne Empfängerliste oberhalb der Tabellengrenze blockiert:
dort wuchs entries nie auf die Menge, weil der Sync-Effekt pauschal bei
> 25 pausierte. Der Sync pausiert jetzt nur noch, wenn pro Zeile wirklich
Daten nötig sind (Empfänger-/Individualtext-Schritt oder eigene
Platzhalter im Brieftext). Ohne solche Daten erzeugt die Menge N
Blanko-Zeilen – auch > 25. Extern-Modus unverändert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-21 11:57:59 +02:00
s4luorth
9a467b9fb5 Konfigurator: Abrechnungs-Integritaet + Motiv-Katalog nur mit Motiven
All checks were successful
Build & Deploy / deploy (push) Successful in 28s
Fehlbelastung behoben: PayPal belastete bisher den CLIENT-Quote
(quote.gross_total, mit der frei getippten Menge gerechnet), waehrend
der Auftrag serverseitig aus entries.length angelegt/bepreist wird.
Wichen die auseinander (Menge > 25, Sync pausiert, Menge nachtraeglich
geaendert), zahlte der Kunde z. B. fuer 150 Karten, angelegt wurden 15.
- PayPal belastet jetzt ausschliesslich den serverseitig bestaetigten
  Auftragsbetrag (ergebnis.gross_total), nie den Client-Quote.
- Checkout wird blockiert, solange entries.length !== quantity, mit
  klarer Meldung (Extern-/Datei-/preislose Produkte ausgenommen).

Motiv: Die Option "Motiv aus unserer Auswahl" wird nur noch angezeigt,
wenn fuer das Format Motive hinterlegt sind; sonst automatisch "Ohne
Motiv". Vor dem Laden bleibt die Option sichtbar (kein Aufblitzen).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-21 11:45:07 +02:00
s4luorth
aa5a0d960f Konfigurator: Freitext-Adresse nur bei Adressmodus 'freitext' senden
All checks were successful
Build & Deploy / deploy (push) Successful in 22s
Bei strukturierter Standardadresse wurde der Umschlag trotzdem mit dem
Freitext beschriftet, wenn im Freitextfeld noch Reste eines frueheren
Moduswechsels standen: das Backend (addressText) bevorzugt free_text.
free_text wird jetzt zentral in mitPlatzhaltern() normalisiert und in
Vorschau, Textpruefung und Bestellung identisch mitgeschickt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-18 12:59:56 +02:00
s4luorth
25547df815 Konfigurator: Platzhalter sicher ersetzen + Anrede kein Standard-Platzhalter
All checks were successful
Build & Deploy / deploy (push) Successful in 20s
- Standard-Platzhalter (Vorname/Nachname/Strasse/PLZ/Ort/Land) werden beim Senden
  je Empfänger explizit aufgelöst (standardWert) und mit den eigenen Platzhaltern
  in entries.placeholders geschrieben – so wird zuverlässig ersetzt (Vorschau +
  Bestellung).
- „Anrede" aus STANDARD_STRUKTUR entfernt (kein Empfängerfeld dafür) → [[Anrede]]
  ist jetzt ein frei befüllbarer eigener Platzhalter statt fälschlich als Standard.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-18 12:17:21 +02:00
s4luorth
b5669ee8a3 Motiv-Freigabe: Entwurf eingepasst statt beschnitten (objectFit contain)
All checks were successful
Build & Deploy / deploy (push) Successful in 23s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-18 11:47:49 +02:00
s4luorth
2386721e41 Motiv-Freigabe: öffentliche Seite /freigabe/:token (A6-Entwurf ansehen + freigeben)
All checks were successful
Build & Deploy / deploy (push) Successful in 23s
Kunde öffnet den Einmal-Link (kein Login), sieht den A6-Drucklayout-Entwurf und
gibt frei oder hinterlegt einen Änderungswunsch. API: fetchFreigabe/sendFreigabe/
freigabeLayoutUrl (öffentlich, token-gated).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-18 08:05:23 +02:00
s4luorth
bad4abc945 Muster & Angebot: Vor-/Nachname getrennt, „Ort" statt „Stadt", Angebot als Seite
All checks were successful
Build & Deploy / deploy (push) Successful in 21s
- Muster bestellen + individuelles Angebot: getrennte Felder Vorname/Nachname
  (statt einem Namensfeld).
- Muster-Formular: Feld „Stadt" → „Ort".
- „Individuelles Angebot" öffnet jetzt eine eigene Seite (/angebot) statt Popup.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-11 21:15:38 +02:00
s4luorth
1e90b8de09 Kundenangebote: Auswahl im Konfigurator (Aufschlaege + Name-Zusatz)
All checks were successful
Build & Deploy / deploy (push) Successful in 43s
Eingeloggte Kunden sehen ihre Angebote (customer_offers) je Produkt und waehlen eins;
Aufschlaege pro Stueck flie\u00dfen serverseitig in den Preis, Name-Zusatz + Notiz sichtbar.
Quote/Order senden jetzt den Token (auth) mit, damit die Angebote greifen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-03 12:10:23 +02:00
s4luorth
84427d8ca3 Motiv-/Datei-Upload: ueber oeffentlichen Proxy (Gaeste ohne Login)
All checks were successful
Build & Deploy / deploy (push) Successful in 45s
uploadFile schickt die Datei als Base64 an /skrift-orders/upload statt direkt an
Directus /files (das braucht einen Token, den Gaeste nicht haben).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-03 10:13:45 +02:00
s4luorth
04a04d6e86 /textmenge: volle App-Breite, Vorschau ohne Scroll + Vergroessern + Bild-Download
All checks were successful
Build & Deploy / deploy (push) Successful in 41s
- Wrapper .sk-konf (Breite 1:1 wie Konfigurator/neuer Auftrag).
- Vorschau skaliert (kein Inner-Scroll), Klick oeffnet Lightbox (Vergroessern).
- Buttons 'Vergroessern' und 'Bild herunterladen' (SVG->PNG).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-01 15:30:40 +02:00
8 changed files with 569 additions and 118 deletions

View File

@@ -7,6 +7,8 @@ import Auftraege from './routes/Auftraege';
import Stammdaten from './routes/Stammdaten'; import Stammdaten from './routes/Stammdaten';
import Configurator from './routes/Configurator'; import Configurator from './routes/Configurator';
import MusterSeite from './routes/Muster'; import MusterSeite from './routes/Muster';
import AngebotSeite from './routes/Angebot';
import FreigabeSeite from './routes/Freigabe';
import Textmenge from './routes/Textmenge'; import Textmenge from './routes/Textmenge';
import { loadSession } from './lib/api'; import { loadSession } from './lib/api';
@@ -36,8 +38,11 @@ export default function App() {
<Routes> <Routes>
<Route path="/" element={<Configurator />} /> <Route path="/" element={<Configurator />} />
<Route path="/login" element={<Login />} /> <Route path="/login" element={<Login />} />
{/* Muster als eigene Seite (kein Popup) – Direktlink /muster. */} {/* Muster & individuelles Angebot als eigene Seiten (kein Popup). */}
<Route path="/muster" element={<MusterSeite />} /> <Route path="/muster" element={<MusterSeite />} />
<Route path="/angebot" element={<AngebotSeite />} />
{/* Motiv-Freigabe über Einmal-Link (kein Login nötig). */}
<Route path="/freigabe/:token" element={<FreigabeSeite />} />
{/* Textmengen-Tool – öffentlich, Direktlink /textmenge. */} {/* Textmengen-Tool – öffentlich, Direktlink /textmenge. */}
<Route path="/textmenge" element={<Textmenge />} /> <Route path="/textmenge" element={<Textmenge />} />
<Route <Route

View File

@@ -180,7 +180,7 @@ function Angebot({ onClose, vorbelegung }: { onClose: () => void; vorbelegung?:
export function Muster({ onClose = () => {}, variant = 'dialog' }: { onClose?: () => void; variant?: 'dialog' | 'page' }) { export function Muster({ onClose = () => {}, variant = 'dialog' }: { onClose?: () => void; variant?: 'dialog' | 'page' }) {
const [produkte, setProdukte] = useState<Product[]>([]); const [produkte, setProdukte] = useState<Product[]>([]);
const [form, setForm] = useState({ const [form, setForm] = useState({
product: '', name: '', email: '', phone: '', product: '', first_name: '', last_name: '', email: '', phone: '',
street: '', zip: '', city: '', country: 'Deutschland', message: '', street: '', zip: '', city: '', country: 'Deutschland', message: '',
}); });
const [datenschutz, setDatenschutz] = useState(false); const [datenschutz, setDatenschutz] = useState(false);
@@ -195,7 +195,8 @@ export function Muster({ onClose = () => {}, variant = 'dialog' }: { onClose?: (
fetchMyCustomer() fetchMyCustomer()
.then((c) => c && setForm((f) => ({ .then((c) => c && setForm((f) => ({
...f, ...f,
name: [c.first_name, c.last_name].filter(Boolean).join(' ') || f.name, first_name: c.first_name || f.first_name,
last_name: c.last_name || f.last_name,
email: c.email || f.email, email: c.email || f.email,
phone: c.phone || f.phone, phone: c.phone || f.phone,
}))) })))
@@ -216,7 +217,8 @@ export function Muster({ onClose = () => {}, variant = 'dialog' }: { onClose?: (
try { try {
await sendSampleRequest({ await sendSampleRequest({
product: form.product, product: form.product,
name: form.name.trim(), first_name: form.first_name.trim(),
last_name: form.last_name.trim(),
email: form.email.trim(), email: form.email.trim(),
phone: form.phone.trim() || undefined, phone: form.phone.trim() || undefined,
street: form.street.trim(), street: form.street.trim(),
@@ -259,7 +261,7 @@ export function Muster({ onClose = () => {}, variant = 'dialog' }: { onClose?: (
</div> </div>
); );
const gueltig = form.product && form.name.trim() && istEmail(form.email) && form.phone.trim() const gueltig = form.product && form.first_name.trim() && form.last_name.trim() && istEmail(form.email) && form.phone.trim()
&& form.street.trim() && form.zip.trim() && form.city.trim() && datenschutz; && form.street.trim() && form.zip.trim() && form.city.trim() && datenschutz;
return wrap('Muster bestellen', ( return wrap('Muster bestellen', (
@@ -275,12 +277,13 @@ export function Muster({ onClose = () => {}, variant = 'dialog' }: { onClose?: (
{produkte.map((p) => <option key={p.key} value={p.key}>{p.name}</option>)} {produkte.map((p) => <option key={p.key} value={p.key}>{p.name}</option>)}
</select> </select>
</div> </div>
{feld('name', 'Name')} {feld('first_name', 'Vorname')}
{feld('last_name', 'Nachname')}
{feld('email', 'E-Mail-Adresse', false, 'email')} {feld('email', 'E-Mail-Adresse', false, 'email')}
{feld('phone', 'Telefon', false, 'tel')} {feld('phone', 'Telefon', false, 'tel')}
{feld('street', 'Straße und Hausnummer')} {feld('street', 'Straße und Hausnummer')}
{feld('zip', 'PLZ')} {feld('zip', 'PLZ')}
{feld('city', 'Stadt')} {feld('city', 'Ort')}
<div className="sk-field"> <div className="sk-field">
<label className="sk-label sk-label--required" htmlFor="m-country">Land</label> <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')}> <select id="m-country" className="sk-input" value={form.country} onChange={set('country')}>
@@ -398,10 +401,10 @@ function Sidebar({ onNavigate }: { onNavigate?: () => void }) {
<IconMail /> <IconMail />
<span>Muster bestellen</span> <span>Muster bestellen</span>
</NavLink> </NavLink>
<button className="sk-nav-item sk-nav-item--quiet" onClick={() => { open('angebot'); onNavigate?.(); }}> <NavLink to="/angebot" className="sk-nav-item sk-nav-item--quiet" onClick={onNavigate}>
<IconOffer /> <IconOffer />
<span>Individuelles Angebot</span> <span>Individuelles Angebot</span>
</button> </NavLink>
<button className="sk-nav-item sk-nav-item--quiet" onClick={() => { open('hilfe'); onNavigate?.(); }}> <button className="sk-nav-item sk-nav-item--quiet" onClick={() => { open('hilfe'); onNavigate?.(); }}>
<IconHelp /> <IconHelp />
<span>Hilfe</span> <span>Hilfe</span>

View File

@@ -15,7 +15,7 @@ const istEmail = (e: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(e.trim());
*/ */
export default function OfferForm({ produkt, onClose }: { produkt?: string; onClose?: () => void }) { export default function OfferForm({ produkt, onClose }: { produkt?: string; onClose?: () => void }) {
const gewaehltesProdukt = produkt?.trim() || ''; const gewaehltesProdukt = produkt?.trim() || '';
const [form, setForm] = useState({ name: '', email: '', phone: '', quantity: '', message: '' }); const [form, setForm] = useState({ first_name: '', last_name: '', email: '', phone: '', quantity: '', message: '' });
const [datenschutz, setDatenschutz] = useState(false); const [datenschutz, setDatenschutz] = useState(false);
const [status, setStatus] = useState<'formular' | 'sendet' | 'fertig'>('formular'); const [status, setStatus] = useState<'formular' | 'sendet' | 'fertig'>('formular');
const [fehler, setFehler] = useState<string[]>([]); const [fehler, setFehler] = useState<string[]>([]);
@@ -28,7 +28,8 @@ export default function OfferForm({ produkt, onClose }: { produkt?: string; onCl
if (!c) return; if (!c) return;
setForm((f) => ({ setForm((f) => ({
...f, ...f,
name: [c.first_name, c.last_name].filter(Boolean).join(' ') || f.name, first_name: c.first_name || f.first_name,
last_name: c.last_name || f.last_name,
email: c.email || f.email, email: c.email || f.email,
phone: c.phone || f.phone, phone: c.phone || f.phone,
})); }));
@@ -49,7 +50,8 @@ export default function OfferForm({ produkt, onClose }: { produkt?: string; onCl
setStatus('sendet'); setStatus('sendet');
try { try {
await sendOfferRequest({ await sendOfferRequest({
name: form.name.trim(), first_name: form.first_name.trim(),
last_name: form.last_name.trim(),
email: form.email.trim(), email: form.email.trim(),
phone: form.phone.trim() || undefined, phone: form.phone.trim() || undefined,
quantity: form.quantity ? Number(form.quantity) : null, quantity: form.quantity ? Number(form.quantity) : null,
@@ -90,8 +92,12 @@ export default function OfferForm({ produkt, onClose }: { produkt?: string; onCl
)} )}
<form style={{ marginTop: 'var(--sk-space-4)' }} onSubmit={absenden}> <form style={{ marginTop: 'var(--sk-space-4)' }} onSubmit={absenden}>
<div className="sk-field"> <div className="sk-field">
<label className="sk-label" htmlFor="a-name">Name</label> <label className="sk-label" htmlFor="a-vorname">Vorname</label>
<input id="a-name" className="sk-input" value={form.name} onChange={set('name')} required /> <input id="a-vorname" className="sk-input" value={form.first_name} onChange={set('first_name')} required />
</div>
<div className="sk-field">
<label className="sk-label" htmlFor="a-nachname">Nachname</label>
<input id="a-nachname" className="sk-input" value={form.last_name} onChange={set('last_name')} required />
</div> </div>
<div className="sk-field"> <div className="sk-field">
<label className="sk-label" htmlFor="a-mail">E-Mail</label> <label className="sk-label" htmlFor="a-mail">E-Mail</label>

View File

@@ -232,7 +232,13 @@ export type Product = {
input_mode: 'text' | 'datei'; input_mode: 'text' | 'datei';
}; };
export type Format = { id: number; key: string; name: string; product: number }; export type Format = {
id: number; key: string; name: string; product: number;
/** Familie fürs Ausrichtungs-Dropdown (z. B. A5, A6). Leer = eigener Eintrag. */
family?: string | null;
/** Ausrichtung innerhalb der Familie. Leer = keine Wahl. */
orientation?: 'hoch' | 'quer' | null;
};
/** /**
* Produkt-Schlüssel, die für den angemeldeten Kunden individuell freigeschaltet * Produkt-Schlüssel, die für den angemeldeten Kunden individuell freigeschaltet
@@ -268,6 +274,31 @@ export async function fetchFormats(): Promise<Format[]> {
return data?.data ?? []; return data?.data ?? [];
} }
/**
* Formate für den Textmengencheck: alle mit show_check=true. Das sind die
* Text-Seiten-Formate (A4, A5, DIN lang, A6) – unabhängig davon, ob sie im
* öffentlichen Konfigurator bestellbar sind. Adress-Postkarten sind ausgenommen.
*/
export async function fetchCheckFormats(): Promise<Format[]> {
const felder = 'fields=id,key,name,product,family,orientation';
try {
const data = await request(
`/items/formats?${felder}&filter[show_check][_eq]=true&limit=-1`,
{ auth: false },
);
const rows: Format[] = data?.data ?? [];
if (rows.length) return rows;
} catch {
/* Feld show_check evtl. noch nicht angelegt → Fallback unten. */
}
// Fallback (z. B. vor dem Directus-Bootstrap): aktive Formate, damit der Check nutzbar bleibt.
const aktiv = await request(
`/items/formats?${felder}&filter[active][_eq]=true&limit=-1`,
{ auth: false },
);
return aktiv?.data ?? [];
}
/* ── Preis & Bestellung (Extension skrift-orders) ─────────────────────────── */ /* ── Preis & Bestellung (Extension skrift-orders) ─────────────────────────── */
export type QuoteLine = { label: string; amount: number; per_unit?: boolean }; export type QuoteLine = { label: string; amount: number; per_unit?: boolean };
@@ -296,11 +327,33 @@ export type QuoteInput = {
email?: string; email?: string;
/** Segment für den Mengen-Multiplikator (B2B/B2C). */ /** Segment für den Mengen-Multiplikator (B2B/B2C). */
person_type?: 'privat' | 'unternehmen'; person_type?: 'privat' | 'unternehmen';
/** Gewähltes Kundenangebot (ID) – Aufschläge werden serverseitig addiert. */
offer?: number;
}; };
/** Preis serverseitig berechnen lassen – der Client rechnet nie selbst. */ /** Preis serverseitig berechnen lassen – der Client rechnet nie selbst.
* auth:true, damit bei eingeloggten Kunden der Token mitgeht (Kundenangebote). */
export async function fetchQuote(input: QuoteInput): Promise<Quote> { export async function fetchQuote(input: QuoteInput): Promise<Quote> {
return request('/skrift-orders/quote', { method: 'POST', body: input, auth: false }); return request('/skrift-orders/quote', { method: 'POST', body: input, auth: true });
}
/* ── Kundenangebote (customer_offers) ───────────────────────────────────────── */
export type OfferSurcharge = { label: string; amount: number };
export type CustomerOffer = {
id: number;
label: string;
name_suffix?: string | null;
note?: string | null;
allow_multi_page?: boolean;
surcharges?: OfferSurcharge[] | null;
product?: { key: string; name: string } | null;
};
/** Angebote des eingeloggten Kunden für ein Produkt (leer, wenn nicht eingeloggt). */
export async function fetchMyOffers(productKey: string): Promise<CustomerOffer[]> {
const data = await request(`/skrift-orders/my-offers?product=${encodeURIComponent(productKey)}`, { auth: true });
return data?.offers ?? [];
} }
/* ── Motive ───────────────────────────────────────────────────────────────── */ /* ── Motive ───────────────────────────────────────────────────────────────── */
@@ -372,6 +425,8 @@ export type PreviewInput = {
realistic?: boolean; realistic?: boolean;
envelope_labeling?: string; envelope_labeling?: string;
envelope_text?: string; envelope_text?: string;
/** Textmengencheck: unbegrenzt, ohne Vorschau-Kontingent. */
check?: boolean;
entries: unknown[]; entries: unknown[];
}; };
@@ -420,6 +475,8 @@ export type OrderInput = {
voucher_code?: string; voucher_code?: string;
payment_method: 'paypal' | 'rechnung'; payment_method: 'paypal' | 'rechnung';
source?: string; source?: string;
/** Gewähltes Kundenangebot (ID). */
offer?: number;
entries: unknown[]; entries: unknown[];
/** Motiv aus dem Katalog (ID) bzw. hochgeladene Datei (Directus-Datei-ID). */ /** Motiv aus dem Katalog (ID) bzw. hochgeladene Datei (Directus-Datei-ID). */
motif?: number; motif?: number;
@@ -436,7 +493,9 @@ export type OrderResult = {
}; };
export async function createOrder(input: OrderInput): Promise<OrderResult> { export async function createOrder(input: OrderInput): Promise<OrderResult> {
return request('/skrift-orders', { method: 'POST', body: input, auth: false }); // auth:true → eingeloggte Kunden senden ihren Token (Kundenangebote greifen);
// Gäste haben keine Session → kein Header, Verhalten wie zuvor.
return request('/skrift-orders', { method: 'POST', body: input, auth: true });
} }
/** Vermerkt eine erfolgreiche PayPal-Zahlung am Auftrag. */ /** Vermerkt eine erfolgreiche PayPal-Zahlung am Auftrag. */
@@ -481,23 +540,28 @@ export const paypalClientId = (s: PublicSettings | null) =>
/** Lädt eine Datei nach Directus und liefert deren ID. */ /** Lädt eine Datei nach Directus und liefert deren ID. */
export async function uploadFile(datei: File): Promise<string> { export async function uploadFile(datei: File): Promise<string> {
const form = new FormData(); // Als Base64 (Data-URL) an den öffentlichen Upload-Proxy schicken – so können
form.append('file', datei); // auch Gäste (Bestellung ohne Login) Motive/Quelldateien hochladen. Der direkte
// Directus-/files-Upload braucht einen Token, den Gäste nicht haben.
const session = await ensureFreshToken(); const data_base64 = await new Promise<string>((resolve, reject) => {
const res = await fetch(`${BASE}/files`, { const r = new FileReader();
method: 'POST', r.onload = () => resolve(String(r.result || ''));
headers: session ? { Authorization: `Bearer ${session.access_token}` } : undefined, r.onerror = () => reject(new Error('Datei konnte nicht gelesen werden.'));
body: form, r.readAsDataURL(datei);
}); });
const data = await parse(res); const data = await request('/skrift-orders/upload', {
return data?.data?.id; method: 'POST',
auth: false,
body: { filename: datei.name, content_type: datei.type || 'application/octet-stream', data_base64 },
});
return data?.id;
} }
/* ── Angebotsanfrage ──────────────────────────────────────────────────────── */ /* ── Angebotsanfrage ──────────────────────────────────────────────────────── */
export type OfferRequest = { export type OfferRequest = {
name: string; first_name: string;
last_name: string;
email: string; email: string;
phone?: string; phone?: string;
quantity?: number | null; quantity?: number | null;
@@ -514,7 +578,8 @@ export async function sendOfferRequest(input: OfferRequest) {
export type SampleRequest = { export type SampleRequest = {
product: string; product: string;
name: string; first_name: string;
last_name: string;
email: string; email: string;
phone?: string; phone?: string;
street: string; street: string;
@@ -528,6 +593,29 @@ export async function sendSampleRequest(input: SampleRequest) {
return request('/skrift-orders/sample-request', { method: 'POST', body: input, auth: false }); return request('/skrift-orders/sample-request', { method: 'POST', body: input, auth: false });
} }
/* ── Motiv-Freigabe (öffentlich, per Einmal-Token) ─────────────────────────── */
export type Freigabe = {
order_number: string;
product: string;
status: 'pending' | 'approved' | 'changes' | string;
note: string;
has_layout: boolean;
};
export async function fetchFreigabe(token: string): Promise<Freigabe> {
return request(`/skrift-orders/freigabe/${encodeURIComponent(token)}`, { auth: false });
}
export async function sendFreigabe(token: string, action: 'approve' | 'changes', note?: string) {
return request(`/skrift-orders/freigabe/${encodeURIComponent(token)}`, { method: 'POST', body: { action, note }, auth: false });
}
/** Direkte URL zum A6-Entwurfs-SVG (Token-gated, kein Login nötig). */
export function freigabeLayoutUrl(token: string) {
return `${BASE}/skrift-orders/freigabe/${encodeURIComponent(token)}/layout`;
}
/* ── Statusverlauf & Benachrichtigungen ───────────────────────────────────── */ /* ── Statusverlauf & Benachrichtigungen ───────────────────────────────────── */
export type StatusEvent = { export type StatusEvent = {

13
src/routes/Angebot.tsx Normal file
View File

@@ -0,0 +1,13 @@
import { useNavigate } from 'react-router-dom';
import OfferForm from '../components/OfferForm';
/** Individuelles Angebot als eigene Seite (Direktlink /angebot) – kein Popup. */
export default function AngebotSeite() {
const navigate = useNavigate();
return (
<div className="sk-view">
<h1>Individuelles Angebot anfordern</h1>
<OfferForm onClose={() => navigate('/')} />
</div>
);
}

View File

@@ -12,6 +12,7 @@ import {
fetchPriceItems, fetchPriceItems,
fetchProducts, fetchProducts,
fetchQuote, fetchQuote,
fetchMyOffers,
fetchSettings, fetchSettings,
generatePreview, generatePreview,
validateText, validateText,
@@ -26,6 +27,7 @@ import {
type Product, type Product,
type PublicSettings, type PublicSettings,
type Quote, type Quote,
type CustomerOffer,
} from '../lib/api'; } from '../lib/api';
import { ladePayPal, rendereButtons } from '../lib/paypal'; import { ladePayPal, rendereButtons } from '../lib/paypal';
import { ladeVorlageHerunter, leseEmpfaengerDatei } from '../lib/excel'; import { ladeVorlageHerunter, leseEmpfaengerDatei } from '../lib/excel';
@@ -42,8 +44,10 @@ const PRODUKT_TEXT: Record<string, string> = {
follow_ups: 'Automatisierte handschriftliche Nachfass-Mailings für mehr Rücklauf.', follow_ups: 'Automatisierte handschriftliche Nachfass-Mailings für mehr Rücklauf.',
}; };
/** Standard-Platzhalter der strukturierten Adresse (werden automatisch befüllt). */ /** Standard-Platzhalter der strukturierten Adresse (werden automatisch befüllt).
const STANDARD_STRUKTUR = ['Anrede', 'Vorname', 'Nachname', 'Strasse', 'PLZ', 'Ort', 'Land']; * Ohne „Anrede" – dafür gibt es kein Empfängerfeld; als [[Anrede]] ist es ein
* frei befüllbarer eigener Platzhalter. */
const STANDARD_STRUKTUR = ['Vorname', 'Nachname', 'Strasse', 'PLZ', 'Ort', 'Land'];
/** Standard-Platzhalter der Freitext-Adresse. */ /** Standard-Platzhalter der Freitext-Adresse. */
const STANDARD_FREITEXT = ['Zeile1', 'Zeile2', 'Zeile3', 'Zeile4', 'Zeile5']; const STANDARD_FREITEXT = ['Zeile1', 'Zeile2', 'Zeile3', 'Zeile4', 'Zeile5'];
@@ -302,6 +306,7 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri
const [motifMode, setMotifMode] = useState<MotifMode>(entwurf.motifMode ?? 'katalog'); const [motifMode, setMotifMode] = useState<MotifMode>(entwurf.motifMode ?? 'katalog');
const [motifs, setMotifs] = useState<Motif[]>([]); const [motifs, setMotifs] = useState<Motif[]>([]);
const [motifeGeladen, setMotifeGeladen] = useState(false);
const [motifId, setMotifId] = useState<number | null>(entwurf.motifId ?? null); const [motifId, setMotifId] = useState<number | null>(entwurf.motifId ?? null);
// Gestaltungsservice ist jetzt eine der sich gegenseitig ausschließenden // Gestaltungsservice ist jetzt eine der sich gegenseitig ausschließenden
// Motiv-Optionen – daher abgeleitet, kein eigener Zustand mehr. // Motiv-Optionen – daher abgeleitet, kein eigener Zustand mehr.
@@ -336,6 +341,9 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri
const [quote, setQuote] = useState<Quote | null>(null); const [quote, setQuote] = useState<Quote | null>(null);
const [quoteFehler, setQuoteFehler] = useState<string | null>(null); const [quoteFehler, setQuoteFehler] = useState<string | null>(null);
// Kundenangebote (customer_offers) für den eingeloggten Kunden + das Produkt.
const [offers, setOffers] = useState<CustomerOffer[]>([]);
const [offerId, setOfferId] = useState<number | null>(null);
const [previewDocs, setPreviewDocs] = useState<PreviewDoc[]>([]); const [previewDocs, setPreviewDocs] = useState<PreviewDoc[]>([]);
const [previewLaedt, setPreviewLaedt] = useState(false); const [previewLaedt, setPreviewLaedt] = useState(false);
@@ -352,7 +360,7 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri
const [sendet, setSendet] = useState(false); const [sendet, setSendet] = useState(false);
const [bestellFehler, setBestellFehler] = useState<string[]>([]); const [bestellFehler, setBestellFehler] = useState<string[]>([]);
const [bestellung, setBestellung] = useState<{ nummer: string; zahlung: string; bezahlt: boolean; extern?: boolean } | null>(null); const [bestellung, setBestellung] = useState<{ nummer: string; zahlung: string; bezahlt: boolean; betrag: number; extern?: boolean } | null>(null);
const [settings, setSettings] = useState<PublicSettings | null>(null); const [settings, setSettings] = useState<PublicSettings | null>(null);
const [preisKatalog, setPreisKatalog] = useState<Record<string, PriceItem>>({}); const [preisKatalog, setPreisKatalog] = useState<Record<string, PriceItem>>({});
@@ -396,7 +404,7 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri
// … Motive, Einstellungen und Preise dagegen optional. Sie dürfen den // … Motive, Einstellungen und Preise dagegen optional. Sie dürfen den
// Konfigurator nicht blockieren, wenn Felder fehlen (z. B. vor dem Bootstrap). // Konfigurator nicht blockieren, wenn Felder fehlen (z. B. vor dem Bootstrap).
fetchMotifs().then(setMotifs).catch(() => setMotifs([])); fetchMotifs().then(setMotifs).catch(() => setMotifs([])).finally(() => setMotifeGeladen(true));
fetchSettings().then(setSettings).catch(() => setSettings(null)); fetchSettings().then(setSettings).catch(() => setSettings(null));
fetchPriceItems() fetchPriceItems()
.then((items) => setPreisKatalog(Object.fromEntries(items.map((i) => [i.key, i])))) .then((items) => setPreisKatalog(Object.fromEntries(items.map((i) => [i.key, i]))))
@@ -495,6 +503,17 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri
[motifs, benoetigteAusrichtung], [motifs, benoetigteAusrichtung],
); );
const gewaehltesMotiv = sichtbareMotive.find((m) => m.id === motifId) ?? null; const gewaehltesMotiv = sichtbareMotive.find((m) => m.id === motifId) ?? null;
// Katalog-Auswahl nur anbieten, wenn (nach dem Laden) auch Motive hinterlegt
// sind. Vor dem Laden bleibt die Option sichtbar (Standard), damit sie nicht
// aufblitzt und wieder verschwindet.
const katalogVerfuegbar = sichtbareMotive.length > 0;
const katalogOptionSichtbar = !motifeGeladen || katalogVerfuegbar;
// Ist „Motiv aus unserer Auswahl" gewählt, aber es gibt (nach dem Laden) keine
// Motive für dieses Format, automatisch auf „Ohne Motiv" wechseln.
useEffect(() => {
if (motifeGeladen && !katalogVerfuegbar && motifMode === 'katalog') setMotifMode('ohne');
}, [motifeGeladen, katalogVerfuegbar, motifMode]);
// Produkt aus dem Direktlink (z. B. /briefe) vorwählen, sobald geladen – // Produkt aus dem Direktlink (z. B. /briefe) vorwählen, sobald geladen –
// überschreibt auch ein evtl. zwischengespeichertes anderes Produkt. // überschreibt auch ein evtl. zwischengespeichertes anderes Produkt.
@@ -565,23 +584,6 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri
useEffect(() => { setPreviewSession(null); }, useEffect(() => { setPreviewSession(null); },
[textTemplate, font, format, realistisch, labeling, needsEnvelope, umschlagText, entries]); [textTemplate, font, format, realistisch, labeling, needsEnvelope, umschlagText, entries]);
// Empfängerzeilen an die Menge angleichen (nur im Tabellenmodus). Gilt auch im
// Extern-Modus: die (vom Betreiber vorgegebene) Menge erzeugt die passende
// Anzahl Zeilen – egal ob mit oder ohne Kuvert/Empfänger.
useEffect(() => {
// Bei vielen Empfängern (Tabelle unpraktisch) wird auf Excel verwiesen und
// nicht synchronisiert – AUSSER im Extern-Modus ohne Empfänger-Schritt, wo die
// Menge einfach N Blanko-Dokumente erzeugen muss (auch über der Tabellengrenze).
if (nurUpload && (!externModus || empfaengerSchritt)) return;
setEntries((prev) => {
if (prev.length === quantity) return prev;
if (prev.length < quantity) {
return [...prev, ...Array.from({ length: quantity - prev.length }, leereEntry)];
}
return prev.slice(0, quantity);
});
}, [quantity, nurUpload, externModus, empfaengerSchritt]);
// Extern-Preset (vom Betreiber im Direktlink gesetzt) – der Kunde wählt nichts: // Extern-Preset (vom Betreiber im Direktlink gesetzt) – der Kunde wählt nichts:
// ?extern=1&versand=sammel|einzeln&kuvert=0|1&beschriftung=empfaenger|freitext|keine&menge=N // ?extern=1&versand=sammel|einzeln&kuvert=0|1&beschriftung=empfaenger|freitext|keine&menge=N
// Die Menge ist maßgeblich (greift immer); nur ein Excel-Import passt sie an die // Die Menge ist maßgeblich (greift immer); nur ein Excel-Import passt sie an die
@@ -613,6 +615,14 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri
// Produkt kommt aus dem Direktlink; Extern-Modus ist oben definiert. // Produkt kommt aus dem Direktlink; Extern-Modus ist oben definiert.
const angemeldet = !!loadSession(); const angemeldet = !!loadSession();
// Kundenangebote laden, sobald Produkt gewählt + eingeloggt (sonst keine).
useEffect(() => {
setOfferId(null);
if (!product || !angemeldet) { setOffers([]); return; }
let ab = false;
fetchMyOffers(product).then((o) => { if (!ab) setOffers(o); }).catch(() => { if (!ab) setOffers([]); });
return () => { ab = true; };
}, [product, angemeldet]);
// „Sie sind" entfällt, wenn die Einstufung schon feststeht (eingeloggt mit // „Sie sind" entfällt, wenn die Einstufung schon feststeht (eingeloggt mit
// person_type) oder im Extern-Modus. // person_type) oder im Extern-Modus.
const zeigeSieSind = !externModus && !(angemeldet && !!personType); const zeigeSieSind = !externModus && !(angemeldet && !!personType);
@@ -705,6 +715,7 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri
voucher_code: voucher.trim() || undefined, voucher_code: voucher.trim() || undefined,
// Bekannte E-Mail mitgeben, damit Kundenrabatte in den Live-Preis einfließen. // Bekannte E-Mail mitgeben, damit Kundenrabatte in den Live-Preis einfließen.
email: istEmail(rechnung.email) ? rechnung.email.trim() : undefined, email: istEmail(rechnung.email) ? rechnung.email.trim() : undefined,
offer: offerId ?? undefined,
}) })
.then((q) => !abgebrochen && setQuote(q)) .then((q) => !abgebrochen && setQuote(q))
.catch((err) => { .catch((err) => {
@@ -715,7 +726,7 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri
return () => { abgebrochen = true; }; return () => { abgebrochen = true; };
}, [product, kontaktSelektiert, format, preisMenge, shippingType, needsEnvelope, labeling, }, [product, kontaktSelektiert, format, preisMenge, shippingType, needsEnvelope, labeling,
motifMode, istPostkarte, textservice, designservice, voucher, rechnung.email, personType]); motifMode, istPostkarte, textservice, designservice, voucher, rechnung.email, personType, offerId]);
/** /**
* Einzelversand: Kuvert mit Empfängeradresse ist zwingend (wir adressieren * Einzelversand: Kuvert mit Empfängeradresse ist zwingend (wir adressieren
@@ -778,6 +789,49 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri
const eigeneImText = genutztePlatzhalter.filter((p) => !standardSet.has(p.toLowerCase())); const eigeneImText = genutztePlatzhalter.filter((p) => !standardSet.has(p.toLowerCase()));
/** Platzhalter im individuellen Umschlagtext – analog zur Text-Tabelle. */ /** Platzhalter im individuellen Umschlagtext – analog zur Text-Tabelle. */
const genutztePlatzhalterUmschlag = useMemo(() => textPlatzhalter(umschlagText), [umschlagText]); const genutztePlatzhalterUmschlag = useMemo(() => textPlatzhalter(umschlagText), [umschlagText]);
// Müssen pro Dokument eigene Daten erfasst werden? Das ist der Fall bei einem
// Empfänger-/Individualtext-Schritt oder bei eigenen Platzhaltern im Brieftext
// (jede Zeile trägt andere Werte → oberhalb der Tabellengrenze via Excel).
const zeilenDatenNoetig = !!empfaengerSchritt || eigeneImText.length > 0;
// Empfängerzeilen an die Menge angleichen. Unterhalb der Tabellengrenze immer;
// darüber nur, wenn KEINE Pflicht-Zeilendaten nötig sind (z. B. identische
// Sammelkarten ohne Empfängerliste) – dann erzeugt die Menge N Blanko-Zeilen.
// Werden pro Zeile Daten gebraucht, bleibt es beim Excel-Upload (nicht syncen).
// Im Extern-Modus wie bisher: syncen außer bei Empfänger-/Individualtext-Schritt.
useEffect(() => {
if (nurUpload && (externModus ? empfaengerSchritt : zeilenDatenNoetig)) return;
setEntries((prev) => {
if (prev.length === quantity) return prev;
if (prev.length < quantity) {
return [...prev, ...Array.from({ length: quantity - prev.length }, leereEntry)];
}
return prev.slice(0, quantity);
});
}, [quantity, nurUpload, externModus, empfaengerSchritt, zeilenDatenNoetig]);
/**
* Entry für den Versand ans Backend: löst ALLE im Text/Umschlag genutzten
* Platzhalter explizit auf (Standard-Platzhalter aus der Adresse via standardWert,
* eigene aus e.placeholders). So wird je Empfänger sicher ersetzt – unabhängig
* davon, wie das Backend die Adressfelder interpretiert.
*
* Zusätzlich wird der Freitext nur bei Adressmodus „freitext" mitgeschickt.
* Bei strukturierter Adresse muss free_text leer sein, sonst bevorzugt das
* Backend (addressText) den Freitext und beschriftet den Umschlag falsch –
* auch wenn im Freitextfeld nur Reste von einem früheren Moduswechsel stehen.
*/
const mitPlatzhaltern = (e: Entry): Entry => {
const used = [...new Set([...genutztePlatzhalter, ...genutztePlatzhalterUmschlag])];
return {
...e,
free_text: adressModus === 'freitext' ? e.free_text : '',
placeholders: {
...Object.fromEntries(used.map((p) => [p, standardWert(e, p)])),
...e.placeholders,
},
};
};
/** /**
* Werden Platzhalterwerte NICHT bereits in einem Empfänger-Schritt erfasst * Werden Platzhalterwerte NICHT bereits in einem Empfänger-Schritt erfasst
* (kein Adress-Schritt, kein Umschlag-Freitext)? Dann muss der Text-Schritt * (kein Adress-Schritt, kein Umschlag-Freitext)? Dann muss der Text-Schritt
@@ -807,7 +861,7 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri
.then(() => { .then(() => {
if (abgebrochen || !paypalRef.current) return; if (abgebrochen || !paypalRef.current) return;
return rendereButtons(paypalRef.current, { return rendereButtons(paypalRef.current, {
betrag: quote?.gross_total ?? 0, betrag: bestellung.betrag,
beschreibung: `Skrift ${gewaehltesProdukt?.name ?? ''} – ${bestellung.nummer}`, beschreibung: `Skrift ${gewaehltesProdukt?.name ?? ''} – ${bestellung.nummer}`,
onApproved: async (referenz) => { onApproved: async (referenz) => {
await confirmPayment(bestellung.nummer, referenz); await confirmPayment(bestellung.nummer, referenz);
@@ -819,7 +873,7 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri
.catch((err) => setBestellFehler([err.message ?? 'PayPal konnte nicht geladen werden.'])); .catch((err) => setBestellFehler([err.message ?? 'PayPal konnte nicht geladen werden.']));
return () => { abgebrochen = true; }; return () => { abgebrochen = true; };
}, [bestellung, settings, quote?.gross_total, gewaehltesProdukt?.name]); }, [bestellung, settings, gewaehltesProdukt?.name]);
const mwst = quote?.mwst_percent ?? settings?.mwst_percent ?? 19; const mwst = quote?.mwst_percent ?? settings?.mwst_percent ?? 19;
@@ -897,8 +951,16 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri
if (!paypalMoeglich && zahlung === 'paypal') setZahlung('rechnung'); if (!paypalMoeglich && zahlung === 'paypal') setZahlung('rechnung');
}, [paypalMoeglich, zahlung]); }, [paypalMoeglich, zahlung]);
// Sicherheitsnetz gegen Fehlbelastung: Die produzierte Menge (entries_count,
// serverseitig = entries.length) MUSS der berechneten/bezahlten Menge
// entsprechen. Weicht sie ab (z. B. Excel mit weniger Zeilen als gewählte
// Menge, oder nachträglich geänderte Menge), wird die Bestellung blockiert –
// sonst zahlt der Kunde für mehr Karten, als tatsächlich angelegt werden.
// Extern-/Datei-/Preislose Produkte haben ein anderes Mengenmodell → ausgenommen.
const mengeStimmt = externModus || dateiModus || keinePreise || entries.length === quantity;
// Ohne Preis (Extern-Modus/Datei-Produkt) gibt es keinen quote – dort reicht die Zustimmung. // Ohne Preis (Extern-Modus/Datei-Produkt) gibt es keinen quote – dort reicht die Zustimmung.
const bestellbereit = agb && datenschutz && final && (!!quote || keinePreise); const bestellbereit = agb && datenschutz && final && (!!quote || keinePreise) && mengeStimmt;
async function weiter() { async function weiter() {
// Beim Verlassen des Text-Schritts prüfen, ob das Schriftstück zu viele // Beim Verlassen des Text-Schritts prüfen, ob das Schriftstück zu viele
@@ -918,7 +980,7 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri
realistic: realistisch, realistic: realistisch,
envelope_labeling: needsEnvelope ? labeling : 'keine', envelope_labeling: needsEnvelope ? labeling : 'keine',
envelope_text: umschlagText, envelope_text: umschlagText,
entries: entries.slice(0, 3), entries: entries.slice(0, 3).map(mitPlatzhaltern),
}); });
if (r.has_overflow) { if (r.has_overflow) {
setTextUeberlauf(true); setTextUeberlauf(true);
@@ -967,7 +1029,7 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri
realistic: realistisch, realistic: realistisch,
envelope_labeling: needsEnvelope ? labeling : 'keine', envelope_labeling: needsEnvelope ? labeling : 'keine',
envelope_text: umschlagText, envelope_text: umschlagText,
entries: entries.slice(0, 3), entries: entries.slice(0, 3).map(mitPlatzhaltern),
}); });
setPreviewDocs(ergebnis.docs); setPreviewDocs(ergebnis.docs);
setPreviewVerbleibend(ergebnis.remaining); setPreviewVerbleibend(ergebnis.remaining);
@@ -1049,6 +1111,7 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri
...(designservice ? ['gestaltungsservice'] : []), ...(designservice ? ['gestaltungsservice'] : []),
], ],
voucher_code: voucher.trim() || undefined, voucher_code: voucher.trim() || undefined,
offer: offerId ?? undefined,
payment_method: keinePreise ? 'rechnung' : zahlung, payment_method: keinePreise ? 'rechnung' : zahlung,
source: externModus ? 'extern' : (loadSession() ? 'portal' : 'configurator'), source: externModus ? 'extern' : (loadSession() ? 'portal' : 'configurator'),
// Gezeigte Vorschau 1:1 übernehmen (nur wenn unverändert seit der Vorschau). // Gezeigte Vorschau 1:1 übernehmen (nur wenn unverändert seit der Vorschau).
@@ -1065,7 +1128,9 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri
street: lieferung.street, zip: lieferung.zip, city: lieferung.city, country: lieferung.country, street: lieferung.street, zip: lieferung.zip, city: lieferung.city, country: lieferung.country,
} }
: null, : null,
entries: entries.map((e) => ({ entries: entries.map((e) => {
const n = mitPlatzhaltern(e); // free_text-Normalisierung + Platzhalter
return {
salutation: e.salutation, salutation: e.salutation,
first_name: e.first_name, first_name: e.first_name,
last_name: e.last_name, last_name: e.last_name,
@@ -1074,11 +1139,12 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri
zip: e.zip, zip: e.zip,
city: e.city, city: e.city,
country: e.country, country: e.country,
free_text: adressModus === 'freitext' ? e.free_text : '', free_text: n.free_text,
placeholders: e.placeholders, placeholders: n.placeholders,
// Abweichender Text nur, wenn wirklich gesetzt (sonst greift der Standardtext). // Abweichender Text nur, wenn wirklich gesetzt (sonst greift der Standardtext).
text: (e.text && e.text.trim()) ? e.text : undefined, text: (e.text && e.text.trim()) ? e.text : undefined,
})), };
}),
}); });
// Auftrag ist abgesendet – der zwischengespeicherte Entwurf wird verworfen. // Auftrag ist abgesendet – der zwischengespeicherte Entwurf wird verworfen.
try { localStorage.removeItem(ENTWURF_KEY); } catch { /* egal */ } try { localStorage.removeItem(ENTWURF_KEY); } catch { /* egal */ }
@@ -1086,6 +1152,10 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri
nummer: ergebnis.order_number, nummer: ergebnis.order_number,
zahlung: ergebnis.payment_method, zahlung: ergebnis.payment_method,
bezahlt: ergebnis.payment_method !== 'paypal', bezahlt: ergebnis.payment_method !== 'paypal',
// Serverseitig bestätigter Betrag – NUR dieser wird per PayPal belastet,
// nie der Client-Quote (der könnte von der tatsächlich angelegten
// Auftragsmenge abweichen und den Kunden falsch belasten).
betrag: ergebnis.gross_total,
extern: externModus, extern: externModus,
}); });
} catch (err) { } catch (err) {
@@ -1234,6 +1304,31 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri
{/* ── Menge & Versand ─────────────────────────────────── */} {/* ── Menge & Versand ─────────────────────────────────── */}
{aktuell === 'Menge & Versand' && ( {aktuell === 'Menge & Versand' && (
<> <>
{offers.length > 0 && (
<section className="sk-card">
<h2>Ihr Angebot</h2>
<div className="sk-options" style={{ marginTop: 'var(--sk-space-4)' }}>
<Option name="offer" selected={offerId === null} onSelect={() => setOfferId(null)} title="Standard" />
{offers.map((o) => (
<Option key={o.id} name="offer" selected={offerId === o.id} onSelect={() => setOfferId(o.id)}
title={o.label + (o.name_suffix ? ` (${o.name_suffix})` : '')} />
))}
</div>
{(() => {
const o = offerId !== null ? offers.find((x) => x.id === offerId) : null;
if (!o) return null;
const sur = o.surcharges ?? [];
return (
<div style={{ marginTop: 'var(--sk-space-3)', fontSize: 13, color: 'var(--sk-muted, #666)', lineHeight: 1.5 }}>
{sur.length > 0 && (
<div>Aufschläge pro Stück: {sur.map((s) => `${s.label} +${Number(s.amount).toFixed(2)} €`).join(' · ')}</div>
)}
{o.note && <div style={{ marginTop: 4 }}>{o.note}</div>}
</div>
);
})()}
</section>
)}
<section className="sk-card"> <section className="sk-card">
<h2>Wie viele?</h2> <h2>Wie viele?</h2>
<div className="sk-field" style={{ marginTop: 'var(--sk-space-4)', marginBottom: 0 }}> <div className="sk-field" style={{ marginTop: 'var(--sk-space-4)', marginBottom: 0 }}>
@@ -1312,8 +1407,10 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri
{/* Einheitliche Radio-Karten in fester Reihenfolge – es ist immer {/* Einheitliche Radio-Karten in fester Reihenfolge – es ist immer
genau eine Option gewählt (auch der Gestaltungsservice). */} genau eine Option gewählt (auch der Gestaltungsservice). */}
<div className="sk-options" style={{ marginTop: 'var(--sk-space-4)' }}> <div className="sk-options" style={{ marginTop: 'var(--sk-space-4)' }}>
{katalogOptionSichtbar && (
<Option name="motif" selected={motifMode === 'katalog'} onSelect={() => setMotifMode('katalog')} <Option name="motif" selected={motifMode === 'katalog'} onSelect={() => setMotifMode('katalog')}
title="Motiv aus unserer Auswahl" desc="Wir zeigen Ihnen passende Motive" /> title="Motiv aus unserer Auswahl" desc="Wir zeigen Ihnen passende Motive" />
)}
<Option name="motif" selected={motifMode === 'ohne'} onSelect={() => setMotifMode('ohne')} <Option name="motif" selected={motifMode === 'ohne'} onSelect={() => setMotifMode('ohne')}
title="Ohne Motiv" desc="Blanko-Karte, nur beschrieben" /> title="Ohne Motiv" desc="Blanko-Karte, nur beschrieben" />
<Option name="motif" selected={motifMode === 'upload'} onSelect={() => setMotifMode('upload')} <Option name="motif" selected={motifMode === 'upload'} onSelect={() => setMotifMode('upload')}
@@ -1998,6 +2095,22 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri
</span> </span>
</label> </label>
{!mengeStimmt && (
<div role="alert" style={{
border: '1px solid var(--sk-danger, #c62828)',
background: 'rgba(198,40,40,0.08)',
color: 'var(--sk-danger, #c62828)',
borderRadius: 'var(--sk-radius, 10px)',
padding: 'var(--sk-space-3) var(--sk-space-4)',
margin: 'var(--sk-space-4) 0 0',
fontSize: 'var(--sk-size-sm)',
}}>
Ihre hinterlegten Empfänger/Dokumente ({entries.length}) stimmen nicht mit der
gewählten Menge ({quantity}) überein. Bitte gleichen Sie die Menge oder Ihre
Empfängerliste an, bevor Sie kostenpflichtig bestellen.
</div>
)}
{bestellFehler.length > 0 && ( {bestellFehler.length > 0 && (
<ul className="sk-error" style={{ margin: 'var(--sk-space-3) 0 0', paddingLeft: '1.1em' }}> <ul className="sk-error" style={{ margin: 'var(--sk-space-3) 0 0', paddingLeft: '1.1em' }}>
{bestellFehler.map((f) => <li key={f}>{f}</li>)} {bestellFehler.map((f) => <li key={f}>{f}</li>)}

105
src/routes/Freigabe.tsx Normal file
View File

@@ -0,0 +1,105 @@
import { useEffect, useState } from 'react';
import { useParams } from 'react-router-dom';
import { ApiError, fetchFreigabe, sendFreigabe, freigabeLayoutUrl, type Freigabe } from '../lib/api';
/**
* Öffentliche Motiv-Freigabe (Direktlink mit Einmal-Token, kein Login nötig).
* Zeigt den A6-Entwurf und lässt den Kunden freigeben oder einen Änderungswunsch
* hinterlegen. Erst nach Freigabe geht die Karte in den Druck.
*/
export default function FreigabeSeite() {
const { token = '' } = useParams();
const [data, setData] = useState<Freigabe | null>(null);
const [status, setStatus] = useState<'laedt' | 'da' | 'fehler'>('laedt');
const [fehler, setFehler] = useState('');
const [modus, setModus] = useState<'ansicht' | 'aendern'>('ansicht');
const [note, setNote] = useState('');
const [sendet, setSendet] = useState(false);
const [ergebnis, setErgebnis] = useState<'approved' | 'changes' | null>(null);
useEffect(() => {
fetchFreigabe(token)
.then((d) => { setData(d); setStatus('da'); if (d.status === 'approved' || d.status === 'changes') setErgebnis(d.status); })
.catch((e) => { setStatus('fehler'); setFehler(e instanceof ApiError ? e.message : 'Freigabe-Link ungültig oder abgelaufen.'); });
}, [token]);
async function absenden(action: 'approve' | 'changes') {
if (action === 'changes' && !note.trim()) { setFehler('Bitte beschreiben Sie kurz Ihren Änderungswunsch.'); return; }
setFehler(''); setSendet(true);
try {
await sendFreigabe(token, action, action === 'changes' ? note.trim() : undefined);
setErgebnis(action === 'approve' ? 'approved' : 'changes');
} catch (e) {
setFehler(e instanceof ApiError ? e.message : 'Konnte nicht gespeichert werden.');
} finally { setSendet(false); }
}
if (status === 'laedt') return <div className="sk-view"><p className="sk-note">Lädt …</p></div>;
if (status === 'fehler') return (
<div className="sk-view">
<h1>Freigabe</h1>
<p className="sk-error">{fehler}</p>
</div>
);
const d = data!;
const vorschau = (
<div style={{
margin: 'var(--sk-space-4) 0', maxWidth: 520, aspectRatio: '148 / 105', width: '100%',
border: '1px solid var(--sk-border)', borderRadius: 'var(--sk-radius)', overflow: 'hidden',
background: '#f4f6fa', boxShadow: '0 2px 10px rgba(0,0,0,.08)',
}}>
{d.has_layout
? <img src={freigabeLayoutUrl(token)} alt="A6-Entwurf" style={{ width: '100%', height: '100%', objectFit: 'contain', display: 'block' }} />
: <div style={{ display: 'grid', placeItems: 'center', height: '100%' }}><span className="sk-note">Entwurf wird noch erstellt …</span></div>}
</div>
);
if (ergebnis === 'approved') return (
<div className="sk-view">
<h1>Vielen Dank!</h1>
<p>Sie haben den Entwurf zu <strong>{d.order_number}</strong> freigegeben. Ihre Karte geht jetzt in den Druck.</p>
{vorschau}
</div>
);
if (ergebnis === 'changes') return (
<div className="sk-view">
<h1>Änderungswunsch erhalten</h1>
<p>Danke – wir haben Ihren Änderungswunsch zu <strong>{d.order_number}</strong> notiert und melden uns mit einem neuen Entwurf.</p>
{vorschau}
</div>
);
return (
<div className="sk-view">
<h1>Ihr Postkarten-Entwurf</h1>
<p className="sk-note">Bestellung {d.order_number} · {d.product}. Bitte prüfen Sie den Druck-Entwurf (A6). Erst nach Ihrer Freigabe geht die Karte in den Druck.</p>
{vorschau}
{fehler && <p className="sk-error" style={{ margin: '0 0 var(--sk-space-3)' }}>{fehler}</p>}
{modus === 'ansicht' ? (
<div style={{ display: 'flex', gap: 'var(--sk-space-3)', flexWrap: 'wrap' }}>
<button className="sk-btn sk-btn--primary" disabled={sendet} onClick={() => absenden('approve')}>
{sendet ? 'Wird gesendet …' : '✓ Entwurf freigeben'}
</button>
<button className="sk-btn" disabled={sendet} onClick={() => { setModus('aendern'); setFehler(''); }}>
Änderung wünschen
</button>
</div>
) : (
<div className="sk-field">
<label className="sk-label" htmlFor="f-note">Was sollen wir ändern?</label>
<textarea id="f-note" className="sk-input" rows={4} value={note} onChange={(e) => setNote(e.target.value)}
placeholder="z. B. Motiv etwas größer, anderer Ausschnitt, Text ergänzen …" />
<div style={{ display: 'flex', gap: 'var(--sk-space-3)', flexWrap: 'wrap', marginTop: 'var(--sk-space-3)' }}>
<button className="sk-btn sk-btn--primary" disabled={sendet} onClick={() => absenden('changes')}>
{sendet ? 'Wird gesendet …' : 'Änderungswunsch senden'}
</button>
<button className="sk-btn" disabled={sendet} onClick={() => { setModus('ansicht'); setFehler(''); }}>Zurück</button>
</div>
</div>
)}
</div>
);
}

View File

@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { fetchFormats, fetchSettings, generatePreview } from '../lib/api'; import { fetchCheckFormats, fetchSettings, generatePreview } from '../lib/api';
import type { Format, PublicSettings, PreviewDoc, PreviewOverflow } from '../lib/api'; import type { Format, PublicSettings, PreviewDoc, PreviewOverflow } from '../lib/api';
/** Schriftarten wie im Konfigurator/manuellen Druck. */ /** Schriftarten wie im Konfigurator/manuellen Druck. */
@@ -22,6 +22,41 @@ const inkify = (svg: string): string => {
.replace(/<\/svg>\s*$/i, '</g></svg>'); .replace(/<\/svg>\s*$/i, '</g></svg>');
}; };
/** Musterbild als PNG herunterladen (SVG → Canvas), wie im PreviewPanel. */
function bildSpeichern(svg: string) {
const m = svg.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 inkSvg = inkify(svg);
const url = URL.createObjectURL(new Blob([inkSvg], { type: 'image/svg+xml;charset=utf-8' }));
const img = new Image();
img.onload = () => {
const canvas = document.createElement('canvas');
canvas.width = w;
canvas.height = h;
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-textmenge.png';
a.click();
URL.revokeObjectURL(a.href);
}
URL.revokeObjectURL(url);
}, 'image/png');
} else {
URL.revokeObjectURL(url);
}
};
img.onerror = () => URL.revokeObjectURL(url);
img.src = url;
}
/** Stabile anonyme Kennung fürs Vorschau-Kontingent (wie im Konfigurator). */ /** Stabile anonyme Kennung fürs Vorschau-Kontingent (wie im Konfigurator). */
function clientId(): string { function clientId(): string {
const KEY = 'skrift.client'; const KEY = 'skrift.client';
@@ -52,7 +87,9 @@ function overflowMeldung(overflow?: PreviewOverflow[]) {
export default function Textmenge() { export default function Textmenge() {
const [formate, setFormate] = useState<Format[]>([]); const [formate, setFormate] = useState<Format[]>([]);
const [settings, setSettings] = useState<PublicSettings | null>(null); const [settings, setSettings] = useState<PublicSettings | null>(null);
const [format, setFormat] = useState('a4'); // Format wird über Familie (A4/A5/A6/DIN lang) + optionale Ausrichtung (hoch/quer) gewählt.
const [familie, setFamilie] = useState('');
const [ausrichtung, setAusrichtung] = useState('');
const [font, setFont] = useState('tilda'); const [font, setFont] = useState('tilda');
const [realistic, setRealistic] = useState(true); const [realistic, setRealistic] = useState(true);
const [text, setText] = useState(''); const [text, setText] = useState('');
@@ -61,17 +98,56 @@ export default function Textmenge() {
const [doc, setDoc] = useState<PreviewDoc | null>(null); const [doc, setDoc] = useState<PreviewDoc | null>(null);
const [laedt, setLaedt] = useState(false); const [laedt, setLaedt] = useState(false);
const [fehler, setFehler] = useState<string | null>(null); const [fehler, setFehler] = useState<string | null>(null);
const [gross, setGross] = useState(false);
useEffect(() => { useEffect(() => {
fetchFormats() fetchCheckFormats().then(setFormate).catch(() => {});
.then((f) => {
setFormate(f);
setFormat(f.find((x) => x.key === 'a4')?.key ?? f[0]?.key ?? 'a4');
})
.catch(() => {});
fetchSettings().then(setSettings).catch(() => {}); fetchSettings().then(setSettings).catch(() => {});
}, []); }, []);
// Formate zu Familien gruppieren (gleiche family = ein Eintrag mit Ausrichtungen).
// Formate ohne family bilden je einen eigenen Eintrag (Label = Name).
const familien = useMemo(() => {
const map = new Map<string, Format[]>();
for (const f of formate) {
const label = (f.family && f.family.trim()) || f.name;
if (!map.has(label)) map.set(label, []);
map.get(label)!.push(f);
}
return [...map.entries()].map(([label, items]) => ({ label, items }));
}, [formate]);
// Aktuelle Familie + verfügbare Ausrichtungen + effektiv gewähltes Format.
const aktuelleFamilie = familien.find((fm) => fm.label === familie) ?? familien[0] ?? null;
const ausrichtungen = (aktuelleFamilie?.items ?? []).filter((f) => f.orientation);
const hatAusrichtung = ausrichtungen.length > 1;
const gewaehltesFormat = aktuelleFamilie
? (hatAusrichtung
? (aktuelleFamilie.items.find((f) => f.orientation === ausrichtung) ?? aktuelleFamilie.items[0])
: aktuelleFamilie.items[0])
: null;
const formatKey = gewaehltesFormat?.key ?? 'a4';
// Standardauswahl setzen / reparieren, wenn Formate geladen sind oder sich die Familie ändert.
useEffect(() => {
if (!familien.length) return;
const gueltig = familien.some((fm) => fm.label === familie);
if (!gueltig) {
const start = familien.find((fm) => fm.items.some((f) => f.key === 'a4')) ?? familien[0];
setFamilie(start.label);
const ori = start.items.filter((f) => f.orientation);
setAusrichtung(ori.length > 1 ? (ori[0].orientation ?? '') : '');
}
}, [familien, familie]);
// Escape schließt die Großansicht.
useEffect(() => {
if (!gross) return;
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setGross(false); };
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [gross]);
// Gesperrte Zeichen wie im Konfigurator (Zeilenumbruch/Tab bleiben erlaubt). // Gesperrte Zeichen wie im Konfigurator (Zeilenumbruch/Tab bleiben erlaubt).
const verbotenRe = useMemo(() => { const verbotenRe = useMemo(() => {
const wl = settings?.char_whitelist; const wl = settings?.char_whitelist;
@@ -97,7 +173,8 @@ export default function Textmenge() {
useEffect(() => { useEffect(() => {
setDoc(null); setDoc(null);
setFehler(null); setFehler(null);
}, [text, font, format, realistic]); setGross(false);
}, [text, font, formatKey, realistic]);
async function generieren() { async function generieren() {
if (!text.trim() || laedt) return; if (!text.trim() || laedt) return;
@@ -108,8 +185,9 @@ export default function Textmenge() {
client_id: clientId(), client_id: clientId(),
text, text,
font, font,
format, format: formatKey,
realistic, realistic,
check: true,
entries: [{}], entries: [{}],
}); });
if (ergebnis.has_overflow) { if (ergebnis.has_overflow) {
@@ -127,24 +205,47 @@ export default function Textmenge() {
} }
return ( return (
<div style={{ maxWidth: 820, margin: '0 auto', padding: '40px 20px 72px' }}> <div className="sk-konf">
<div>
<h1 style={{ margin: '0 0 8px', fontSize: 28 }}>Textmenge prüfen</h1> <h1 style={{ margin: '0 0 8px', fontSize: 28 }}>Textmenge prüfen</h1>
<p style={{ color: 'var(--sk-muted, #666)', margin: '0 0 28px', lineHeight: 1.6 }}> <p style={{ color: 'var(--sk-muted, #666)', margin: 0, lineHeight: 1.6 }}>
Text eingeben, Format und Schrift wählen, dann eine Vorschau erzeugen. So siehst du, ob der Text auf ein Schriftstück passt. Text eingeben, Format und Schrift wählen, dann eine Vorschau erzeugen. So siehst du, ob der Text auf ein Schriftstück passt.
</p> </p>
</div>
<div className="sk-card"> <div className="sk-card">
<div className="sk-card-body" style={{ display: 'flex', flexDirection: 'column', gap: 20 }}> <div className="sk-card-body" style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
<div style={{ display: 'flex', gap: 16, flexWrap: 'wrap' }}> <div style={{ display: 'flex', gap: 16, flexWrap: 'wrap' }}>
<label className="sk-field" style={{ flex: '1 1 200px' }}> <label className="sk-field" style={{ flex: '1 1 200px' }}>
<span className="sk-label">Format</span> <span className="sk-label">Format</span>
<select className="sk-input" value={format} onChange={(e) => setFormat(e.target.value)}> <select
{formate.length === 0 && <option value="a4">A4</option>} className="sk-input"
{formate.map((f) => ( value={aktuelleFamilie?.label ?? ''}
<option key={f.key} value={f.key}>{f.name}</option> onChange={(e) => {
setFamilie(e.target.value);
const fam = familien.find((fm) => fm.label === e.target.value);
const ori = (fam?.items ?? []).filter((f) => f.orientation);
setAusrichtung(ori.length > 1 ? (ori[0].orientation ?? '') : '');
}}
>
{familien.length === 0 && <option value="">A4</option>}
{familien.map((fm) => (
<option key={fm.label} value={fm.label}>{fm.label}</option>
))} ))}
</select> </select>
</label> </label>
{hatAusrichtung && (
<label className="sk-field" style={{ flex: '1 1 200px' }}>
<span className="sk-label">Ausrichtung</span>
<select className="sk-input" value={ausrichtung} onChange={(e) => setAusrichtung(e.target.value)}>
{ausrichtungen.map((f) => (
<option key={f.key} value={f.orientation ?? ''}>
{f.orientation === 'hoch' ? 'Hochformat' : f.orientation === 'quer' ? 'Querformat' : f.name}
</option>
))}
</select>
</label>
)}
<label className="sk-field" style={{ flex: '1 1 200px' }}> <label className="sk-field" style={{ flex: '1 1 200px' }}>
<span className="sk-label">Schriftart</span> <span className="sk-label">Schriftart</span>
<select className="sk-input" value={font} onChange={(e) => setFont(e.target.value)}> <select className="sk-input" value={font} onChange={(e) => setFont(e.target.value)}>
@@ -170,7 +271,6 @@ export default function Textmenge() {
style={{ fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace', resize: 'vertical', lineHeight: 1.6 }} style={{ fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace', resize: 'vertical', lineHeight: 1.6 }}
onChange={(e) => setText(saeubere(e.target.value))} onChange={(e) => setText(saeubere(e.target.value))}
onKeyDown={(e) => { onKeyDown={(e) => {
// Tab fügt einen echten Tabulator ein (z. B. Datum an den rechten Rand).
if (e.key !== 'Tab' || e.shiftKey || e.ctrlKey || e.altKey || e.metaKey) return; if (e.key !== 'Tab' || e.shiftKey || e.ctrlKey || e.altKey || e.metaKey) return;
e.preventDefault(); e.preventDefault();
const ta = e.currentTarget; const ta = e.currentTarget;
@@ -184,7 +284,6 @@ export default function Textmenge() {
/> />
</label> </label>
{/* Button direkt unter dem Textfeld */}
<button <button
type="button" type="button"
className="sk-btn sk-btn--primary" className="sk-btn sk-btn--primary"
@@ -203,42 +302,38 @@ export default function Textmenge() {
</div> </div>
{/* Vorschau / Ergebnis */} {/* Vorschau / Ergebnis */}
<div className="sk-card" style={{ marginTop: 20 }}> <div className="sk-card">
<div className="sk-card-body"> <div className="sk-card-body">
{fehler ? ( {fehler ? (
<div <div
style={{ style={{
display: 'flex', display: 'flex', gap: 10, alignItems: 'flex-start', color: '#c62828',
gap: 10, background: 'rgba(198,40,40,.08)', border: '1px solid rgba(198,40,40,.25)',
alignItems: 'flex-start', borderRadius: 10, padding: '14px 16px', fontSize: 15,
color: '#c62828',
background: 'rgba(198,40,40,.08)',
border: '1px solid rgba(198,40,40,.25)',
borderRadius: 10,
padding: '14px 16px',
fontSize: 15,
}} }}
> >
<span style={{ fontSize: 18, lineHeight: 1 }}>⚠</span> <span style={{ fontSize: 18, lineHeight: 1 }}>⚠</span>
<span>{fehler}</span> <span>{fehler}</span>
</div> </div>
) : doc ? ( ) : doc ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 14, alignItems: 'center' }}>
<div <div style={{ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap', width: '100%' }}>
<span
style={{ style={{
alignSelf: 'flex-start', fontSize: 13, fontWeight: 600, padding: '4px 12px', borderRadius: 999,
fontSize: 13, background: 'rgba(46,125,50,.12)', color: '#2e7d32',
fontWeight: 600,
padding: '4px 12px',
borderRadius: 999,
background: 'rgba(46,125,50,.12)',
color: '#2e7d32',
}} }}
> >
✓ Passt auf eine Seite ✓ Passt auf eine Seite
</span>
<span style={{ flex: 1 }} />
<button type="button" className="sk-btn" onClick={() => setGross(true)}>🔍 Vergrößern</button>
<button type="button" className="sk-btn sk-btn--primary" onClick={() => bildSpeichern(doc.svg)}>⬇ Bild herunterladen</button>
</div> </div>
<div <div
style={{ background: '#fff', border: '1px solid var(--sk-border, #e2e2e2)', borderRadius: 10, padding: 12, overflow: 'auto', maxHeight: '70vh' }} className="sk-preview-doc-sheet"
onClick={() => setGross(true)}
style={{ width: '100%', maxWidth: 460, cursor: 'zoom-in' }}
dangerouslySetInnerHTML={{ __html: inkify(doc.svg) }} dangerouslySetInnerHTML={{ __html: inkify(doc.svg) }}
/> />
</div> </div>
@@ -249,6 +344,29 @@ export default function Textmenge() {
)} )}
</div> </div>
</div> </div>
{/* Großansicht (Lightbox) */}
{gross && doc && (
<div
onClick={() => setGross(false)}
style={{
position: 'fixed', inset: 0, background: 'rgba(0,0,0,.72)', zIndex: 1000,
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
gap: 16, padding: 24,
}}
>
<div style={{ display: 'flex', gap: 10 }} onClick={(e) => e.stopPropagation()}>
<button type="button" className="sk-btn sk-btn--primary" onClick={() => bildSpeichern(doc.svg)}>⬇ Bild herunterladen</button>
<button type="button" className="sk-btn" onClick={() => setGross(false)}>Schließen</button>
</div>
<div
className="sk-preview-doc-sheet"
onClick={(e) => e.stopPropagation()}
style={{ width: 'min(92vw, 62vh)', maxHeight: '86vh', overflow: 'auto' }}
dangerouslySetInnerHTML={{ __html: inkify(doc.svg) }}
/>
</div>
)}
</div> </div>
); );
} }