/** * 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 { 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 = { '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 { 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 { 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 { 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; /** Familie fürs Ausrichtungs-Dropdown (z. B. A5, A6). Leer = eigener Eintrag. */ family?: string | null; /** Ausrichtung innerhalb der Familie. Leer = keine Wahl. */ orientation?: 'hoch' | 'quer' | null; }; /** * Produkt-Schlüssel, die für den angemeldeten Kunden individuell freigeschaltet * sind (z. B. Unterschriftenservice). Nur mit Login sinnvoll; sonst leer. */ export async function fetchMyUnlockedProducts(): Promise { 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 { // 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 { const data = await request( '/items/formats?fields=id,key,name,product&filter[active][_eq]=true&limit=-1', { auth: false }, ); return data?.data ?? []; } /** * Formate für den Textmengencheck: alle mit show_check=true. Das sind die * Text-Seiten-Formate (A4, A5, DIN lang, A6) – unabhängig davon, ob sie im * öffentlichen Konfigurator bestellbar sind. Adress-Postkarten sind ausgenommen. */ export async function fetchCheckFormats(): Promise { const felder = 'fields=id,key,name,product,family,orientation'; try { const data = await request( `/items/formats?${felder}&filter[show_check][_eq]=true&limit=-1`, { auth: false }, ); const rows: Format[] = data?.data ?? []; if (rows.length) return rows; } catch { /* Feld show_check evtl. noch nicht angelegt → Fallback unten. */ } // Fallback (z. B. vor dem Directus-Bootstrap): aktive Formate, damit der Check nutzbar bleibt. const aktiv = await request( `/items/formats?${felder}&filter[active][_eq]=true&limit=-1`, { auth: false }, ); return aktiv?.data ?? []; } /* ── Preis & Bestellung (Extension skrift-orders) ─────────────────────────── */ 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'; /** Gewähltes Kundenangebot (ID) – Aufschläge werden serverseitig addiert. */ offer?: number; }; /** 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 { 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 { const data = await request(`/skrift-orders/my-offers?product=${encodeURIComponent(productKey)}`, { auth: true }); return data?.offers ?? []; } /* ── Motive ───────────────────────────────────────────────────────────────── */ export type Motif = { id: number; name: string; image: string | null; orientation: 'quer' | 'hochkant' | null; }; export async function fetchMotifs(): Promise { 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 { 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 { 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; shipping_address?: Record | null; /** Vorschau-Session (gezeigte Dokumente werden 1:1 übernommen). */ preview_session?: string; addons?: string[]; voucher_code?: string; payment_method: 'paypal' | 'rechnung'; source?: string; /** Gewähltes Kundenangebot (ID). */ offer?: number; 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 { // 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. */ 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 { 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 { // Als Base64 (Data-URL) an den öffentlichen Upload-Proxy schicken – so können // auch Gäste (Bestellung ohne Login) Motive/Quelldateien hochladen. Der direkte // Directus-/files-Upload braucht einen Token, den Gäste nicht haben. const data_base64 = await new Promise((resolve, reject) => { const r = new FileReader(); r.onload = () => resolve(String(r.result || '')); r.onerror = () => reject(new Error('Datei konnte nicht gelesen werden.')); r.readAsDataURL(datei); }); const data = await request('/skrift-orders/upload', { method: 'POST', auth: false, body: { filename: datei.name, content_type: datei.type || 'application/octet-stream', data_base64 }, }); return data?.id; } /* ── Angebotsanfrage ──────────────────────────────────────────────────────── */ export type OfferRequest = { first_name: string; last_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; first_name: string; last_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 }); } /* ── 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 { 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 ───────────────────────────────────── */ 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 { 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())); }