Import aus Monorepo (frischer Start)
All checks were successful
Build & Deploy / deploy (push) Successful in 26s

This commit is contained in:
Lucas Orth
2026-08-24 12:13:54 +02:00
commit 32aa0a549f
38 changed files with 7881 additions and 0 deletions

559
src/lib/api.ts Normal file
View File

@@ -0,0 +1,559 @@
/**
* 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;
};
/** 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;
email: string;
person_type: 'privat' | 'unternehmen' | null;
first_name: string | null;
last_name: string | null;
company: string | null;
phone: string | 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;
};
/**
* 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,billing_address,shipping_address,alt_emails',
);
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,
billing_address: c.billing_address ?? null,
shipping_address: c.shipping_address ?? null,
},
});
}
/* ── 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;
/** 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;
/** 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;
message?: 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()));
}