7 Commits

Author SHA1 Message Date
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 374 additions and 65 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

@@ -296,11 +296,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 ───────────────────────────────────────────────────────────────── */
@@ -420,6 +442,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 +460,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 +507,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 +545,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 +560,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'];
@@ -336,6 +340,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);
@@ -613,6 +620,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 +720,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 +731,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 +794,22 @@ 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]);
/**
* 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.
*/
const mitPlatzhaltern = (e: Entry): Entry => {
const used = [...new Set([...genutztePlatzhalter, ...genutztePlatzhalterUmschlag])];
return {
...e,
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
@@ -918,7 +950,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);
@@ -1049,6 +1081,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).
@@ -1075,7 +1108,7 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri
city: e.city, city: e.city,
country: e.country, country: e.country,
free_text: adressModus === 'freitext' ? e.free_text : '', free_text: adressModus === 'freitext' ? e.free_text : '',
placeholders: e.placeholders, placeholders: mitPlatzhaltern(e).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,
})), })),
@@ -1234,6 +1267,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 }}>

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

@@ -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';
@@ -61,6 +96,7 @@ 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() fetchFormats()
@@ -72,6 +108,14 @@ export default function Textmenge() {
fetchSettings().then(setSettings).catch(() => {}); fetchSettings().then(setSettings).catch(() => {});
}, []); }, []);
// 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,6 +141,7 @@ export default function Textmenge() {
useEffect(() => { useEffect(() => {
setDoc(null); setDoc(null);
setFehler(null); setFehler(null);
setGross(false);
}, [text, font, format, realistic]); }, [text, font, format, realistic]);
async function generieren() { async function generieren() {
@@ -127,11 +172,13 @@ 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 }}>
@@ -170,7 +217,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 +230,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 +248,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 +290,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>
); );
} }