// Reine Ansichtslogik der Auftragsbestätigung (AB) im Kundenportal: Token, Beträge, Daten, Zustände, Status, AB-Nummer // und alle Seitentexte. Ohne Importe, ohne DOM, ohne fetch; jede Funktion verträgt fehlende Felder. // Die Anrede wählt der Aufrufer über ANREDE_SIE (öffentliche Seite, Widerrufsseite) oder ANREDE_DU (angemeldetes Portal). export const TOKEN_MUSTER = /^[A-Za-z0-9_-]{43}$/ export const KONTAKT = { email: 'support@webklar.com', telefon: '0176 23726355' } export const BESTAETIGUNG_FALLBACK = 'Ich bestätige den Auftrag zu diesen Bedingungen.' export const ANREDE_SIE = 'sie' export const ANREDE_DU = 'du' /** Texte, die in beiden Anreden gleich lauten (weder Du- noch Sie-Formen). */ export const TEXTE_GEMEINSAM = { titelOffen: 'Auftragsbestätigung', leistung: 'Leistungsumfang', aufwand: 'Abrechnung nach Aufwand', zahlung: 'Zahlung', termine: 'Termine', unterlagen: 'Unterlagen', spalten: ['Beschreibung', 'Menge', 'Einzelpreis', 'Summe'], gesamtpreis: 'Gesamtpreis', linkPdf: 'Auftragsbestätigung als PDF ansehen', linkAgb: 'Allgemeine Geschäftsbedingungen (PDF)', freiwillig: '(freiwillig)', knopf: 'Auftrag bestätigen', sendet: 'Wird gesendet …', ansichtsmodus: 'Im Ansichtsmodus kann der Auftrag nicht bestätigt werden. Die Bestätigung gibt der Kunde selbst ab.', erneut: 'Erneut versuchen', kartePdf: 'PDF ansehen', karteDetails: 'Details ansehen', karteDetailsBestaetigen: 'Details ansehen und bestätigen', } /** Öffentliche Seite und Widerrufsseite (Sie-Form); enthält auch die Schlüssel, die das Portal in Du überschreibt. */ export const TEXTE_SIE = { laedt: 'Ihre Auftragsbestätigung wird geladen …', geladen: 'Ihre Auftragsbestätigung wurde geladen.', titelBestaetigt: 'Ihr Auftrag ist bestätigt.', intro: 'Bitte prüfen Sie die Angaben. Mit einem Klick bestätigen Sie den Auftrag.', kontaktVor: 'Sie erreichen uns unter', keinJs: 'Bitte aktivieren Sie JavaScript, um Ihren Auftrag zu bestätigen.', abNummerUngueltig: 'Die Nummer sieht anders aus als AB-2026-0001. Bitte prüfen Sie die Nummer oder lassen Sie das Feld leer.', formular: 'Ihre Bestätigung', speicherHinweis: 'Ihre Bestätigung wird mit Datum und Uhrzeit gespeichert.', fruehbeginnWunsch: 'Wir beginnen auf Ihren Wunsch schon vor Ablauf der Widerrufsfrist.', fehler: { keine: 'Keine Verbindung. Bitte prüfen Sie Ihre Internetverbindung und versuchen Sie es erneut.', haken: 'Bitte setzen Sie das Häkchen, um den Auftrag zu bestätigen.', anmeldung: 'Ihre Anmeldung ist abgelaufen. Bitte laden Sie die Seite neu und melden Sie sich erneut an.', gesperrt: 'Das Bestätigen ist gerade nicht möglich. Bitte laden Sie die Seite neu.', zuviele: 'Zu viele Anfragen. Bitte versuchen Sie es in einigen Minuten noch einmal.', sonst: 'Die Bestätigung konnte nicht gespeichert werden. Bitte versuchen Sie es später noch einmal.', }, } /** Angemeldetes Portal (Du-Form): nur die Schlüssel, die anders lauten. */ export const TEXTE_DU = { formular: 'Deine Bestätigung', speicherHinweis: 'Deine Bestätigung wird mit Datum und Uhrzeit gespeichert.', fruehbeginnWunsch: 'Wir beginnen auf deinen Wunsch schon vor Ablauf der Widerrufsfrist.', fehler: { keine: 'Keine Verbindung. Bitte prüfe deine Internetverbindung und versuche es erneut.', haken: 'Bitte setze das Häkchen, um den Auftrag zu bestätigen.', anmeldung: 'Deine Anmeldung ist abgelaufen. Bitte lade die Seite neu und melde dich erneut an.', gesperrt: 'Das Bestätigen ist gerade nicht möglich. Bitte lade die Seite neu.', zuviele: 'Zu viele Anfragen. Bitte versuche es in einigen Minuten noch einmal.', sonst: 'Die Bestätigung konnte nicht gespeichert werden. Bitte versuche es später noch einmal.', }, } /** Texte der gewählten Anrede (ohne Angabe: Sie). */ export const texte = (anrede = ANREDE_SIE) => ({ ...TEXTE_GEMEINSAM, ...(anrede === ANREDE_DU ? TEXTE_DU : TEXTE_SIE) }) /* ------------------------------------------------------------------ Token und Links --- */ /** Token aus der Adresse: zuerst das Fragment (so verlinkt K31), dann die Suche (Rückfall). '' = ungültig. */ export function tokenAusAdresse(suche = '', hash = '') { for (const roh of [hash, suche]) { const teil = String(roh ?? '').replace(/^[#?]/, '') if (!teil) continue const werte = new URLSearchParams(teil).getAll('t') if (werte.length === 1 && TOKEN_MUSTER.test(werte[0])) return werte[0] } return '' } export const pdfPfad = (token) => `/api/auftrag/${encodeURIComponent(token)}/pdf` /** AGB-Link: nur ein PDF unter /rechtliches/, sonst ''. */ export const agbPfad = (agbUrl) => (typeof agbUrl === 'string' && /^\/rechtliches\/[A-Za-z0-9][A-Za-z0-9._-]*\.pdf$/.test(agbUrl) ? agbUrl : '') /* ------------------------------------------------------------------ Beträge und Zeiten --- */ const EURO = new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }) const BERLIN_TAG = new Intl.DateTimeFormat('sv-SE', { timeZone: 'Europe/Berlin' }) const BERLIN_ZEIT = new Intl.DateTimeFormat('de-DE', { timeZone: 'Europe/Berlin', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hourCycle: 'h23', }) /** Ganze Cent als deutscher Euro-Betrag (negativ mit dem Minuszeichen), sonst '–'. */ export function euroDe(cents) { if (!Number.isSafeInteger(cents)) return '–' const text = EURO.format(cents / 100) return text.startsWith('-') ? `\u{2212}${text.slice(1)}` : text } /** 'YYYY-MM-DD' oder ISO-Zeitpunkt (Berliner Tag) oder 'TT.MM.JJJJ' als 'TT.MM.JJJJ', sonst ''. */ export function datumDe(wert) { if (typeof wert !== 'string') return '' if (/^\d{2}\.\d{2}\.\d{4}$/.test(wert)) return wert if (/^\d{4}-\d{2}-\d{2}$/.test(wert)) return `${wert.slice(8, 10)}.${wert.slice(5, 7)}.${wert.slice(0, 4)}` if (/^\d{4}-\d{2}-\d{2}T/.test(wert)) { const zeit = new Date(wert) if (Number.isNaN(zeit.getTime())) return '' return datumDe(BERLIN_TAG.format(zeit)) } return '' } /** 'TT.MM.JJJJ, HH:MM Uhr' in Berliner Zeit, sonst ''. */ export function zeitpunktDe(iso) { if (typeof iso !== 'string' || !iso) return '' const zeit = new Date(iso) if (Number.isNaN(zeit.getTime())) return '' const teile = Object.fromEntries(BERLIN_ZEIT.formatToParts(zeit).map((teil) => [teil.type, teil.value])) return `${teile.day}.${teile.month}.${teile.year}, ${teile.hour}:${teile.minute} Uhr` } /* ------------------------------------------------------------------ Zustand der AB --- */ export const zeigtFruehbeginn = (a) => typeof a?.texte?.fruehbeginn === 'string' && a.texte.fruehbeginn.trim() !== '' export const darfBestaetigen = (a) => !a?.bestaetigtAm && (a?.status === 'versendet' || a?.status === 'abgerechnet') const STATUS = { versendet: { text: 'Versendet', ton: 'muted' }, bestaetigt: { text: 'Bestätigt', ton: 'ok' }, abgerechnet: { text: 'Abgerechnet', ton: 'ok' }, storniert: { text: 'Storniert', ton: 'muted' }, ersetzt: { text: 'Ersetzt', ton: 'muted' }, widerrufen: { text: 'Widerrufen', ton: 'rot' }, } export function statusAnzeige(a) { if (a?.kannBestaetigen === true && !a.bestaetigtAm) return { text: 'Bitte bestätigen', ton: 'warn' } const eintrag = STATUS[a?.status] return eintrag ? { ...eintrag } : { text: '–', ton: 'muted' } } export function kartenMeta(a) { const teile = [] const datum = datumDe(a?.datum) if (datum) teile.push(`vom ${datum}`) if (a?.art === 'aufwand') teile.push('nach Aufwand') else if (Number.isSafeInteger(a?.summeCents)) teile.push(euroDe(a.summeCents)) return teile.join(' · ') } /** Kopie: zuerst die AB, die noch bestätigt werden kann, dann nach Nummer absteigend (Zeichenvergleich). */ export function sortiereAuftraege(liste) { if (!Array.isArray(liste)) return [] return [...liste].sort((x, y) => { const a = x?.kannBestaetigen === true ? 0 : 1 const b = y?.kannBestaetigen === true ? 0 : 1 if (a !== b) return a - b const nx = String(x?.nummer ?? '') const ny = String(y?.nummer ?? '') return nx < ny ? 1 : nx > ny ? -1 : 0 }) } /* ------------------------------------------------------------------ Inhalte --- */ export function zusammenfassung(a) { const auftrag = a ?? {} const aufwand = auftrag.art === 'aufwand' const kopf = [ { label: 'Nummer', wert: auftrag.nummer || '' }, { label: 'Datum', wert: datumDe(auftrag.datum) }, { label: 'Projekt', wert: auftrag.titel || '' }, ].filter((zeile) => zeile.wert) const positionen = (Array.isArray(auftrag.positionen) ? auftrag.positionen : []).map((p) => ({ beschreibung: String(p?.beschreibung ?? ''), menge: String(p?.menge ?? ''), einzelpreis: euroDe(p?.einzelpreisCents), summe: euroDe(p?.summeCents), })) const termine = [ { label: 'Start', wert: datumDe(auftrag.startAm) }, { label: 'Voraussichtliche Fertigstellung', wert: datumDe(auftrag.fertigBis) }, ].filter((zeile) => zeile.wert) const hinweis = typeof auftrag.texte?.kleinunternehmer === 'string' ? auftrag.texte.kleinunternehmer.trim() : '' return { kopf, art: aufwand ? 'aufwand' : 'festpreis', positionen, summe: !aufwand && Number.isSafeInteger(auftrag.summeCents) ? euroDe(auftrag.summeCents) : '', aufwand: aufwand ? String(auftrag.aufwandText ?? '') : '', zahlung: String(auftrag.zahlungsplanText ?? ''), termine, hinweis, } } export function bestaetigtZeilen(a, anrede = ANREDE_SIE) { if (!a?.bestaetigtAm) return [] const zeitpunkt = zeitpunktDe(a.bestaetigtAm) const zeilen = [zeitpunkt ? `Bestätigt am ${zeitpunkt}` : 'Bestätigt'] if (a.kundenart === 'privat') { if (a.fruehbeginn === true) zeilen.push(texte(anrede).fruehbeginnWunsch) else if (datumDe(a.fruehesterBeginn)) zeilen.push(`Wir beginnen frühestens am ${datumDe(a.fruehesterBeginn)}.`) } return zeilen } /* ------------------------------------------------------------------ Antworten des Servers --- */ const BEENDET_GRUENDE = [['ersetzt', /ersetzt/i], ['storniert', /storniert/i], ['widerrufen', /widerrufen/i]] /** Zustand der öffentlichen Seite aus Status und Antwort (nur feste eigene Texte, nie Servertext). */ export function zustandAusAntwort(status, body) { if (status === 404) { return { zustand: 'ungueltig', titel: 'Dieser Link ist ungültig oder abgelaufen.', text: 'Bitte melden Sie sich bei uns. Wir senden Ihnen gern einen neuen Link.', } } if (status === 410) { const meldung = typeof body?.error === 'string' ? body.error : '' const grund = BEENDET_GRUENDE.find(([, muster]) => muster.test(meldung))?.[0] || '' return { zustand: 'beendet', titel: grund ? `Diese Auftragsbestätigung wurde ${grund}.` : 'Diese Auftragsbestätigung ist nicht mehr gültig.', text: grund === 'ersetzt' ? 'Bitte verwenden Sie den Link in der neuesten Auftragsbestätigung, die wir Ihnen geschickt haben.' : 'Bei Fragen melden Sie sich gern bei uns.', } } if (status === 429) return { zustand: 'limit', titel: 'Zu viele Anfragen', text: 'Bitte versuchen Sie es in einigen Minuten noch einmal.' } return { zustand: 'fehler', titel: 'Das hat leider nicht geklappt', text: 'Ihre Auftragsbestätigung konnte gerade nicht geladen werden. Bitte versuchen Sie es in einigen Minuten noch einmal.', } } /** Meldung beim Bestätigen nach Status (feste eigene Texte der gewählten Anrede). */ export function fehlerMeldung(status, anrede = ANREDE_SIE) { const fehler = texte(anrede).fehler if (status === 0) return fehler.keine if (status === 400) return fehler.haken if (status === 401) return fehler.anmeldung if (status === 403 || status === 409) return fehler.gesperrt if (status === 429) return fehler.zuviele return fehler.sonst } /** Freiwillige AB-Nummer der Widerrufsseite: 'AB-JJJJ-NNNN' oder ''. */ export function abNummerNormalisiert(eingabe) { const text = String(eingabe ?? '').trim().toUpperCase().replace(/[\s\-\u{2010}-\u{2015}\u{2212}]+/gu, '-') return /^AB-\d{4}-\d{4}$/.test(text) ? text : '' }