Unterschriftenservice, Extern-Modus, Adressmodell, Alt-Mail, Team-Mails
skrift-app (Konfigurator): - Datei-Produkte (Unterschriftenservice): Mehrfach-Upload (PDF/Word/Excel), komplett preisfrei (keine Preisleiste/Aufpreise/Kostenübersicht/Zahlung) - Extern-/Etsy-Modus (?extern=1&versand&kuvert&beschriftung&menge): vom Betreiber vorkonfiguriert, Kunde füllt nur Empfänger/Text/Rechnungsdaten/Übersicht, keine Preise/Zahlung; Menge greift immer (auch >25 / ohne Empfänger); eigener Danke-Text - Adressmodell: zwei feste Adressen (Rechnung/Lieferung) ohne Namen statt Dropdown; Auto-Vorbefüllung; Lieferadresse nur bei Sammelversand; abweichende Lieferadresse per Default aus; Pflichtfelder erzwungen - Platzhalter-Tabelle immer im Text-Schritt (auch Sammelversand ohne Kuvert) + Excel; Tabellen-Eingabe nur bis 25, „Ab 26"-Hinweis entfernt - Stammdaten: zwei Adressblöcke + Erfolgsmeldung Directus/Backend: - Signatur-Endpunkte /signature-preview + /signature-render (Proxy zum signature-service, Marker + Konto-Offsets), Modul-Reiter „Unterschriften" (aus Auftrag oder manuell), Tabelle über volle Breite - Generierung läuft bei jeder Bestellung; Kuverts immer (Datei-Produkt: nur Kuvert, DIN Lang als Default); source_files am Auftrag - Produktions-Board: „Dateien"-Button (hochgeladene Originale, keine SVGs) - Alt-Mail-Login: Zweit-Mails melden im selben Konto an (skrift-auth) - Team-Mails schlank + konfigurierbar (email_settings.team_email) für Bestellung/Muster/Anfrage; keine Bestellbestätigung bei Datei-Produkten, separate Vorlagen unterschriften_eingang/extern_eingang - Rechnungsadresse wird ins Konto übernommen, wenn dort noch keine steht - Kunden-Policy: directus_files create/read (Upload); Bootstrap: users billing_address/shipping_address/alt_emails, orders.source_files/source=extern, signatures marker/offsets, unterschriftenservice input_mode=datei/pricing_mode=formel Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -171,6 +171,8 @@ export type CustomerAddress = {
|
||||
city?: string;
|
||||
country?: string;
|
||||
};
|
||||
/** Feste Adresse ohne Bezeichnung (Rechnung bzw. Lieferung). */
|
||||
export type Adresse2 = { street?: string; zip?: string; city?: string; country?: string };
|
||||
export type Customer = {
|
||||
/** Directus-User-ID (UUID) – Kunde ist das Login-Konto selbst. */
|
||||
id: string;
|
||||
@@ -180,8 +182,13 @@ export type Customer = {
|
||||
last_name: string | null;
|
||||
company: string | null;
|
||||
phone: string | null;
|
||||
/** Mehrere hinterlegte Adressen (Rechnung/Lieferung). */
|
||||
addresses: CustomerAddress[] | null;
|
||||
/** Genau zwei feste Adressen ohne Namen. */
|
||||
billing_address: Adresse2 | null;
|
||||
shipping_address: Adresse2 | null;
|
||||
/** Weitere Login-Mailadressen (nur Anzeige; Pflege durch den Betreiber). */
|
||||
alt_emails: string[] | null;
|
||||
/** Veraltet – frühere Mehrfach-Adressliste (nur für Alt-Daten). */
|
||||
addresses?: CustomerAddress[] | null;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -190,7 +197,7 @@ export type Customer = {
|
||||
*/
|
||||
export async function fetchMyCustomer(): Promise<Customer | null> {
|
||||
const data = await request(
|
||||
'/users/me?fields=id,email,person_type,first_name,last_name,company,phone,addresses',
|
||||
'/users/me?fields=id,email,person_type,first_name,last_name,company,phone,billing_address,shipping_address,alt_emails',
|
||||
);
|
||||
return data?.data ?? null;
|
||||
}
|
||||
@@ -204,7 +211,8 @@ export async function saveMyCustomer(c: Customer) {
|
||||
last_name: c.last_name,
|
||||
company: c.company,
|
||||
phone: c.phone,
|
||||
addresses: c.addresses ?? [],
|
||||
billing_address: c.billing_address ?? null,
|
||||
shipping_address: c.shipping_address ?? null,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -401,6 +409,8 @@ export type OrderInput = {
|
||||
text_briefing?: string;
|
||||
/** Hochgeladenes Dokument (Datei-Produkte, z. B. zu unterschreibendes PDF). */
|
||||
source_file?: string;
|
||||
/** Alle hochgeladenen Dokumente (Datei-Produkte, mehrere möglich). */
|
||||
source_files?: string[];
|
||||
/** Rechnungs-/Lieferadresse als Snapshot (für Bestätigung & Rechnung). */
|
||||
billing?: Record<string, string>;
|
||||
shipping_address?: Record<string, string> | null;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import {
|
||||
ApiError,
|
||||
assetUrl,
|
||||
@@ -25,7 +26,6 @@ import {
|
||||
type Product,
|
||||
type PublicSettings,
|
||||
type Quote,
|
||||
type CustomerAddress,
|
||||
} from '../lib/api';
|
||||
import { ladePayPal, rendereButtons } from '../lib/paypal';
|
||||
import { ladeVorlageHerunter, leseEmpfaengerDatei } from '../lib/excel';
|
||||
@@ -270,6 +270,10 @@ const LAENDER = ['Deutschland', 'Österreich', 'Schweiz', 'Luxemburg', 'Belgien'
|
||||
|
||||
export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: string }) {
|
||||
const { publish, registerGoto } = useWizard();
|
||||
const [searchParams] = useSearchParams();
|
||||
// Externer/Etsy-Modus (?extern=1): Bestellung ohne Preise & Zahlung,
|
||||
// nur Empfänger → Text → Übersicht (siehe schritte weiter unten).
|
||||
const externModus = searchParams.get('extern') === '1';
|
||||
|
||||
// Zwischengespeicherten Entwurf einmalig laden (siehe ENTWURF_KEY).
|
||||
const entwurf = useMemo(ladeEntwurf, []);
|
||||
@@ -344,7 +348,7 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri
|
||||
|
||||
const [sendet, setSendet] = useState(false);
|
||||
const [bestellFehler, setBestellFehler] = useState<string[]>([]);
|
||||
const [bestellung, setBestellung] = useState<{ nummer: string; zahlung: string; bezahlt: boolean } | null>(null);
|
||||
const [bestellung, setBestellung] = useState<{ nummer: string; zahlung: string; bezahlt: boolean; extern?: boolean } | null>(null);
|
||||
|
||||
const [settings, setSettings] = useState<PublicSettings | null>(null);
|
||||
const [preisKatalog, setPreisKatalog] = useState<Record<string, PriceItem>>({});
|
||||
@@ -375,7 +379,8 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri
|
||||
}, [zeichenHinweis]);
|
||||
const [motivDatei, setMotivDatei] = useState<File | null>(null);
|
||||
/** Hochgeladenes Dokument bei Datei-Produkten (z. B. zu unterschreibendes PDF). */
|
||||
const [quellDatei, setQuellDatei] = useState<File | null>(null);
|
||||
/** Hochgeladene Dokumente bei Datei-Produkten (mehrere PDF/Word/Excel möglich). */
|
||||
const [quellDateien, setQuellDateien] = useState<File[]>([]);
|
||||
const paypalRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -407,17 +412,16 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri
|
||||
fetchMyUnlockedProducts().then(setFreigeschaltet).catch(() => setFreigeschaltet([]));
|
||||
}, []);
|
||||
|
||||
/** Gespeicherte Adressen des angemeldeten Kunden (für die Adressauswahl). */
|
||||
const [kundenAdressen, setKundenAdressen] = useState<CustomerAddress[]>([]);
|
||||
|
||||
// Angemeldete Kunden: Einstufung und Rechnungsdaten übernehmen.
|
||||
// Angemeldete Kunden: Einstufung + die zwei festen Adressen automatisch
|
||||
// übernehmen (Rechnung → Rechnungsdaten, Lieferung → abweichende Lieferadresse).
|
||||
// Kein Adress-Dropdown mehr; alles wird direkt eingesetzt.
|
||||
useEffect(() => {
|
||||
if (!loadSession()) return;
|
||||
fetchMyCustomer()
|
||||
.then((c) => {
|
||||
if (!c) return;
|
||||
if (c.person_type) setPersonType(c.person_type);
|
||||
setKundenAdressen(c.addresses ?? []);
|
||||
const b = c.billing_address ?? {};
|
||||
setRechnung((r) => ({
|
||||
...r,
|
||||
first_name: c.first_name ?? r.first_name,
|
||||
@@ -425,46 +429,27 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri
|
||||
company: c.company ?? r.company,
|
||||
email: c.email ?? r.email,
|
||||
phone: c.phone ?? r.phone,
|
||||
street: b.street ?? r.street,
|
||||
zip: b.zip ?? r.zip,
|
||||
city: b.city ?? r.city,
|
||||
country: b.country ?? r.country,
|
||||
}));
|
||||
// Lieferadresse vorbefüllen, aber „abweichende Lieferadresse" bleibt per
|
||||
// Default AUS – der Kunde aktiviert sie bei Bedarf selbst.
|
||||
const s = c.shipping_address ?? {};
|
||||
if (s.street || s.zip || s.city) {
|
||||
setLieferung((l) => ({
|
||||
...l,
|
||||
street: s.street ?? l.street,
|
||||
zip: s.zip ?? l.zip,
|
||||
city: s.city ?? l.city,
|
||||
country: s.country ?? l.country,
|
||||
}));
|
||||
}
|
||||
})
|
||||
.catch(() => { /* Vorausfüllen ist optional */ });
|
||||
}, []);
|
||||
|
||||
/** Adressfelder aus einer gespeicherten Adresse füllen. */
|
||||
const uebernimm = (a: CustomerAddress) => (r: Adresse): Adresse => ({
|
||||
...r,
|
||||
street: a.street ?? r.street,
|
||||
zip: a.zip ?? r.zip,
|
||||
city: a.city ?? r.city,
|
||||
country: a.country ?? r.country,
|
||||
});
|
||||
const adresseUebernehmen = (a: CustomerAddress) => setRechnung(uebernimm(a));
|
||||
const lieferungUebernehmen = (a: CustomerAddress) => setLieferung(uebernimm(a));
|
||||
|
||||
/** Hervorgehobene Auswahl gespeicherter Adressen (nur mit Login und Adressen). */
|
||||
const adressWahl = (id: string, uebernehmen: (a: CustomerAddress) => void) =>
|
||||
kundenAdressen.length === 0 ? null : (
|
||||
<div className="sk-field" style={{
|
||||
marginTop: 'var(--sk-space-5)',
|
||||
marginBottom: 'var(--sk-space-5)',
|
||||
padding: 'var(--sk-space-4)',
|
||||
border: '1px solid var(--sk-accent, #1E3A6A)',
|
||||
borderRadius: 'var(--sk-radius, 10px)',
|
||||
background: 'rgba(30, 58, 106, 0.04)',
|
||||
}}>
|
||||
<label className="sk-label" htmlFor={id}>Gespeicherte Adresse übernehmen</label>
|
||||
<select id={id} className="sk-input" defaultValue=""
|
||||
onChange={(e) => { const i = Number(e.target.value); if (!Number.isNaN(i) && kundenAdressen[i]) uebernehmen(kundenAdressen[i]); }}>
|
||||
<option value="">Bitte wählen …</option>
|
||||
{kundenAdressen.map((a, i) => (
|
||||
<option key={i} value={i}>
|
||||
{[a.label, a.street, [a.zip, a.city].filter(Boolean).join(' ')].filter(Boolean).join(' · ') || `Adresse ${i + 1}`}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Sichtbarkeit je Personentyp (im Admin über products.visibility pflegbar).
|
||||
// Ein per Direktlink vorgewähltes Produkt bleibt immer sichtbar – sonst würde
|
||||
// die Anfrage abbrechen, sobald z. B. „privat" bei einem Unternehmens-Produkt gewählt wird.
|
||||
@@ -490,6 +475,9 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri
|
||||
// Datei-Produkt (z. B. Unterschriftenservice): im Text-Schritt lädt der Kunde
|
||||
// ein Dokument hoch, statt Text zu tippen.
|
||||
const dateiModus = gewaehltesProdukt?.input_mode === 'datei';
|
||||
// Kein Preis im Konfigurator: Extern-Modus ODER Datei-Produkt (z. B.
|
||||
// Unterschriftenservice – dort wird individuell abgerechnet).
|
||||
const keinePreise = externModus || dateiModus;
|
||||
// Eigener Format-Schritt nur, wenn es überhaupt eine Wahl gibt.
|
||||
const brauchtFormatSchritt = produktFormate.length > 1;
|
||||
|
||||
@@ -511,12 +499,14 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri
|
||||
}
|
||||
}, [vorwahlProdukt, product, products]);
|
||||
|
||||
// Genau ein Format → still setzen (kein Format-Schritt nötig).
|
||||
// Genau ein Format → still setzen (kein Format-Schritt nötig). Im Extern-Modus
|
||||
// gibt es keinen Format-Schritt → dort immer das erste Format vorbelegen.
|
||||
useEffect(() => {
|
||||
if (produktFormate.length === 1 && format !== produktFormate[0].key) {
|
||||
if (!produktFormate.length) return;
|
||||
if ((produktFormate.length === 1 || (externModus && !format)) && format !== produktFormate[0].key) {
|
||||
setFormat(produktFormate[0].key);
|
||||
}
|
||||
}, [produktFormate, format]);
|
||||
}, [produktFormate, format, externModus]);
|
||||
|
||||
const nurUpload = quantity > TABELLEN_GRENZE;
|
||||
|
||||
@@ -529,6 +519,9 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri
|
||||
const brauchtAdressen =
|
||||
shippingType === 'einzeln' || (needsEnvelope && labeling === 'empfaenger');
|
||||
const umschlagFreitext = needsEnvelope && labeling === 'freitext';
|
||||
// Empfänger-Schritt (früh definiert, da mehrere Effekte darauf zugreifen):
|
||||
// „Individueller Text" bei Freitext-Kuvert, „Empfänger" bei Adressbedarf, sonst keiner.
|
||||
const empfaengerSchritt = umschlagFreitext ? 'Individueller Text' : brauchtAdressen ? 'Empfänger' : null;
|
||||
|
||||
// Ab 26 Empfängern ist die Tabelle keine Option mehr.
|
||||
useEffect(() => {
|
||||
@@ -549,9 +542,14 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri
|
||||
useEffect(() => { setPreviewSession(null); },
|
||||
[textTemplate, font, format, realistisch, labeling, needsEnvelope, umschlagText, entries]);
|
||||
|
||||
// Empfängerzeilen an die Menge angleichen (nur im Tabellenmodus).
|
||||
// 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(() => {
|
||||
if (nurUpload) return;
|
||||
// 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) {
|
||||
@@ -559,38 +557,69 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri
|
||||
}
|
||||
return prev.slice(0, quantity);
|
||||
});
|
||||
}, [quantity, nurUpload]);
|
||||
}, [quantity, nurUpload, externModus, empfaengerSchritt]);
|
||||
|
||||
// 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
|
||||
// Die Menge ist maßgeblich (greift immer); nur ein Excel-Import passt sie an die
|
||||
// tatsächlich gelesene Empfängerzahl an.
|
||||
useEffect(() => {
|
||||
if (!externModus) return;
|
||||
const v = searchParams.get('versand'); if (v === 'einzeln' || v === 'sammel') setShippingType(v);
|
||||
const k = searchParams.get('kuvert'); if (k === '1' || k === '0') setNeedsEnvelope(k === '1');
|
||||
const b = searchParams.get('beschriftung'); if (b === 'empfaenger' || b === 'freitext' || b === 'keine') setLabeling(b);
|
||||
const m = parseInt(searchParams.get('menge') || '', 10);
|
||||
if (Number.isFinite(m) && m > 0) { setQuantity(m); setQuantityChosen(true); setMengeText(String(m)); }
|
||||
// Nur einmal beim Betreten des Extern-Modus anwenden.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [externModus]);
|
||||
|
||||
// Abweichende Lieferadresse gibt es nur beim Sammelversand – sonst zurücksetzen.
|
||||
useEffect(() => { if (shippingType !== 'sammel') setLieferAbweichend(false); }, [shippingType]);
|
||||
|
||||
/**
|
||||
* Der Empfängerschritt entfällt vollständig, wenn weder Adressen noch ein
|
||||
* Umschlagtext gebraucht werden. Bei „Individueller Text" heißt er anders,
|
||||
* weil dort keine Adressen, sondern der Umschlagtext erfasst wird.
|
||||
* (empfaengerSchritt ist oben – bei brauchtAdressen/umschlagFreitext – definiert.)
|
||||
*/
|
||||
const empfaengerSchritt = umschlagFreitext
|
||||
? 'Individueller Text'
|
||||
: brauchtAdressen
|
||||
? 'Empfänger'
|
||||
: null;
|
||||
|
||||
// Ist das aktuell gewählte Produkt ein „auf Anfrage"-Kontaktprodukt?
|
||||
const kontaktSelektiert = kontaktProdukte.find((p) => p.key === product) ?? null;
|
||||
|
||||
// Produkt kommt aus dem Direktlink; Extern-Modus ist oben definiert.
|
||||
const angemeldet = !!loadSession();
|
||||
// „Sie sind" entfällt, wenn die Einstufung schon feststeht (eingeloggt mit
|
||||
// person_type) oder im Extern-Modus.
|
||||
const zeigeSieSind = !externModus && !(angemeldet && !!personType);
|
||||
|
||||
// Anfrageprodukte (Unterschriftenservice, Follow-ups) haben keinen Konfigurator –
|
||||
// nach „Weiter" folgt direkt das Anfrageformular als eigene Seite.
|
||||
const schritte = kontaktSelektiert
|
||||
// Direktlink auf ein Kontaktprodukt → direkt zum Anfrageformular (ohne „Sie sind").
|
||||
? (vorwahlProdukt ? ['Anfrage'] : ['Sie sind', 'Produkt', 'Anfrage'])
|
||||
: [
|
||||
'Sie sind',
|
||||
...(vorwahlProdukt ? [] : ['Produkt']),
|
||||
...(brauchtFormatSchritt ? ['Format'] : []),
|
||||
'Menge & Versand',
|
||||
...(istPostkarte ? ['Motiv'] : []),
|
||||
// nach „Weiter" folgt direkt das Anfrageformular als eigene Seite. Freigeschaltete
|
||||
// Kunden landen NICHT hier (dann ist das Produkt bestellbar, s. bestellbar/kontaktProdukte).
|
||||
const schritte = externModus
|
||||
// Extern: der Kunde füllt NUR Empfänger + Text; Menge/Versand/Kuvert/Format
|
||||
// legt der Betreiber über die Direktlink-Parameter fest (s. Extern-Preset).
|
||||
// Keine Preise, keine Rechnungsdaten, keine Zahlung.
|
||||
? [
|
||||
...(empfaengerSchritt ? [empfaengerSchritt] : []),
|
||||
'Text',
|
||||
'Rechnungsdaten',
|
||||
'Übersicht',
|
||||
];
|
||||
]
|
||||
: kontaktSelektiert
|
||||
// Direktlink auf ein Kontaktprodukt → direkt zum Anfrageformular (ohne „Sie sind").
|
||||
? (vorwahlProdukt ? ['Anfrage'] : ['Sie sind', 'Produkt', 'Anfrage'])
|
||||
: [
|
||||
...(zeigeSieSind ? ['Sie sind'] : []),
|
||||
...(vorwahlProdukt ? [] : ['Produkt']),
|
||||
...(brauchtFormatSchritt ? ['Format'] : []),
|
||||
'Menge & Versand',
|
||||
...(istPostkarte ? ['Motiv'] : []),
|
||||
...(empfaengerSchritt ? [empfaengerSchritt] : []),
|
||||
'Text',
|
||||
'Rechnungsdaten',
|
||||
'Übersicht',
|
||||
];
|
||||
const maxStep = schritte.length - 1;
|
||||
const name = (i: number) => schritte[i];
|
||||
|
||||
@@ -628,7 +657,8 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri
|
||||
const preisMenge = quantityChosen ? quantity : REFERENZ_MENGE;
|
||||
|
||||
useEffect(() => {
|
||||
if (!product || kontaktSelektiert) return; // Kontaktprodukte haben keinen Preis
|
||||
// Kontaktprodukte, Extern-Modus und Datei-Produkte zeigen keinen Preis.
|
||||
if (!product || kontaktSelektiert || keinePreise) { setQuote(null); return; }
|
||||
let abgebrochen = false;
|
||||
setQuoteFehler(null);
|
||||
|
||||
@@ -772,6 +802,8 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri
|
||||
* Der MwSt-Hinweis wird bewusst nicht angezeigt.
|
||||
*/
|
||||
const aufpreis = (key: string): Aufpreis => {
|
||||
// Ohne Preise (Datei-Produkt/Extern) an keiner Option Aufpreise zeigen.
|
||||
if (keinePreise) return null;
|
||||
const it = preisKatalog[key];
|
||||
if (!it || !it.price) return null;
|
||||
// Nur Sammelversand wird für alle identisch (netto) angezeigt. Einzelversand
|
||||
@@ -808,16 +840,21 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri
|
||||
: entries.every((e) => e.last_name.trim() && e.zip.trim() && e.city.trim());
|
||||
}
|
||||
case 'Text':
|
||||
if (dateiModus) return !!quellDatei;
|
||||
if (dateiModus) return quellDateien.length > 0;
|
||||
if (textservice) return textBriefing.trim().length > 0;
|
||||
return textTemplate.trim().length > 0 && !textZuLang && !textUeberlauf;
|
||||
case 'Rechnungsdaten':
|
||||
// Pflichtfelder wie im bestehenden Konfigurator (Telefon optional)
|
||||
// Pflichtfelder wie im bestehenden Konfigurator (Telefon optional).
|
||||
// Bei abweichender Lieferadresse sind deren Kernfelder ebenfalls Pflicht.
|
||||
return (
|
||||
!!rechnung.first_name.trim() && !!rechnung.last_name.trim() &&
|
||||
istEmail(rechnung.email) &&
|
||||
!!rechnung.street.trim() && !!rechnung.zip.trim() && !!rechnung.city.trim() &&
|
||||
(personType !== 'unternehmen' || !!rechnung.company.trim())
|
||||
(personType !== 'unternehmen' || !!rechnung.company.trim()) &&
|
||||
(!lieferAbweichend || (
|
||||
!!lieferung.first_name.trim() && !!lieferung.last_name.trim() &&
|
||||
!!lieferung.street.trim() && !!lieferung.zip.trim() && !!lieferung.city.trim()
|
||||
))
|
||||
);
|
||||
default: return false;
|
||||
}
|
||||
@@ -833,7 +870,8 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri
|
||||
if (!paypalMoeglich && zahlung === 'paypal') setZahlung('rechnung');
|
||||
}, [paypalMoeglich, zahlung]);
|
||||
|
||||
const bestellbereit = agb && datenschutz && final && !!quote;
|
||||
// Ohne Preis (Extern-Modus/Datei-Produkt) gibt es keinen quote – dort reicht die Zustimmung.
|
||||
const bestellbereit = agb && datenschutz && final && (!!quote || keinePreise);
|
||||
|
||||
async function weiter() {
|
||||
// Beim Verlassen des Text-Schritts prüfen, ob das Schriftstück zu viele
|
||||
@@ -940,25 +978,33 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri
|
||||
}
|
||||
}
|
||||
|
||||
// Datei-Produkte (z. B. Unterschriftenservice): Dokument hochladen.
|
||||
let quellDateiId: string | null = null;
|
||||
if (dateiModus && quellDatei) {
|
||||
// Datei-Produkte (z. B. Unterschriftenservice): ein oder mehrere
|
||||
// Dokumente (PDF/Word/Excel) hochladen.
|
||||
let quellDateiIds: string[] = [];
|
||||
if (dateiModus && quellDateien.length) {
|
||||
try {
|
||||
quellDateiId = await uploadFile(quellDatei);
|
||||
quellDateiIds = await Promise.all(quellDateien.map((f) => uploadFile(f)));
|
||||
} catch {
|
||||
setBestellFehler(['Das Dokument konnte nicht hochgeladen werden.']);
|
||||
setBestellFehler(['Ein Dokument konnte nicht hochgeladen werden.']);
|
||||
setSendet(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Extern-Modus (Etsy o. Ä.): keine Rechnungsdaten erfasst → Platzhalter,
|
||||
// Bestellung ohne Zahlung, als „extern" markiert.
|
||||
const bestellEmail = externModus && !rechnung.email.trim() ? 'extern@skrift.de' : rechnung.email.trim();
|
||||
const bestellVorname = externModus && !rechnung.first_name.trim() ? 'Externe' : rechnung.first_name;
|
||||
const bestellNachname = externModus && !rechnung.last_name.trim() ? 'Bestellung' : rechnung.last_name;
|
||||
|
||||
const ergebnis = await createOrder({
|
||||
motif: istPostkarte && motifMode === 'katalog' ? motifId ?? undefined : undefined,
|
||||
motif_upload: motivDateiId ?? undefined,
|
||||
source_file: quellDateiId ?? undefined,
|
||||
email: rechnung.email.trim(),
|
||||
first_name: rechnung.first_name,
|
||||
last_name: rechnung.last_name,
|
||||
source_file: quellDateiIds[0] ?? undefined,
|
||||
source_files: quellDateiIds.length ? quellDateiIds : undefined,
|
||||
email: bestellEmail,
|
||||
first_name: bestellVorname,
|
||||
last_name: bestellNachname,
|
||||
company: rechnung.company,
|
||||
phone: rechnung.phone,
|
||||
person_type: personType ?? 'privat',
|
||||
@@ -976,14 +1022,14 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri
|
||||
...(designservice ? ['gestaltungsservice'] : []),
|
||||
],
|
||||
voucher_code: voucher.trim() || undefined,
|
||||
payment_method: zahlung,
|
||||
source: loadSession() ? 'portal' : 'configurator',
|
||||
payment_method: keinePreise ? 'rechnung' : zahlung,
|
||||
source: externModus ? 'extern' : (loadSession() ? 'portal' : 'configurator'),
|
||||
// Gezeigte Vorschau 1:1 übernehmen (nur wenn unverändert seit der Vorschau).
|
||||
preview_session: previewSession ?? undefined,
|
||||
// Adress-Snapshot für Bestätigung/Rechnung.
|
||||
billing: {
|
||||
first_name: rechnung.first_name, last_name: rechnung.last_name, company: rechnung.company,
|
||||
email: rechnung.email, phone: rechnung.phone, street: rechnung.street,
|
||||
first_name: bestellVorname, last_name: bestellNachname, company: rechnung.company,
|
||||
email: bestellEmail, phone: rechnung.phone, street: rechnung.street,
|
||||
zip: rechnung.zip, city: rechnung.city, country: rechnung.country,
|
||||
},
|
||||
shipping_address: lieferAbweichend
|
||||
@@ -1011,6 +1057,7 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri
|
||||
nummer: ergebnis.order_number,
|
||||
zahlung: ergebnis.payment_method,
|
||||
bezahlt: ergebnis.payment_method !== 'paypal',
|
||||
extern: externModus,
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) setBestellFehler(err.details.length ? err.details : [err.message]);
|
||||
@@ -1041,9 +1088,11 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri
|
||||
</>
|
||||
) : (
|
||||
<p style={{ marginTop: 'var(--sk-space-3)' }} className="sk-note">
|
||||
{bestellung.zahlung === 'rechnung'
|
||||
? 'Die Rechnung erhalten Sie per E-Mail. Die Produktion startet nach Zahlungseingang.'
|
||||
: 'Ihre Zahlung ist eingegangen. Wir haben Ihnen eine Bestätigung per E-Mail geschickt.'}
|
||||
{bestellung.extern
|
||||
? 'Die Bestellung wird direkt in die Produktion gegeben – die Abrechnung läuft über die Plattform.'
|
||||
: bestellung.zahlung === 'rechnung'
|
||||
? 'Die Rechnung erhalten Sie per E-Mail. Die Produktion startet nach Zahlungseingang.'
|
||||
: 'Ihre Zahlung ist eingegangen. Wir haben Ihnen eine Bestätigung per E-Mail geschickt.'}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -1079,8 +1128,8 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri
|
||||
}}>{zeichenHinweis}</div>
|
||||
)}
|
||||
{/* Preiszeile erst ab dem zweiten Schritt (Produkt) und nur für
|
||||
bepreisbare Produkte – Anfrageprodukte haben keinen Preis. */}
|
||||
{step > 0 && !kontaktSelektiert && (
|
||||
bepreisbare Produkte – Anfrage-, Extern- und Datei-Produkte ohne Preis. */}
|
||||
{step > 0 && !kontaktSelektiert && !keinePreise && (
|
||||
<div className="sk-pricebar">
|
||||
<div className="sk-pricebar-inner">
|
||||
<div className="sk-price-block">
|
||||
@@ -1174,12 +1223,6 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri
|
||||
setQuantity(gueltig);
|
||||
setMengeText(String(gueltig));
|
||||
}} />
|
||||
{nurUpload && (
|
||||
<p className="sk-note" style={{ marginTop: 'var(--sk-space-2)' }}>
|
||||
Ab {TABELLEN_GRENZE + 1} Stück laden Sie die Empfängerdaten als Excel-Datei hoch –
|
||||
eine Vorlage stellen wir im Schritt „Empfänger" bereit.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -1410,17 +1453,18 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri
|
||||
|
||||
<hr className="sk-group-split" />
|
||||
|
||||
{/* Art der Erfassung */}
|
||||
{/* Art der Erfassung. Die Tabellen-Option gibt es nur bis zur
|
||||
Tabellengrenze – darüber ausschließlich Excel-Upload. */}
|
||||
<div className="sk-options">
|
||||
<Option name="eingabe" selected={eingabeArt === 'excel'}
|
||||
onSelect={() => setEingabeArt('excel')}
|
||||
title="Excel-Datei hochladen" />
|
||||
<Option name="eingabe" selected={eingabeArt === 'tabelle'}
|
||||
onSelect={() => !nurUpload && setEingabeArt('tabelle')}
|
||||
title="Direkt in Tabelle eingeben"
|
||||
desc={nurUpload
|
||||
? `Ab ${TABELLEN_GRENZE + 1} Empfängern nicht möglich – bitte Datei hochladen`
|
||||
: `Bis ${TABELLEN_GRENZE} Empfänger – ohne Datei-Upload`} />
|
||||
{!nurUpload && (
|
||||
<Option name="eingabe" selected={eingabeArt === 'tabelle'}
|
||||
onSelect={() => setEingabeArt('tabelle')}
|
||||
title="Direkt in Tabelle eingeben"
|
||||
desc={`Bis ${TABELLEN_GRENZE} Empfänger – ohne Datei-Upload`} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{eingabeArt === 'excel' ? (
|
||||
@@ -1528,20 +1572,26 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri
|
||||
{dateiModus ? (
|
||||
<section className="sk-card sk-card--head" style={{ marginTop: 'var(--sk-space-3)' }}>
|
||||
<div className="sk-card-head">
|
||||
<h2>Dokument hochladen</h2>
|
||||
<p>Laden Sie das Dokument hoch, das wir unterschreiben und versenden sollen (PDF oder Word).</p>
|
||||
<h2>Dokumente hochladen</h2>
|
||||
<p>Laden Sie ein oder mehrere Dokumente hoch, die wir unterschreiben und versenden sollen (PDF, Word oder Excel).</p>
|
||||
</div>
|
||||
<div className="sk-card-body">
|
||||
<label className="sk-drop">
|
||||
<span className="sk-drop-title">Datei hier ablegen oder klicken</span>
|
||||
<span className="sk-drop-sub" style={{ display: 'block' }}>.pdf, .doc oder .docx</span>
|
||||
<input type="file" accept=".pdf,.doc,.docx"
|
||||
onChange={(e) => setQuellDatei(e.target.files?.[0] ?? null)} />
|
||||
<span className="sk-drop-title">Dateien hier ablegen oder klicken</span>
|
||||
<span className="sk-drop-sub" style={{ display: 'block' }}>.pdf, .doc, .docx, .xls, .xlsx – mehrere möglich</span>
|
||||
<input type="file" multiple accept=".pdf,.doc,.docx,.xls,.xlsx"
|
||||
onChange={(e) => setQuellDateien((prev) => [...prev, ...Array.from(e.target.files ?? [])])} />
|
||||
</label>
|
||||
{quellDatei && (
|
||||
<p className="sk-note" style={{ marginTop: 'var(--sk-space-3)' }}>
|
||||
{quellDatei.name} – wird mit der Bestellung übertragen.
|
||||
</p>
|
||||
{quellDateien.length > 0 && (
|
||||
<ul className="sk-note" style={{ marginTop: 'var(--sk-space-3)', paddingLeft: '1.1em' }}>
|
||||
{quellDateien.map((f, i) => (
|
||||
<li key={i}>
|
||||
{f.name}
|
||||
<button type="button" className="sk-btn sk-btn--ghost" style={{ marginLeft: 'var(--sk-space-2)', padding: '2px 8px' }}
|
||||
onClick={() => setQuellDateien((prev) => prev.filter((_, idx) => idx !== i))}>Entfernen</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
@@ -1709,8 +1759,6 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri
|
||||
<p>An diese Adresse stellen wir die Rechnung aus.</p>
|
||||
</div>
|
||||
<div className="sk-card-body">
|
||||
{/* Adressauswahl für angemeldete Kunden mit gespeicherten Adressen. */}
|
||||
{adressWahl('r-gespeichert', adresseUebernehmen)}
|
||||
<Feld id="r-vn" label="Vorname" value={rechnung.first_name} required
|
||||
onChange={(v) => setRechnung({ ...rechnung, first_name: v })} />
|
||||
<Feld id="r-nn" label="Nachname" value={rechnung.last_name} required
|
||||
@@ -1741,6 +1789,9 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Abweichende Lieferadresse gibt es nur beim Sammelversand –
|
||||
beim Einzelversand geht alles direkt an die Empfänger. */}
|
||||
{shippingType === 'sammel' && (
|
||||
<section className="sk-card" style={{ marginTop: 'var(--sk-space-3)' }}>
|
||||
<label className="sk-check">
|
||||
<input type="checkbox" checked={lieferAbweichend}
|
||||
@@ -1750,7 +1801,6 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri
|
||||
|
||||
{lieferAbweichend && (
|
||||
<div style={{ marginTop: '18px' }}>
|
||||
{adressWahl('l-gespeichert', lieferungUebernehmen)}
|
||||
<Feld id="l-vn" label="Vorname" value={lieferung.first_name} required
|
||||
onChange={(v) => setLieferung({ ...lieferung, first_name: v })} />
|
||||
<Feld id="l-nn" label="Nachname" value={lieferung.last_name} required
|
||||
@@ -1771,12 +1821,23 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── Übersicht ───────────────────────────────────────── */}
|
||||
{aktuell === 'Übersicht' && (
|
||||
<>
|
||||
{keinePreise ? (
|
||||
<section className="sk-card">
|
||||
<p className="sk-note" style={{ margin: 0 }}>
|
||||
{externModus
|
||||
? 'Externe Bestellung – sie wird ohne Preis und ohne Zahlung direkt zur Produktion eingereiht (Abrechnung erfolgt über die Plattform).'
|
||||
: 'Der Preis wird individuell berechnet – Sie erhalten die Rechnung anschließend per E-Mail.'}
|
||||
</p>
|
||||
</section>
|
||||
) : (
|
||||
<>
|
||||
<section className="sk-card">
|
||||
<h2>Kostenübersicht</h2>
|
||||
{quote ? (
|
||||
@@ -1827,6 +1888,8 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
|
||||
<section style={{ marginTop: 'var(--sk-space-3)' }}>
|
||||
<label className="sk-check sk-check--card">
|
||||
@@ -1875,7 +1938,7 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri
|
||||
|
||||
<button className="sk-btn sk-btn--primary sk-btn--block" style={{ marginTop: 'var(--sk-space-5)' }}
|
||||
disabled={!bestellbereit || sendet} onClick={bestellen}>
|
||||
{sendet ? 'Wird gesendet …' : 'Zahlungspflichtig bestellen'}
|
||||
{sendet ? 'Wird gesendet …' : externModus ? 'Bestellung absenden' : keinePreise ? 'Kostenpflichtig bestellen' : 'Zahlungspflichtig bestellen'}
|
||||
</button>
|
||||
</section>
|
||||
</>
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { fetchMyCustomer, saveMyCustomer, type Customer, type CustomerAddress } from '../lib/api';
|
||||
import { fetchMyCustomer, saveMyCustomer, type Customer, type Adresse2 } from '../lib/api';
|
||||
|
||||
const LAENDER = ['Deutschland', 'Österreich', 'Schweiz', 'Luxemburg', 'Belgien', 'Niederlande', 'Frankreich'];
|
||||
const leereAdresse = (): CustomerAddress => ({ label: '', street: '', zip: '', city: '', country: 'Deutschland' });
|
||||
|
||||
/** Stammdaten des angemeldeten Kunden – füllen später den Konfigurator vor. */
|
||||
export default function Stammdaten() {
|
||||
@@ -37,13 +36,29 @@ export default function Stammdaten() {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Adressen (mehrere möglich) ──
|
||||
const adressen = c?.addresses ?? [];
|
||||
const setAdressen = (next: CustomerAddress[]) => c && setC({ ...c, addresses: next });
|
||||
const setAdrFeld = (i: number, k: keyof CustomerAddress, v: string) =>
|
||||
setAdressen(adressen.map((a, idx) => (idx === i ? { ...a, [k]: v } : a)));
|
||||
const addAdresse = () => setAdressen([...adressen, leereAdresse()]);
|
||||
const removeAdresse = (i: number) => setAdressen(adressen.filter((_, idx) => idx !== i));
|
||||
// ── Zwei feste Adressen (Rechnung + Lieferung, ohne Bezeichnung) ──
|
||||
const setAdrFeld = (slot: 'billing_address' | 'shipping_address', k: keyof Adresse2, v: string) =>
|
||||
c && setC({ ...c, [slot]: { ...(c[slot] ?? {}), [k]: v } });
|
||||
|
||||
const adressBlock = (slot: 'billing_address' | 'shipping_address', titel: string) => {
|
||||
const a: Adresse2 = c?.[slot] ?? {};
|
||||
return (
|
||||
<div className="sk-check--card" style={{ padding: 'var(--sk-space-4)', marginBottom: 'var(--sk-space-3)', border: '1px solid var(--sk-border)', borderRadius: 'var(--sk-radius, 10px)' }}>
|
||||
<label className="sk-label" style={{ marginBottom: 'var(--sk-space-2)', display: 'block' }}>{titel}</label>
|
||||
<input className="sk-input" style={{ marginBottom: 'var(--sk-space-2)' }} placeholder="Straße & Hausnummer"
|
||||
value={a.street ?? ''} onChange={(e) => setAdrFeld(slot, 'street', e.target.value)} />
|
||||
<div style={{ display: 'flex', gap: 'var(--sk-space-2)', marginBottom: 'var(--sk-space-2)' }}>
|
||||
<input className="sk-input" placeholder="PLZ" value={a.zip ?? ''}
|
||||
onChange={(e) => setAdrFeld(slot, 'zip', e.target.value.replace(/[^0-9]/g, ''))} style={{ flex: '0 0 30%' }} />
|
||||
<input className="sk-input" placeholder="Ort" value={a.city ?? ''}
|
||||
onChange={(e) => setAdrFeld(slot, 'city', e.target.value)} />
|
||||
</div>
|
||||
<select className="sk-input" value={a.country ?? 'Deutschland'} onChange={(e) => setAdrFeld(slot, 'country', e.target.value)}>
|
||||
{LAENDER.map((l) => <option key={l} value={l}>{l}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const feld = (k: keyof Customer, label: string, type = 'text') => (
|
||||
<div className="sk-field">
|
||||
@@ -92,34 +107,27 @@ export default function Stammdaten() {
|
||||
<input className="sk-input" value={c.email} disabled />
|
||||
</div>
|
||||
|
||||
{/* Adressen – beliebig viele; im Konfigurator wählbar. */}
|
||||
{/* Zwei feste Adressen – werden automatisch im Bestellvorgang eingesetzt. */}
|
||||
<div className="sk-field" style={{ marginBottom: 0 }}>
|
||||
<label className="sk-label">Adressen</label>
|
||||
{adressen.length === 0 && <p className="sk-note">Noch keine Adresse hinterlegt.</p>}
|
||||
{adressen.map((a, i) => (
|
||||
<div key={i} className="sk-check--card" style={{ padding: 'var(--sk-space-4)', marginBottom: 'var(--sk-space-3)', border: '1px solid var(--sk-border)', borderRadius: 'var(--sk-radius, 10px)' }}>
|
||||
<div style={{ display: 'flex', gap: 'var(--sk-space-2)', marginBottom: 'var(--sk-space-2)' }}>
|
||||
<input className="sk-input" placeholder="Bezeichnung (z. B. Rechnung, Lieferung)"
|
||||
value={a.label ?? ''} onChange={(e) => setAdrFeld(i, 'label', e.target.value)} />
|
||||
<button type="button" className="sk-btn sk-btn--ghost" onClick={() => removeAdresse(i)}>Entfernen</button>
|
||||
</div>
|
||||
<input className="sk-input" style={{ marginBottom: 'var(--sk-space-2)' }} placeholder="Straße & Hausnummer"
|
||||
value={a.street ?? ''} onChange={(e) => setAdrFeld(i, 'street', e.target.value)} />
|
||||
<div style={{ display: 'flex', gap: 'var(--sk-space-2)', marginBottom: 'var(--sk-space-2)' }}>
|
||||
<input className="sk-input" placeholder="PLZ" value={a.zip ?? ''}
|
||||
onChange={(e) => setAdrFeld(i, 'zip', e.target.value.replace(/[^0-9]/g, ''))} style={{ flex: '0 0 30%' }} />
|
||||
<input className="sk-input" placeholder="Ort" value={a.city ?? ''}
|
||||
onChange={(e) => setAdrFeld(i, 'city', e.target.value)} />
|
||||
</div>
|
||||
<select className="sk-input" value={a.country ?? 'Deutschland'} onChange={(e) => setAdrFeld(i, 'country', e.target.value)}>
|
||||
{LAENDER.map((l) => <option key={l} value={l}>{l}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
))}
|
||||
<button type="button" className="sk-btn sk-btn--ghost" onClick={addAdresse}>+ Adresse hinzufügen</button>
|
||||
{adressBlock('billing_address', 'Rechnungsadresse')}
|
||||
{adressBlock('shipping_address', 'Lieferadresse')}
|
||||
</div>
|
||||
|
||||
<button className="sk-btn sk-btn--primary" style={{ marginTop: 'var(--sk-space-5)' }} disabled={status === 'speichert'}>
|
||||
{/* Zusätzliche Login-Mailadressen (Pflege durch Skrift). */}
|
||||
{c.alt_emails && c.alt_emails.length > 0 && (
|
||||
<div className="sk-field" style={{ marginTop: 'var(--sk-space-4)', marginBottom: 0 }}>
|
||||
<label className="sk-label">Weitere Login-Adressen</label>
|
||||
<p className="sk-note">{c.alt_emails.join(', ')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === 'gespeichert' && (
|
||||
<p className="sk-note" role="status" style={{
|
||||
marginTop: 'var(--sk-space-4)', color: 'var(--sk-success, #2e7d32)', fontWeight: 600,
|
||||
}}>✓ Stammdaten gespeichert.</p>
|
||||
)}
|
||||
|
||||
<button className="sk-btn sk-btn--primary" style={{ marginTop: 'var(--sk-space-4)' }} disabled={status === 'speichert'}>
|
||||
{status === 'speichert' ? 'Wird gespeichert …' : status === 'gespeichert' ? 'Gespeichert' : 'Speichern'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
Reference in New Issue
Block a user