Produktions-Modul, überarbeitete Preislogik und WordPress-Preisrechner
Directus - Neues Modul „Produktion": Board mit Neu/Warteschlange/Im Druck/ Kuvertierung/Versandfertig/Versendet/Archiv, zentraler Suche, Druck-Popup (Maschine, Template, Maße, Nummernbereich, Direktdruck vs. Warteschlange) und Empfängerliste mit Vorschau von Schriftstück und Kuvert. - Interface-Plugins: Auftragsdateien, Drucken, Testdruck, Empfängersuche. - Mailer-Hook für Auftrags- und Statusmails inkl. Signatur und AGB-Anhang. - Auftrag: Empfängerzeilen inline, Produktionsstatus (inkl. storniert), Versanddatum, Brief-/Kuvert-Indikatoren, Insights-Dashboard. - Absender aller Mails ist Skrift (Projektname + email_settings); die Angebotsanfrage nennt jetzt das angefragte Produkt. Preislogik - Eine gemeinsame Multiplikator-Formel; Normalpreis-Menge je Produkt (products.norm_qty). Alte B2B/B2C-Fallback-Felder entfernt. - Mengenrabatt-Staffel (ab X Stück Y %) in den Preis-Einstellungen. - Preis pro Stück ohne einmalige Kosten (unit_net_total). - Einzelversand rechnet Kuvert und Beschriftung nicht mehr doppelt. - Fehlerhafte Grundpreis-Formel legt die Preisauskunft nicht mehr lahm. - Briefnummern 1-basiert: Dateiname entspricht der Briefnummer. app.skrift.de - Direktlinks für Muster, Unterschriftenservice und Follow-ups; Muster als eigene Seite, Kontaktprodukte direkt im Anfrageformular. - Mehrere Adressen in den Stammdaten plus Adressauswahl im Konfigurator. - Verbotene Zeichen werden bei der Eingabe entfernt und gemeldet. - Zeilenüberlauf als Fehlerkasten vor „Weiter"; Mengenfeld leerbar, Standardmenge 100; Checkout-Checkboxen im Kartenstil. Produktion - skrift-agent: Poll-Modell je Maschine, Maß-Übersteuerung, Nummernfilter und automatischer Übergang in die Kuvertierung. WordPress - Neues Plugin „Skrift Preisrechner": Shortcodes inkl./exkl. MwSt, stündlicher Abgleich der Preisdaten in WordPress und ein sicherer Formel-Interpreter, damit Formeländerungen automatisch greifen. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
548
Webseite/skrift-app/src/lib/api.ts
Normal file
548
Webseite/skrift-app/src/lib/api.ts
Normal file
@@ -0,0 +1,548 @@
|
||||
/**
|
||||
* Directus-Client.
|
||||
*
|
||||
* Bewusst ohne SDK: Wir brauchen vor allem unsere eigenen Endpunkte
|
||||
* (/skrift-auth, /skrift-orders) plus einfache Item-Abfragen – ein schlanker
|
||||
* fetch-Wrapper ist überschaubarer und hält uns von SDK-Versionswechseln fern.
|
||||
*/
|
||||
|
||||
const BASE = (import.meta.env.VITE_DIRECTUS_URL ?? '').replace(/\/$/, '');
|
||||
|
||||
const STORAGE_KEY = 'skrift.session';
|
||||
|
||||
export type Session = {
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
/** Ablaufzeitpunkt als Unix-ms. */
|
||||
expires_at: number;
|
||||
};
|
||||
|
||||
export function loadSession(): Session | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
return raw ? (JSON.parse(raw) as Session) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function saveSession(s: Session | null) {
|
||||
if (s) localStorage.setItem(STORAGE_KEY, JSON.stringify(s));
|
||||
else localStorage.removeItem(STORAGE_KEY);
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
status: number;
|
||||
/** Einzelne Validierungsmeldungen, falls der Endpunkt mehrere liefert. */
|
||||
details: string[];
|
||||
constructor(message: string, status: number, details: string[] = []) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
this.details = details;
|
||||
}
|
||||
}
|
||||
|
||||
async function parse(res: Response) {
|
||||
const text = await res.text();
|
||||
let data: any = null;
|
||||
try {
|
||||
data = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
/* keine JSON-Antwort */
|
||||
}
|
||||
if (!res.ok) {
|
||||
// Directus liefert errors als Objekte ({message}), unsere eigenen
|
||||
// Endpunkte als reine Strings – beides muss ankommen.
|
||||
const roh = Array.isArray(data?.errors) ? data.errors : [];
|
||||
const details: string[] = roh
|
||||
.map((e: unknown) => (typeof e === 'string' ? e : (e as { message?: string })?.message))
|
||||
.filter((m: unknown): m is string => typeof m === 'string' && m.length > 0);
|
||||
|
||||
const basis = details[0] ?? data?.error ?? data?.message ?? res.statusText;
|
||||
// Manche eigenen Endpunkte hängen einen Diagnose-Grund an (data.detail).
|
||||
const msg = typeof data?.detail === 'string' ? `${basis} (${data.detail})` : basis;
|
||||
throw new ApiError(msg, res.status, details);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
/** Erneuert die Session, wenn das Access-Token abgelaufen ist. */
|
||||
async function ensureFreshToken(): Promise<Session | null> {
|
||||
const session = loadSession();
|
||||
if (!session) return null;
|
||||
// 30 s Puffer, damit ein Request nicht mitten im Ablauf landet.
|
||||
if (Date.now() < session.expires_at - 30_000) return session;
|
||||
|
||||
try {
|
||||
const res = await fetch(`${BASE}/auth/refresh`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ refresh_token: session.refresh_token, mode: 'json' }),
|
||||
});
|
||||
const data = await parse(res);
|
||||
const next: Session = {
|
||||
access_token: data.data?.access_token ?? data.access_token,
|
||||
refresh_token: data.data?.refresh_token ?? data.refresh_token,
|
||||
expires_at: Date.now() + (data.data?.expires ?? data.expires ?? 900_000),
|
||||
};
|
||||
saveSession(next);
|
||||
return next;
|
||||
} catch {
|
||||
saveSession(null);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
type RequestOptions = { method?: string; body?: unknown; auth?: boolean };
|
||||
|
||||
async function request(path: string, { method = 'GET', body, auth = true }: RequestOptions = {}) {
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
|
||||
if (auth) {
|
||||
const session = await ensureFreshToken();
|
||||
if (session) headers.Authorization = `Bearer ${session.access_token}`;
|
||||
}
|
||||
|
||||
const res = await fetch(BASE + path, {
|
||||
method,
|
||||
headers,
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
return parse(res);
|
||||
}
|
||||
|
||||
/* ── Auth (passwortlos, siehe Extension skrift-auth) ──────────────────────── */
|
||||
|
||||
export async function requestLoginCode(email: string) {
|
||||
return request('/skrift-auth/request-code', { method: 'POST', body: { email }, auth: false });
|
||||
}
|
||||
|
||||
export async function verifyLoginCode(email: string, code: string): Promise<Session> {
|
||||
const data = await request('/skrift-auth/verify-code', {
|
||||
method: 'POST',
|
||||
body: { email, code },
|
||||
auth: false,
|
||||
});
|
||||
const session: Session = {
|
||||
access_token: data.access_token,
|
||||
refresh_token: data.refresh_token,
|
||||
expires_at: Date.now() + (data.expires ?? 900_000),
|
||||
};
|
||||
saveSession(session);
|
||||
return session;
|
||||
}
|
||||
|
||||
export function logout() {
|
||||
saveSession(null);
|
||||
}
|
||||
|
||||
/* ── Daten ────────────────────────────────────────────────────────────────── */
|
||||
|
||||
export type Order = {
|
||||
id: number;
|
||||
order_number: string;
|
||||
status: string;
|
||||
gross_total: number | null;
|
||||
entries_count: number | null;
|
||||
date_created: string | null;
|
||||
product?: { name: string } | null;
|
||||
};
|
||||
|
||||
/** Aufträge des angemeldeten Kunden – die Einschränkung erfolgt in Directus. */
|
||||
export async function fetchMyOrders(): Promise<Order[]> {
|
||||
const data = await request(
|
||||
'/items/orders?fields=id,order_number,status,gross_total,entries_count,date_created,product.name' +
|
||||
'&sort=-date_created&limit=100',
|
||||
);
|
||||
return data?.data ?? [];
|
||||
}
|
||||
|
||||
export async function fetchMe() {
|
||||
const data = await request('/users/me?fields=id,email,first_name,last_name');
|
||||
return data?.data ?? null;
|
||||
}
|
||||
|
||||
/* ── Stammdaten ───────────────────────────────────────────────────────────── */
|
||||
|
||||
export type CustomerAddress = {
|
||||
label?: string;
|
||||
street?: string;
|
||||
zip?: string;
|
||||
city?: string;
|
||||
country?: string;
|
||||
};
|
||||
export type Customer = {
|
||||
/** Directus-User-ID (UUID) – Kunde ist das Login-Konto selbst. */
|
||||
id: string;
|
||||
email: string;
|
||||
person_type: 'privat' | 'unternehmen' | null;
|
||||
first_name: string | null;
|
||||
last_name: string | null;
|
||||
company: string | null;
|
||||
phone: string | null;
|
||||
/** Mehrere hinterlegte Adressen (Rechnung/Lieferung). */
|
||||
addresses: CustomerAddress[] | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Profil des angemeldeten Kunden. Kunde = Login-Konto (directus_users),
|
||||
* daher direkt über /users/me – kein separater „customers"-Datensatz mehr.
|
||||
*/
|
||||
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',
|
||||
);
|
||||
return data?.data ?? null;
|
||||
}
|
||||
|
||||
/** person_type fehlt bewusst – die Einstufung ändert nur der Betreiber. */
|
||||
export async function saveMyCustomer(c: Customer) {
|
||||
return request('/users/me', {
|
||||
method: 'PATCH',
|
||||
body: {
|
||||
first_name: c.first_name,
|
||||
last_name: c.last_name,
|
||||
company: c.company,
|
||||
phone: c.phone,
|
||||
addresses: c.addresses ?? [],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/* ── Katalog (öffentlich lesbar – auch für Gäste) ─────────────────────────── */
|
||||
|
||||
export type Product = {
|
||||
id: number;
|
||||
key: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
type: 'letter' | 'postcard' | 'sample' | 'contact';
|
||||
pricing_mode: 'formel' | 'auf_anfrage';
|
||||
/** Für wen das Produkt sichtbar ist (im Admin pflegbar). */
|
||||
visibility: 'alle' | 'privat' | 'unternehmen';
|
||||
/** Eingabeart im Konfigurator: freier Text oder Datei-Upload. */
|
||||
input_mode: 'text' | 'datei';
|
||||
};
|
||||
|
||||
export type Format = { id: number; key: string; name: string; product: number };
|
||||
|
||||
/**
|
||||
* Produkt-Schlüssel, die für den angemeldeten Kunden individuell freigeschaltet
|
||||
* sind (z. B. Unterschriftenservice). Nur mit Login sinnvoll; sonst leer.
|
||||
*/
|
||||
export async function fetchMyUnlockedProducts(): Promise<string[]> {
|
||||
if (!loadSession()) return [];
|
||||
try {
|
||||
const data = await request('/items/customer_products?fields=product.key&limit=-1');
|
||||
return (data?.data ?? [])
|
||||
.map((r: { product?: { key?: string } }) => r.product?.key)
|
||||
.filter((k: unknown): k is string => typeof k === 'string');
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchProducts(): Promise<Product[]> {
|
||||
// fields=* damit ein noch fehlendes Feld (z. B. visibility vor dem Bootstrap)
|
||||
// den Aufruf nicht scheitern lässt.
|
||||
const data = await request(
|
||||
'/items/products?fields=*&filter[active][_eq]=true&sort=sort&limit=-1',
|
||||
{ auth: false },
|
||||
);
|
||||
return (data?.data ?? []).map((p: Product) => ({ ...p, visibility: p.visibility ?? 'alle', input_mode: p.input_mode ?? 'text' }));
|
||||
}
|
||||
|
||||
export async function fetchFormats(): Promise<Format[]> {
|
||||
const data = await request(
|
||||
'/items/formats?fields=id,key,name,product&filter[active][_eq]=true&limit=-1',
|
||||
{ auth: false },
|
||||
);
|
||||
return data?.data ?? [];
|
||||
}
|
||||
|
||||
/* ── Preis & Bestellung (Extension skrift-orders) ─────────────────────────── */
|
||||
|
||||
export type QuoteLine = { label: string; amount: number; per_unit?: boolean };
|
||||
export type Quote = {
|
||||
lines: QuoteLine[];
|
||||
net_total: number;
|
||||
/** Netto-Summe nur der pro-Stück-Positionen (ohne einmalige Kosten). */
|
||||
unit_net_total: number;
|
||||
vat_amount: number;
|
||||
gross_total: number;
|
||||
mwst_percent: number;
|
||||
paypal_allowed: boolean;
|
||||
};
|
||||
|
||||
export type QuoteInput = {
|
||||
product: string;
|
||||
format?: string;
|
||||
quantity: number;
|
||||
shipping_type: 'einzeln' | 'sammel';
|
||||
needs_envelope: boolean;
|
||||
envelope_labeling?: 'empfaenger' | 'freitext' | 'keine';
|
||||
motif_mode?: string;
|
||||
addons?: string[];
|
||||
voucher_code?: string;
|
||||
/** Optional: bekannte Kunden-E-Mail, damit hinterlegte Kundenrabatte greifen. */
|
||||
email?: string;
|
||||
/** Segment für den Mengen-Multiplikator (B2B/B2C). */
|
||||
person_type?: 'privat' | 'unternehmen';
|
||||
};
|
||||
|
||||
/** Preis serverseitig berechnen lassen – der Client rechnet nie selbst. */
|
||||
export async function fetchQuote(input: QuoteInput): Promise<Quote> {
|
||||
return request('/skrift-orders/quote', { method: 'POST', body: input, auth: false });
|
||||
}
|
||||
|
||||
/* ── Motive ───────────────────────────────────────────────────────────────── */
|
||||
|
||||
export type Motif = {
|
||||
id: number;
|
||||
name: string;
|
||||
image: string | null;
|
||||
orientation: 'quer' | 'hochkant' | null;
|
||||
};
|
||||
|
||||
export async function fetchMotifs(): Promise<Motif[]> {
|
||||
const data = await request(
|
||||
'/items/motifs?fields=id,name,image,orientation&filter[active][_eq]=true&sort=sort&limit=-1',
|
||||
{ auth: false },
|
||||
);
|
||||
return data?.data ?? [];
|
||||
}
|
||||
|
||||
/** Öffentliche URL einer Directus-Datei. */
|
||||
export const assetUrl = (id: string) => `${BASE}/assets/${id}`;
|
||||
|
||||
/* ── Preis-Positionen (für Aufpreise an den Optionen) ─────────────────────── */
|
||||
|
||||
export type PriceItem = {
|
||||
key: string;
|
||||
label: string;
|
||||
category: string;
|
||||
price: number;
|
||||
unit: 'einmalig' | 'pro_stueck' | 'prozent';
|
||||
};
|
||||
|
||||
export async function fetchPriceItems(): Promise<PriceItem[]> {
|
||||
const data = await request(
|
||||
'/items/price_items?fields=key,label,category,price,unit&filter[active][_eq]=true&limit=-1',
|
||||
{ auth: false },
|
||||
);
|
||||
return data?.data ?? [];
|
||||
}
|
||||
|
||||
/* ── Vorschau ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
export type PreviewDoc = { art: 'brief' | 'umschlag'; svg: string };
|
||||
/** Ein Schriftstück, dessen Text nicht auf das Format passt (Zeilen-Überlauf). */
|
||||
export type PreviewOverflow = { index: number; lineCount: number; lineLimit: number };
|
||||
export type PreviewResult = {
|
||||
docs: PreviewDoc[];
|
||||
remaining: number;
|
||||
limit: number;
|
||||
/** true, wenn mindestens ein Schriftstück zu lang für das Format ist. */
|
||||
has_overflow?: boolean;
|
||||
overflow?: PreviewOverflow[];
|
||||
/** Vorschau-Session – bei der Bestellung mitschicken, damit gezeigte
|
||||
* Dokumente 1:1 übernommen werden. */
|
||||
session?: string;
|
||||
};
|
||||
|
||||
export async function fetchPreviewQuota(kennung: string): Promise<{ remaining: number; limit: number }> {
|
||||
const p = kennung.includes('@') ? `email=${encodeURIComponent(kennung)}` : `client_id=${encodeURIComponent(kennung)}`;
|
||||
return request(`/skrift-orders/preview/quota?${p}`, { auth: false });
|
||||
}
|
||||
|
||||
export type PreviewInput = {
|
||||
email?: string;
|
||||
client_id?: string;
|
||||
text: string;
|
||||
font: string;
|
||||
format?: string;
|
||||
realistic?: boolean;
|
||||
envelope_labeling?: string;
|
||||
envelope_text?: string;
|
||||
entries: unknown[];
|
||||
};
|
||||
|
||||
export async function generatePreview(input: PreviewInput): Promise<PreviewResult> {
|
||||
return request('/skrift-orders/preview', { method: 'POST', body: input, auth: false });
|
||||
}
|
||||
|
||||
/**
|
||||
* Reine Zeilen-/Überlauf-Prüfung (beim „Weiter" vom Text-Schritt) – verbraucht
|
||||
* KEIN Vorschau-Kontingent und liefert keine SVGs.
|
||||
*/
|
||||
export async function validateText(
|
||||
input: PreviewInput,
|
||||
): Promise<{ has_overflow?: boolean; overflow?: PreviewOverflow[] }> {
|
||||
return request('/skrift-orders/preview', { method: 'POST', body: { ...input, validate: true }, auth: false });
|
||||
}
|
||||
|
||||
/* ── Bestellung absenden ──────────────────────────────────────────────────── */
|
||||
|
||||
export type OrderInput = {
|
||||
email: string;
|
||||
first_name?: string;
|
||||
last_name?: string;
|
||||
company?: string;
|
||||
phone?: string;
|
||||
person_type: 'privat' | 'unternehmen';
|
||||
product: string;
|
||||
format?: string;
|
||||
font: string;
|
||||
shipping_type: 'einzeln' | 'sammel';
|
||||
needs_envelope: boolean;
|
||||
envelope_labeling?: string;
|
||||
motif_mode?: string;
|
||||
text_template?: string;
|
||||
text_briefing?: string;
|
||||
/** Hochgeladenes Dokument (Datei-Produkte, z. B. zu unterschreibendes PDF). */
|
||||
source_file?: string;
|
||||
/** Rechnungs-/Lieferadresse als Snapshot (für Bestätigung & Rechnung). */
|
||||
billing?: Record<string, string>;
|
||||
shipping_address?: Record<string, string> | null;
|
||||
/** Vorschau-Session (gezeigte Dokumente werden 1:1 übernommen). */
|
||||
preview_session?: string;
|
||||
addons?: string[];
|
||||
voucher_code?: string;
|
||||
payment_method: 'paypal' | 'rechnung';
|
||||
source?: string;
|
||||
entries: unknown[];
|
||||
/** Motiv aus dem Katalog (ID) bzw. hochgeladene Datei (Directus-Datei-ID). */
|
||||
motif?: number;
|
||||
motif_upload?: string;
|
||||
};
|
||||
|
||||
export type OrderResult = {
|
||||
order_number: string;
|
||||
net_total: number;
|
||||
vat_amount: number;
|
||||
gross_total: number;
|
||||
payment_method: 'paypal' | 'rechnung';
|
||||
paypal_allowed: boolean;
|
||||
};
|
||||
|
||||
export async function createOrder(input: OrderInput): Promise<OrderResult> {
|
||||
return request('/skrift-orders', { method: 'POST', body: input, auth: false });
|
||||
}
|
||||
|
||||
/** Vermerkt eine erfolgreiche PayPal-Zahlung am Auftrag. */
|
||||
export async function confirmPayment(order_number: string, payment_ref: string) {
|
||||
return request('/skrift-orders/payment', {
|
||||
method: 'POST',
|
||||
body: { order_number, payment_ref },
|
||||
auth: false,
|
||||
});
|
||||
}
|
||||
|
||||
/* ── Öffentliche Einstellungen (Preisformel, PayPal-Client-ID) ─────────────── */
|
||||
|
||||
export type PublicSettings = {
|
||||
mwst_percent: number;
|
||||
/** Erlaubte Zeichen als Regex-Klasse (z. B. "\\x20-\\x7EÄÖÜ…"). */
|
||||
char_whitelist: string | null;
|
||||
paypal_limit_net: number;
|
||||
paypal_enabled: boolean;
|
||||
paypal_mode: 'sandbox' | 'live';
|
||||
paypal_client_id_sandbox: string | null;
|
||||
paypal_client_id_live: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Bewusst `fields=*`: Wenn einzelne Felder (z. B. die PayPal-Spalten) noch
|
||||
* nicht angelegt sind, würde eine explizite Feldliste den ganzen Aufruf
|
||||
* scheitern lassen.
|
||||
*/
|
||||
export async function fetchSettings(): Promise<PublicSettings | null> {
|
||||
const data = await request('/items/pricing_settings?fields=*', { auth: false });
|
||||
return data?.data ?? null;
|
||||
}
|
||||
|
||||
/** Die zum Modus passende öffentliche Client-ID. */
|
||||
export const paypalClientId = (s: PublicSettings | null) =>
|
||||
!s || !s.paypal_enabled
|
||||
? null
|
||||
: (s.paypal_mode === 'live' ? s.paypal_client_id_live : s.paypal_client_id_sandbox) || null;
|
||||
|
||||
/* ── Datei-Upload (Motiv) ─────────────────────────────────────────────────── */
|
||||
|
||||
/** Lädt eine Datei nach Directus und liefert deren ID. */
|
||||
export async function uploadFile(datei: File): Promise<string> {
|
||||
const form = new FormData();
|
||||
form.append('file', datei);
|
||||
|
||||
const session = await ensureFreshToken();
|
||||
const res = await fetch(`${BASE}/files`, {
|
||||
method: 'POST',
|
||||
headers: session ? { Authorization: `Bearer ${session.access_token}` } : undefined,
|
||||
body: form,
|
||||
});
|
||||
const data = await parse(res);
|
||||
return data?.data?.id;
|
||||
}
|
||||
|
||||
/* ── Angebotsanfrage ──────────────────────────────────────────────────────── */
|
||||
|
||||
export type OfferRequest = {
|
||||
name: string;
|
||||
email: string;
|
||||
phone?: string;
|
||||
quantity?: number | null;
|
||||
message: string;
|
||||
/** Bei Anfrage aus einem Kontakt-Produkt: dessen Name. */
|
||||
product?: string;
|
||||
};
|
||||
|
||||
export async function sendOfferRequest(input: OfferRequest) {
|
||||
return request('/skrift-orders/offer-request', { method: 'POST', body: input, auth: false });
|
||||
}
|
||||
|
||||
/* ── Musterbestellung (kostenlos) ─────────────────────────────────────────── */
|
||||
|
||||
export type SampleRequest = {
|
||||
product: string;
|
||||
name: string;
|
||||
email: string;
|
||||
phone?: string;
|
||||
street: string;
|
||||
zip: string;
|
||||
city: string;
|
||||
country: string;
|
||||
};
|
||||
|
||||
export async function sendSampleRequest(input: SampleRequest) {
|
||||
return request('/skrift-orders/sample-request', { method: 'POST', body: input, auth: false });
|
||||
}
|
||||
|
||||
/* ── Statusverlauf & Benachrichtigungen ───────────────────────────────────── */
|
||||
|
||||
export type StatusEvent = {
|
||||
id: number;
|
||||
status: string;
|
||||
note: string | null;
|
||||
timestamp: string;
|
||||
order: { id: number; order_number: string } | null;
|
||||
};
|
||||
|
||||
/** Statusverlauf – Directus liefert nur Einträge zu eigenen Aufträgen. */
|
||||
export async function fetchStatusEvents(orderId?: number): Promise<StatusEvent[]> {
|
||||
const filter = orderId ? `&filter[order][_eq]=${orderId}` : '';
|
||||
const data = await request(
|
||||
`/items/status_history?fields=id,status,note,timestamp,order.id,order.order_number` +
|
||||
`&sort=-timestamp&limit=30${filter}`,
|
||||
);
|
||||
return data?.data ?? [];
|
||||
}
|
||||
|
||||
const SEEN_KEY = 'skrift.notifications.seen';
|
||||
|
||||
export function getSeenAt(): number {
|
||||
const raw = localStorage.getItem(SEEN_KEY);
|
||||
return raw ? Number(raw) : 0;
|
||||
}
|
||||
export function markNotificationsSeen() {
|
||||
localStorage.setItem(SEEN_KEY, String(Date.now()));
|
||||
}
|
||||
37
Webseite/skrift-app/src/lib/dialogs.tsx
Normal file
37
Webseite/skrift-app/src/lib/dialogs.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { createContext, useContext, useState } from 'react';
|
||||
|
||||
/**
|
||||
* Zentrale Dialog-Steuerung: Hilfe, Individuelles Angebot und Muster bestellen
|
||||
* werden an einer Stelle (AppShell) gerendert, aber von überall geöffnet
|
||||
* (Seitenleiste, Konfigurator, Kontakt-Produkte).
|
||||
*/
|
||||
export type DialogName = 'hilfe' | 'angebot' | 'muster' | null;
|
||||
|
||||
type Ctx = {
|
||||
dialog: DialogName;
|
||||
/** Optionaler Vorbelegungstext (z. B. gewähltes Kontakt-Produkt). */
|
||||
vorbelegung: string;
|
||||
open: (d: Exclude<DialogName, null>, vorbelegung?: string) => void;
|
||||
close: () => void;
|
||||
};
|
||||
|
||||
const DialogContext = createContext<Ctx>({ dialog: null, vorbelegung: '', open: () => {}, close: () => {} });
|
||||
|
||||
export function DialogsProvider({ children }: { children: React.ReactNode }) {
|
||||
const [dialog, setDialog] = useState<DialogName>(null);
|
||||
const [vorbelegung, setVorbelegung] = useState('');
|
||||
return (
|
||||
<DialogContext.Provider
|
||||
value={{
|
||||
dialog,
|
||||
vorbelegung,
|
||||
open: (d, v = '') => { setVorbelegung(v); setDialog(d); },
|
||||
close: () => setDialog(null),
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</DialogContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export const useDialogs = () => useContext(DialogContext);
|
||||
109
Webseite/skrift-app/src/lib/excel.ts
Normal file
109
Webseite/skrift-app/src/lib/excel.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Excel-Import und -Vorlage für Empfängerdaten.
|
||||
*
|
||||
* Die Vorlage wird im Browser erzeugt – so bleibt sie automatisch synchron mit
|
||||
* den erwarteten Spalten und muss nicht als Datei gepflegt werden.
|
||||
*
|
||||
* SheetJS wird bewusst erst bei Bedarf nachgeladen (dynamischer Import): Die
|
||||
* Bibliothek ist größer als die restliche App und wird nur von wenigen Kunden
|
||||
* gebraucht – sie gehört nicht ins Start-Bundle.
|
||||
*/
|
||||
|
||||
export type ExcelEntry = {
|
||||
salutation: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
street: string;
|
||||
house_no: string;
|
||||
zip: string;
|
||||
city: string;
|
||||
country: string;
|
||||
free_text: string;
|
||||
placeholders: Record<string, string>;
|
||||
};
|
||||
|
||||
/** Feste Spalten; alles Weitere wird als freier Platzhalter übernommen. */
|
||||
const SPALTEN: Record<string, keyof ExcelEntry> = {
|
||||
anrede: 'salutation',
|
||||
vorname: 'first_name',
|
||||
nachname: 'last_name',
|
||||
name: 'last_name',
|
||||
strasse: 'street',
|
||||
straße: 'street',
|
||||
nr: 'house_no',
|
||||
hausnummer: 'house_no',
|
||||
plz: 'zip',
|
||||
ort: 'city',
|
||||
stadt: 'city',
|
||||
land: 'country',
|
||||
};
|
||||
|
||||
const norm = (s: string) => s.trim().toLowerCase().replace(/\s+/g, '');
|
||||
|
||||
export const VORLAGEN_SPALTEN = [
|
||||
'Anrede', 'Vorname', 'Nachname', 'Strasse', 'Nr', 'PLZ', 'Ort', 'Land',
|
||||
];
|
||||
|
||||
/** Spalten der Freitext-Variante: fünf frei beschreibbare Zeilen. */
|
||||
export const FREITEXT_SPALTEN = ['Zeile 1', 'Zeile 2', 'Zeile 3', 'Zeile 4', 'Zeile 5'];
|
||||
|
||||
/** Erzeugt die Vorlagendatei und stößt den Download an. */
|
||||
export async function ladeVorlageHerunter(zusatzSpalten: string[] = [], freitext = false) {
|
||||
const XLSX = await import('xlsx');
|
||||
const basis = freitext ? FREITEXT_SPALTEN : VORLAGEN_SPALTEN;
|
||||
const kopf = [...basis, ...zusatzSpalten];
|
||||
const beispielBasis = freitext
|
||||
? ['Erika Mustermann', 'Hauptstraße 1', '10115 Berlin', '', '']
|
||||
: ['Frau', 'Erika', 'Mustermann', 'Hauptstraße', '1', '10115', 'Berlin', 'DE'];
|
||||
const beispiel = [...beispielBasis, ...zusatzSpalten.map(() => 'Beispielwert')];
|
||||
|
||||
const blatt = XLSX.utils.aoa_to_sheet([kopf, beispiel]);
|
||||
blatt['!cols'] = kopf.map(() => ({ wch: 16 }));
|
||||
const mappe = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(mappe, blatt, 'Empfaenger');
|
||||
XLSX.writeFile(mappe, 'skrift-empfaenger-vorlage.xlsx');
|
||||
}
|
||||
|
||||
/** Liest eine hochgeladene Datei ein und wandelt sie in Empfängerzeilen. */
|
||||
export async function leseEmpfaengerDatei(datei: File): Promise<ExcelEntry[]> {
|
||||
const XLSX = await import('xlsx');
|
||||
const puffer = await datei.arrayBuffer();
|
||||
const mappe = XLSX.read(puffer, { type: 'array' });
|
||||
const blatt = mappe.Sheets[mappe.SheetNames[0]];
|
||||
if (!blatt) throw new Error('Die Datei enthält kein Tabellenblatt.');
|
||||
|
||||
const zeilen = XLSX.utils.sheet_to_json<Record<string, unknown>>(blatt, { defval: '' });
|
||||
if (!zeilen.length) throw new Error('Die Datei enthält keine Datenzeilen.');
|
||||
|
||||
return zeilen.map((zeile) => {
|
||||
const eintrag: ExcelEntry = {
|
||||
salutation: '', first_name: '', last_name: '', street: '',
|
||||
house_no: '', zip: '', city: '', country: 'DE',
|
||||
free_text: '', placeholders: {},
|
||||
};
|
||||
|
||||
// Freitext-Vorlage: „Zeile 1..5" werden zur mehrzeiligen Adresse.
|
||||
const freitextZeilen: string[] = [];
|
||||
|
||||
for (const [spalte, wert] of Object.entries(zeile)) {
|
||||
const text = String(wert ?? '').trim();
|
||||
const zeilenTreffer = /^zeile\s*(\d+)$/i.exec(spalte.trim());
|
||||
const feld = SPALTEN[norm(spalte)];
|
||||
|
||||
if (zeilenTreffer) {
|
||||
freitextZeilen[Number(zeilenTreffer[1]) - 1] = text;
|
||||
} else if (feld && feld !== 'placeholders') {
|
||||
(eintrag[feld] as string) = text;
|
||||
} else if (text) {
|
||||
// Unbekannte Spalten werden zu Platzhaltern – so lassen sich
|
||||
// beliebige eigene Felder im Text verwenden.
|
||||
eintrag.placeholders[spalte.trim()] = text;
|
||||
}
|
||||
}
|
||||
|
||||
if (freitextZeilen.length) {
|
||||
eintrag.free_text = freitextZeilen.map((z) => z ?? '').join('\n').replace(/\n+$/, '');
|
||||
}
|
||||
return eintrag;
|
||||
});
|
||||
}
|
||||
86
Webseite/skrift-app/src/lib/paypal.ts
Normal file
86
Webseite/skrift-app/src/lib/paypal.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* PayPal-Anbindung – clientseitig, wie im bisherigen Konfigurator.
|
||||
*
|
||||
* Es wird ausschließlich die öffentliche Client-ID verwendet; das Secret bleibt
|
||||
* außen vor. Die Zahlung wird über `actions.order.create/capture` direkt gegen
|
||||
* PayPal abgewickelt, die Referenz danach in Directus vermerkt.
|
||||
*/
|
||||
|
||||
let geladen: Promise<void> | null = null;
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
paypal?: {
|
||||
Buttons: (config: Record<string, unknown>) => {
|
||||
render: (el: HTMLElement) => Promise<void>;
|
||||
};
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** Lädt das PayPal-SDK genau einmal nach. */
|
||||
export function ladePayPal(clientId: string): Promise<void> {
|
||||
if (window.paypal) return Promise.resolve();
|
||||
if (geladen) return geladen;
|
||||
|
||||
geladen = new Promise<void>((resolve, reject) => {
|
||||
const script = document.createElement('script');
|
||||
script.src =
|
||||
`https://www.paypal.com/sdk/js?client-id=${encodeURIComponent(clientId)}` +
|
||||
'¤cy=EUR&intent=capture&disable-funding=venmo,paylater,credit';
|
||||
script.async = true;
|
||||
script.onload = () => resolve();
|
||||
script.onerror = () => {
|
||||
geladen = null;
|
||||
reject(new Error('PayPal konnte nicht geladen werden.'));
|
||||
};
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
|
||||
return geladen;
|
||||
}
|
||||
|
||||
export type PayPalOptions = {
|
||||
betrag: number;
|
||||
beschreibung: string;
|
||||
onApproved: (referenz: string) => void | Promise<void>;
|
||||
onError: (meldung: string) => void;
|
||||
};
|
||||
|
||||
/** Rendert die PayPal-Buttons in das übergebene Element. */
|
||||
export async function rendereButtons(el: HTMLElement, o: PayPalOptions) {
|
||||
if (!window.paypal) throw new Error('PayPal ist nicht geladen.');
|
||||
el.innerHTML = '';
|
||||
|
||||
await window.paypal
|
||||
.Buttons({
|
||||
style: { shape: 'rect', layout: 'vertical', color: 'blue', label: 'paypal' },
|
||||
|
||||
createOrder: (_data: unknown, actions: any) =>
|
||||
actions.order.create({
|
||||
intent: 'CAPTURE',
|
||||
purchase_units: [
|
||||
{
|
||||
amount: { currency_code: 'EUR', value: o.betrag.toFixed(2) },
|
||||
description: o.beschreibung.slice(0, 127),
|
||||
},
|
||||
],
|
||||
application_context: {
|
||||
brand_name: 'Skrift',
|
||||
locale: 'de-DE',
|
||||
shipping_preference: 'NO_SHIPPING',
|
||||
user_action: 'PAY_NOW',
|
||||
},
|
||||
}),
|
||||
|
||||
onApprove: (_data: unknown, actions: any) =>
|
||||
actions.order.capture().then((details: any) => {
|
||||
const capture = details?.purchase_units?.[0]?.payments?.captures?.[0];
|
||||
if (capture?.status === 'COMPLETED') return o.onApproved(capture.id);
|
||||
o.onError('Die Zahlung wurde nicht abgeschlossen.');
|
||||
}),
|
||||
|
||||
onError: () => o.onError('Bei der Zahlung ist ein Fehler aufgetreten.'),
|
||||
})
|
||||
.render(el);
|
||||
}
|
||||
18
Webseite/skrift-app/src/lib/status.ts
Normal file
18
Webseite/skrift-app/src/lib/status.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
/** Anzeigetexte für die Auftragsstatus aus Directus – an einer Stelle gepflegt. */
|
||||
export const STATUS_LABEL: Record<string, string> = {
|
||||
entwurf: 'Entwurf',
|
||||
vorbereitet: 'Für Sie vorbereitet',
|
||||
wartet_auf_zahlung: 'Wartet auf Zahlung',
|
||||
bezahlt: 'Bezahlt',
|
||||
material_erwartet: 'Material erwartet',
|
||||
material_eingegangen: 'Material eingegangen',
|
||||
in_queue: 'In der Warteschlange',
|
||||
in_produktion: 'In Produktion',
|
||||
gedruckt: 'Geschrieben',
|
||||
versendet: 'Versendet',
|
||||
abgeschlossen: 'Abgeschlossen',
|
||||
storniert: 'Storniert',
|
||||
fehler: 'Fehler',
|
||||
};
|
||||
|
||||
export const statusText = (s: string) => STATUS_LABEL[s] ?? s;
|
||||
46
Webseite/skrift-app/src/lib/wizard.tsx
Normal file
46
Webseite/skrift-app/src/lib/wizard.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { createContext, useCallback, useContext, useMemo, useRef, useState } from 'react';
|
||||
|
||||
/**
|
||||
* Der Konfigurator meldet seine Schritte hierher, damit die Seitenleiste den
|
||||
* Fortschritt anzeigen kann – ohne dass Shell und Wizard voneinander wissen
|
||||
* müssen. Umgekehrt darf die Seitenleiste über `goto` zu einem bereits
|
||||
* erledigten Schritt zurückspringen; der Konfigurator registriert dafür seinen
|
||||
* Handler (nur Rückwärtssprünge sind erlaubt).
|
||||
*/
|
||||
export type WizardState = { steps: string[]; current: number };
|
||||
|
||||
type Ctx = WizardState & {
|
||||
publish: (s: WizardState) => void;
|
||||
/** Vom Konfigurator aufgerufen: setzt den aktiven Sprung-Handler. */
|
||||
registerGoto: (fn: (i: number) => void) => void;
|
||||
/** Von der Seitenleiste aufgerufen: springe zu Schritt i. */
|
||||
goto: (i: number) => void;
|
||||
};
|
||||
|
||||
const WizardContext = createContext<Ctx>({
|
||||
steps: [], current: 0, publish: () => {}, registerGoto: () => {}, goto: () => {},
|
||||
});
|
||||
|
||||
export function WizardProvider({ children }: { children: React.ReactNode }) {
|
||||
const [state, setState] = useState<WizardState>({ steps: [], current: 0 });
|
||||
const gotoRef = useRef<(i: number) => void>(() => {});
|
||||
|
||||
const publish = useCallback((next: WizardState) => {
|
||||
setState((prev) =>
|
||||
prev.current === next.current && prev.steps.join('|') === next.steps.join('|')
|
||||
? prev // unnötige Renders vermeiden
|
||||
: next,
|
||||
);
|
||||
}, []);
|
||||
const registerGoto = useCallback((fn: (i: number) => void) => { gotoRef.current = fn; }, []);
|
||||
const goto = useCallback((i: number) => gotoRef.current(i), []);
|
||||
|
||||
const value = useMemo<Ctx>(
|
||||
() => ({ ...state, publish, registerGoto, goto }),
|
||||
[state, publish, registerGoto, goto],
|
||||
);
|
||||
|
||||
return <WizardContext.Provider value={value}>{children}</WizardContext.Provider>;
|
||||
}
|
||||
|
||||
export const useWizard = () => useContext(WizardContext);
|
||||
Reference in New Issue
Block a user