diff --git a/proxy/src/index.ts b/proxy/src/index.ts index b303c53..2dc9fc1 100644 --- a/proxy/src/index.ts +++ b/proxy/src/index.ts @@ -245,7 +245,8 @@ async function uebersetzeAnalyse(env: Env, analyse: unknown, sprachName: string) if (d.stop_reason !== 'end_turn' || !text) return null; return JSON.parse(text); } catch (e) { - console.log('Übersetzung fehlgeschlagen', String(e)); + // Nur den Fehlertyp loggen: JSON.parse-Meldungen zitieren Teile der Eingabe (= Briefinhalt) + console.log('Übersetzung fehlgeschlagen', e instanceof Error ? e.name : 'unbekannt'); return null; } } @@ -392,7 +393,9 @@ async function demoHandler(request: Request, env: Env): Promise { ? { type: 'document', source: { type: 'base64', media_type: 'application/pdf', data: bild } } : { type: 'image', source: { type: 'base64', media_type: mimeType, data: bild } }; - const antwort = await fetch(ANTHROPIC_URL, { + let antwort: Response; + try { + antwort = await fetch(ANTHROPIC_URL, { method: 'POST', headers: { 'content-type': 'application/json', @@ -417,6 +420,10 @@ async function demoHandler(request: Request, env: Env): Promise { ], }), }); + } catch (e) { + console.log('Demo-KI nicht erreichbar', String(e)); + return demoFehler(503, 'ki', 'Der KI-Dienst ist gerade nicht erreichbar. Bitte versuchen Sie es in einer Minute erneut.', origin); + } if (!antwort.ok) { const status = antwort.status; @@ -433,7 +440,13 @@ async function demoHandler(request: Request, env: Env): Promise { return demoFehler(502, 'ki', 'Die Analyse ist fehlgeschlagen. Bitte versuchen Sie es mit einem neuen, gut beleuchteten Foto.', origin); } - const daten = (await antwort.json()) as { + let datenRoh: unknown; + try { + datenRoh = await antwort.json(); + } catch { + return demoFehler(502, 'ki', 'Die KI hat keine verwertbare Antwort geliefert. Bitte erneut versuchen.', origin); + } + const daten = datenRoh as { stop_reason?: string; content?: { type: string; text?: string }[]; }; @@ -444,6 +457,17 @@ async function demoHandler(request: Request, env: Env): Promise { if (bezahlt) { // Bezahlte Analyse: Guthaben um 1 verringern (kein TTL — bleibt bestehen) await env.RATE_LIMIT.put(`wallet:${walletId}`, String(walletRest - 1)); + if (einrichtungsName) { + // Für die Auswertung mit der Beratungsstelle: nur Datum und Sprache, + // kein Briefinhalt, kein Bezug zu einer Person. Ein Schlüssel pro + // Analyse, damit gleichzeitige Analysen sich nicht überschreiben. + // Lesen: node tools/einrichtungs-code.js stand + await env.RATE_LIMIT.put( + `nutzung:${walletId}:${heute}:${sprachCode}:${crypto.randomUUID().slice(0, 8)}`, + '1', + { expirationTtl: 60 * 60 * 24 * 390 }, // < 13 Monate (siehe Datenschutzerklärung 9a) + ); + } } else { const schreiben: Promise[] = [ env.RATE_LIMIT.put(tagKey, String(tagZahl + 1), { expirationTtl: TAG_TTL }), @@ -476,11 +500,18 @@ async function demoHandler(request: Request, env: Env): Promise { let analyseDe: unknown = undefined; let ausgabeSprache = sprachCode; + let uebersetzungFehlgeschlagen = false; if (zweisprachig) { - analyseDe = analyse; const uebersetzt = await uebersetzeAnalyse(env, analyse, sprachName); - if (uebersetzt) analyse = uebersetzt; - else ausgabeSprache = 'de'; // lieber Deutsch als gar nichts + if (uebersetzt) { + analyseDe = analyse; + analyse = uebersetzt; + } else { + // lieber Deutsch als gar nichts — aber nur EINE Fassung schicken, + // sonst zeigt die Seite zwei Knöpfe „Deutsch | Deutsch" + ausgabeSprache = 'de'; + uebersetzungFehlgeschlagen = true; + } } return Response.json( @@ -491,6 +522,7 @@ async function demoHandler(request: Request, env: Env): Promise { guthaben: bezahlt ? walletRest - 1 : undefined, einrichtung: einrichtungsName ?? undefined, sprache: ausgabeSprache, + uebersetzung_fehlgeschlagen: uebersetzungFehlgeschlagen || undefined, }, { headers: demoCorsHeaders(origin) } ); @@ -685,7 +717,15 @@ async function kaufStatusHandler(request: Request, env: Env): Promise // Der zufällige Teil im Code macht Raten aussichtslos; zusätzlich sind // Fehlversuche pro IP und Tag begrenzt. -const CODE_FEHLVERSUCHE_PRO_TAG = 10; +// Großzügig, weil sich eine ganze Beratungsstelle eine IP teilt. Raten bleibt +// trotzdem aussichtslos: 31^4 ≈ 920.000 Codes je Präfix. +const CODE_FEHLVERSUCHE_PRO_TAG = 30; + +/** „mbe k7qx", „MBEK7QX", „MBE-K7QX" → „MBE-K7QX" (Bindestrich vor den letzten 4 Zeichen). */ +function normCode(eingabe: string): string { + const roh = eingabe.toUpperCase().replace(/[^A-Z0-9]/g, ''); + return roh.length > 4 ? `${roh.slice(0, -4)}-${roh.slice(-4)}` : roh; +} interface Einrichtung { name: string; @@ -716,7 +756,7 @@ async function codeHandler(request: Request, env: Env): Promise { } catch { return demoFehler(400, 'json', 'Ungültige Anfrage.', origin); } - const code = typeof body.code === 'string' ? body.code.trim().toUpperCase().replace(/\s+/g, '') : ''; + const code = typeof body.code === 'string' ? normCode(body.code) : ''; const ip = request.headers.get('cf-connecting-ip') ?? 'unbekannt'; const heute = new Date().toISOString().slice(0, 10); @@ -782,9 +822,8 @@ export class Uebergabe extends DurableObject { const zahl = (await this.ctx.storage.get('zahl')) ?? 0; if (zahl >= UEBERGABE_MAX_SCHREIBVORGAENGE) return false; await this.ctx.storage.put({ [`fach:${fach}`]: wert, zahl: zahl + 1 }); - if ((await this.ctx.storage.getAlarm()) === null) { - await this.ctx.storage.setAlarm(Date.now() + UEBERGABE_LEBENSDAUER_MS); - } + // gleitend: 15 Minuten nach dem LETZTEN Päckchen löschen, nicht nach dem ersten + await this.ctx.storage.setAlarm(Date.now() + UEBERGABE_LEBENSDAUER_MS); return true; } @@ -896,7 +935,12 @@ export default { const pfad = new URL(request.url).pathname; lokalTest = env.LOKAL_TEST === '1'; // Web-Demo + Bezahl-Routen haben eigene Regeln - if (pfad === '/demo') return demoHandler(request, env); + if (pfad === '/demo') { + return demoHandler(request, env).catch((e) => { + console.log('Demo-Fehler', e instanceof Error ? e.name : 'unbekannt'); + return demoFehler(503, 'dienst', 'Der Dienst ist gerade nicht erreichbar. Bitte versuchen Sie es in einer Minute erneut.', request.headers.get('origin')); + }); + } if (pfad === '/kaufen') return kaufenHandler(request, env); if (pfad === '/kauf-status') return kaufStatusHandler(request, env); if (pfad === '/code') return codeHandler(request, env); diff --git a/proxy/wrangler.toml b/proxy/wrangler.toml index fe121de..6116d8d 100644 --- a/proxy/wrangler.toml +++ b/proxy/wrangler.toml @@ -2,6 +2,11 @@ name = "behoerdenklar-proxy" main = "src/index.ts" compatibility_date = "2026-07-01" +# Worker-Logs im Cloudflare-Dashboard (und `npx wrangler tail`) — hilft, +# wenn live etwas schiefgeht (z. B. die Übersetzung) +[observability] +enabled = true + # Erlaubte Anfragen pro Tag: pro Gerät (normale Nutzung) und pro IP-Adresse # (dämmt Angreifer ein, die Geräte-IDs fälschen; höher, weil sich viele # Nutzer eine IP teilen können) diff --git a/tools/einrichtungs-code.js b/tools/einrichtungs-code.js index 7e93784..6306b87 100644 --- a/tools/einrichtungs-code.js +++ b/tools/einrichtungs-code.js @@ -1,8 +1,9 @@ /** * Einrichtungs-Codes für Beratungsstellen anlegen und verwalten. * + * node tools/einrichtungs-code.js neu "MBE Donnersberg" MBE --briefe 100 --bis 2026-12-18 * node tools/einrichtungs-code.js neu "MBE Donnersberg" MBE --briefe 100 --monate 1 - * node tools/einrichtungs-code.js stand MBE-K7QX + * node tools/einrichtungs-code.js stand MBE-K7QX (mit Nutzung nach Woche und Sprache) * node tools/einrichtungs-code.js aufladen MBE-K7QX 100 * node tools/einrichtungs-code.js verlaengern MBE-K7QX 2027-06-30 * @@ -49,8 +50,26 @@ function plusMonate(monate) { return d.toISOString().slice(0, 10); } +/** Wie im Worker: „mbe k7qx" → „MBE-K7QX". */ +function normCode(c) { + const roh = String(c || '').toUpperCase().replace(/[^A-Z0-9]/g, ''); + return roh.length > 4 ? `${roh.slice(0, -4)}-${roh.slice(-4)}` : roh; +} + +/** Nutzung je Analyse (vom Worker: nutzung::::). */ +function nutzung(walletId) { + let liste = []; + try { + liste = JSON.parse(kv('list', '--prefix', `nutzung:${walletId}:`)); + } catch { return null; } + return liste.map((e) => { + const [, , datum, sprache] = e.name.split(':'); + return { datum, sprache }; + }); +} + function ladeCode(code) { - code = String(code || '').toUpperCase(); + code = normCode(code); const walletId = lies(`code:${code}`); if (!walletId) abbruch(`Code ${code} gibt es nicht.`); const einrichtung = JSON.parse(lies(`einrichtung:${walletId}`) || '{}'); @@ -58,14 +77,31 @@ function ladeCode(code) { return { code, walletId, einrichtung, rest }; } -function zeige({ code, einrichtung, rest }) { +function zeige({ code, einrichtung, rest, walletId }) { const heute = new Date().toISOString().slice(0, 10); + const n = walletId ? nutzung(walletId) : null; + let auswertung = ''; + if (n && n.length) { + const proWoche = {}, proSprache = {}; + for (const { datum, sprache } of n) { + const d = new Date(`${datum}T00:00:00Z`); + const montag = new Date(d.getTime() - ((d.getUTCDay() + 6) % 7) * 86400000).toISOString().slice(0, 10); + proWoche[montag] = (proWoche[montag] || 0) + 1; + proSprache[sprache] = (proSprache[sprache] || 0) + 1; + } + auswertung = ` + Genutzt: ${n.length} Briefe + Pro Woche: ${Object.keys(proWoche).sort().map((w) => `ab ${w}: ${proWoche[w]}`).join(' · ')} + Sprachen: ${Object.entries(proSprache).sort((a, b) => b[1] - a[1]).map(([s, z]) => `${s} ${z}`).join(' · ')}`; + } else if (n) { + auswertung = '\n Genutzt: noch keine Briefe'; + } console.log(` Einrichtung: ${einrichtung.name} Code: \x1b[1m${code}\x1b[0m Übrig: ${rest} Briefe Gültig bis: ${einrichtung.ablauf}${einrichtung.ablauf < heute ? ' \x1b[31m(abgelaufen)\x1b[0m' : ''} - Link: https://behoerdenklar.de/demo?code=${code} + Link: https://behoerdenklar.de/demo?code=${code}${auswertung} `); } @@ -78,7 +114,10 @@ if (befehl === 'neu') { abbruch('So geht es: node tools/einrichtungs-code.js neu "MBE Donnersberg" MBE --briefe 100 --monate 1'); } const briefe = parseInt(option('briefe', '100'), 10); - const ablauf = option('bis', plusMonate(parseInt(option('monate', '1'), 10))); + const monate = parseInt(option('monate', '1'), 10); + const ablauf = option('bis', Number.isFinite(monate) && monate > 0 ? plusMonate(monate) : ''); + if (!(briefe > 0)) abbruch('--briefe braucht eine Zahl größer 0, z. B. --briefe 100'); + if (!/^\d{4}-\d{2}-\d{2}$/.test(ablauf)) abbruch('--bis braucht ein Datum wie 2026-12-18 (oder --monate 1)'); const code = `${praefix}-${zufall(4)}`; if (lies(`code:${code}`)) abbruch('Zufällig schon vergeben — einfach nochmal ausführen.'); const walletId = crypto.randomUUID(); @@ -86,7 +125,7 @@ if (befehl === 'neu') { schreib(`einrichtung:${walletId}`, JSON.stringify({ name, ablauf })); schreib(`code:${code}`, walletId); console.log('\n \x1b[32m✓ Code angelegt\x1b[0m'); - zeige({ code, einrichtung: { name, ablauf }, rest: briefe }); + zeige({ code, einrichtung: { name, ablauf }, rest: briefe, walletId }); } else if (befehl === 'stand') { zeige(ladeCode(a)); } else if (befehl === 'aufladen') { diff --git a/webseite/datenschutz.html b/webseite/datenschutz.html index dc4b7db..b013508 100644 --- a/webseite/datenschutz.html +++ b/webseite/datenschutz.html @@ -61,8 +61,9 @@
  • Anthropic PBC (USA) — den Betreiber des KI-Modells „Claude". Anthropic verarbeitet die Daten ausschließlich zur - Erstellung der Analyse, speichert sie maximal 30 Tage (zur - Missbrauchserkennung) und verwendet sie nicht zum Training der KI. + Erstellung der Analyse, löscht sie in der Regel nach 30 Tagen + (länger nur, wenn ein Verdacht auf Missbrauch geprüft werden muss) + und verwendet sie nicht zum Training der KI.
  • @@ -105,15 +106,16 @@

    4. Übermittlung in die USA

    Anthropic PBC und Cloudflare, Inc. haben ihren Sitz in den USA. Die - Übermittlung stützt sich auf den Angemessenheitsbeschluss der - EU-Kommission zum EU-U.S. Data Privacy Framework bzw. auf - EU-Standardvertragsklauseln (Art. 46 DSGVO). + Übermittlung stützt sich auf EU-Standardvertragsklauseln (Art. 46 + Abs. 2 lit. c DSGVO), die Bestandteil der + Auftragsverarbeitungsverträge beider Anbieter sind.

    5. Speicherdauer

      -
    • Brieffotos bei Anthropic: maximal 30 Tage, danach automatische Löschung
    • +
    • Brieffotos bei Anthropic: in der Regel 30 Tage, danach automatische Löschung (länger nur bei Prüfung eines Missbrauchsverdachts)
    • Anonyme Geräte-Kennung + Tageszähler auf unserem Server: maximal 48 Stunden
    • +
    • Technische Server-Protokolle (Cloudflare Workers Logs: Zeitpunkt, aufgerufene Adresse, Fehlerart, technische Metadaten, kein Briefinhalt) zur Fehlersuche: maximal 7 Tage (Art. 6 Abs. 1 lit. f DSGVO)
    • Daten auf Ihrem Gerät: bis Sie sie löschen („Alle Daten löschen" in den Einstellungen oder Deinstallation der App)
    @@ -157,7 +159,7 @@ (Häkchen vor dem Hochladen, Art. 6 Abs. 1 lit. a, Art. 9 Abs. 2 lit. a DSGVO) — über unseren Server (Cloudflare) an Anthropic (USA) zur Analyse geschickt. Es wird auf unserem Server nicht gespeichert; - bei Anthropic wird es nach spätestens 30 Tagen gelöscht (wie in + bei Anthropic wird es in der Regel nach 30 Tagen gelöscht (wie in Abschnitt 3a beschrieben). Das Ergebnis sehen nur Sie in Ihrem Browser; wir speichern es nicht. @@ -222,9 +224,13 @@ Guthaben der Einrichtung, das wie in Abschnitt 9 beschrieben unter einer zufälligen Kennung verwaltet wird. Wir erfahren dabei nicht, wer den Code nutzt, und die Beratungsstelle sieht keine Briefe oder - Ergebnisse, nur wie viele Analysen noch übrig sind. Gegen das Erraten - von Codes zählen wir falsche Eingaben pro IP-Adresse (Speicherung - max. 48 Stunden, Art. 6 Abs. 1 lit. f DSGVO). + Ergebnisse, nur Zahlen: wie viele Analysen noch übrig sind und wie + viele pro Woche und Sprache genutzt wurden. Für die Auswertung + mit der Beratungsstelle zählen wir pro Analyse über einen Code nur das + Datum und die gewählte Sprache, ohne Briefinhalt und ohne Bezug zu + einer Person (Speicherung max. 13 Monate). Gegen das Erraten von + Codes zählen wir falsche Eingaben pro IP-Adresse (Speicherung max. + 48 Stunden). Rechtsgrundlage ist jeweils Art. 6 Abs. 1 lit. f DSGVO.

    9b. Übergabe zwischen Handy und Computer

    diff --git a/webseite/demo.html b/webseite/demo.html index 2bc48b8..bc496bf 100644 --- a/webseite/demo.html +++ b/webseite/demo.html @@ -183,7 +183,7 @@ font-size: 16px; font-weight: 700; } .todo { padding: 8px 0 8px 2px; border-bottom: 1px dotted rgba(33,33,29,0.3); font-size: 16.5px; } - .todo::before { content: '☐'; margin-right: 10px; color: var(--amtsblau); font-size: 18px; } + .todo::before { content: '☐'; margin-inline-end: 10px; color: var(--amtsblau); font-size: 18px; } details { border-bottom: 1px solid rgba(33, 33, 29, 0.2); padding: 12px 4px; } summary { font-weight: 700; cursor: pointer; font-size: 16.5px; list-style: none; } summary::-webkit-details-marker { display: none; } @@ -215,7 +215,7 @@ background: #eaf1fa; border: 2px solid var(--amtsblau); font-size: 16px; } .qr-box { display: none; margin-top: 16px; padding: 18px; background: #fff; border: 2px dashed var(--amtsblau); text-align: center; } - .qr-box .qr { width: 220px; height: 220px; margin: 10px auto; } + .qr-box .qr { width: 290px; max-width: 100%; aspect-ratio: 1; height: auto; margin: 10px auto; } .qr-box .qr svg { width: 100%; height: 100%; display: block; } .qr-box ol { text-align: start; margin: 8px auto 0; max-width: 420px; padding-inline-start: 22px; font-size: 15.5px; } .qr-box .zustand { margin-top: 12px; font-weight: 700; font-size: 16.5px; } @@ -306,11 +306,12 @@ @@ -329,7 +330,7 @@ - +
    Mit dem Handy fotografieren @@ -341,7 +342,7 @@

    ⏳ Warte auf das Handy …

    Verschlüsselt: Den Schlüssel kennt nur dieser QR-Code. Der Code gilt 15 Minuten.

    @@ -369,12 +370,6 @@ Kamera noch auf Mikrofon zu.

    -
    - 🤝 Sie haben einen Code von einer Beratungsstelle? -

    Dann ist BehördenKlar für Sie kostenlos. Geben Sie den Code hier ein:

    - - -
    @@ -384,6 +379,13 @@
    + +
    + 🤝 Sie haben einen Code von einer Beratungsstelle? +

    Dann ist BehördenKlar für Sie kostenlos. Geben Sie den Code hier ein:

    + + +
    @@ -437,7 +439,7 @@
    - Brief + Brief

    @@ -551,8 +553,27 @@ { priceId: 'price_1U6BPRBsRA1AOdnemDUalzb8', titel: '15 Analysen', preis: '6,99 €', best: true }, ]; + // Browser-Speicher abgesichert: Manche Browser (Safari „Alle Cookies + // blockieren", App-interne Browser) werfen schon beim Zugriff einen Fehler. + // Dann merken wir uns die Werte nur, solange die Seite offen ist. + var speicher = (function () { + var ersatz = {}; + return { + get: function (k) { try { return window.localStorage.getItem(k); } catch (e) { return k in ersatz ? ersatz[k] : null; } }, + set: function (k, v) { try { window.localStorage.setItem(k, v); } catch (e) { ersatz[k] = String(v); } }, + remove: function (k) { try { window.localStorage.removeItem(k); } catch (e) { delete ersatz[k]; } }, + }; + })(); + // Zielsprache: kommt als ?sprache=tr von den Sprachseiten, wird gemerkt. var SPRACH_SCHLUESSEL = 'bk_sprache'; + var SPRACH_DEUTSCH = { + tr:'Türkisch', ar:'Arabisch', ru:'Russisch', uk:'Ukrainisch', pl:'Polnisch', ro:'Rumänisch', + en:'Englisch', fa:'Farsi/Dari', ku:'Kurdisch', bg:'Bulgarisch', hr:'Kroatisch', sr:'Serbisch', + bs:'Bosnisch', sq:'Albanisch', it:'Italienisch', el:'Griechisch', es:'Spanisch', pt:'Portugiesisch', + fr:'Französisch', hu:'Ungarisch', vi:'Vietnamesisch', hi:'Hindi', ur:'Urdu', ps:'Paschtu', + ti:'Tigrinya', so:'Somali', zh:'Chinesisch' + }; var SPRACH_NAMEN = { de:'Deutsch', tr:'Türkçe', ar:'العربية', ru:'Русский', uk:'Українська', pl:'Polski', ro:'Română', en:'English', fa:'فارسی', ku:'Kurdî', bg:'Български', hr:'Hrvatski', @@ -560,10 +581,21 @@ pt:'Português', fr:'Français', hu:'Magyar', vi:'Tiếng Việt', hi:'हिन्दी', ur:'اردو', ps:'پښتو', ti:'ትግርኛ', so:'Soomaali', zh:'中文' }; + var sitzungsSprache = null; // vom PC per QR vorgegeben — gilt nur, solange die Seite offen ist function zielsprache() { + if (sitzungsSprache) return sitzungsSprache; var p = new URLSearchParams(window.location.search).get('sprache'); - if (p && SPRACH_NAMEN[p]) { try { localStorage.setItem(SPRACH_SCHLUESSEL, p); } catch (e) {} return p; } - try { var g = localStorage.getItem(SPRACH_SCHLUESSEL); if (g && SPRACH_NAMEN[g]) return g; } catch (e) {} + if (p && SPRACH_NAMEN[p]) { speicher.set(SPRACH_SCHLUESSEL, p); return p; } + var g = speicher.get(SPRACH_SCHLUESSEL); if (g && SPRACH_NAMEN[g]) return g; + // RA4: Sprache des HANDYS vorschlagen (nicht speichern — eine Auswahl von Hand gewinnt). + // Nicht am PC: Dort wählt die Beraterin die Sprache ihres Gegenübers. + var amPc = window.matchMedia && window.matchMedia('(hover: hover) and (pointer: fine)').matches; + var liste = amPc ? [] : (navigator.languages && navigator.languages.length) ? navigator.languages : [navigator.language || '']; + for (var i = 0; i < liste.length; i++) { + var kurz = String(liste[i] || '').toLowerCase().split('-')[0]; + if (kurz === 'de') break; + if (SPRACH_NAMEN[kurz]) return kurz; + } return 'de'; } @@ -591,17 +623,21 @@ // Wallet (passwortloses Guthaben) — Token + zuletzt bekannter Stand lokal. var WALLET_SCHLUESSEL = 'bk_wallet'; var GUTHABEN_SCHLUESSEL = 'bk_guthaben'; - function walletToken() { return localStorage.getItem(WALLET_SCHLUESSEL) || ''; } - function setzeWallet(t) { localStorage.setItem(WALLET_SCHLUESSEL, t); } - function guthaben() { return parseInt(localStorage.getItem(GUTHABEN_SCHLUESSEL) || '0', 10); } - function setzeGuthaben(n) { localStorage.setItem(GUTHABEN_SCHLUESSEL, String(Math.max(0, n | 0))); } + function walletToken() { return speicher.get(WALLET_SCHLUESSEL) || ''; } + function setzeWallet(t) { speicher.set(WALLET_SCHLUESSEL, t); } + function guthaben() { return parseInt(speicher.get(GUTHABEN_SCHLUESSEL) || '0', 10); } + function setzeGuthaben(n) { speicher.set(GUTHABEN_SCHLUESSEL, String(Math.max(0, n | 0))); } function hatGuthaben() { return !!walletToken() && guthaben() > 0; } // Einrichtungs-Code: Name der Beratungsstelle, über die das Guthaben läuft var EINRICHTUNG_SCHLUESSEL = 'bk_einrichtung'; - function einrichtung() { return localStorage.getItem(EINRICHTUNG_SCHLUESSEL) || ''; } + function einrichtung() { return speicher.get(EINRICHTUNG_SCHLUESSEL) || ''; } + // BE3: Ablaufdatum des Einrichtungs-Zugangs (kommt beim Einlösen vom Server) + var ABLAUF_SCHLUESSEL = 'bk_einrichtung_bis'; + var CODE_SCHLUESSEL = 'bk_einrichtung_code'; + function einrichtungBis() { return speicher.get(ABLAUF_SCHLUESSEL) || ''; } function setzeEinrichtung(name) { - if (name) localStorage.setItem(EINRICHTUNG_SCHLUESSEL, name); - else localStorage.removeItem(EINRICHTUNG_SCHLUESSEL); + if (name) speicher.set(EINRICHTUNG_SCHLUESSEL, name); + else { speicher.remove(EINRICHTUNG_SCHLUESSEL); speicher.remove(ABLAUF_SCHLUESSEL); speicher.remove(CODE_SCHLUESSEL); } } // Handy ↔ PC: Ist dieses Gerät gerade das Handy, das für einen PC // fotografiert? Ein Guthaben aus dem QR-Code (w) gilt nur für diese @@ -613,12 +649,25 @@ if (handyModus && handyModus.w) return handyModus.w; return hatGuthaben() ? walletToken() : ''; } + /** An den PC melden. Liefert true/false, ob es geklappt hat (Fehler werden nicht geworfen). */ function meldeAnPc(daten, fach) { - if (!handyModus) return Promise.resolve(); - return BKUebergabe.senden(handyModus.ue, fach || 'status', daten).catch(function () {}); + if (!handyModus) return Promise.resolve(false); + return BKUebergabe.senden(handyModus.ue, fach || 'status', daten) + .then(function () { return true; }, function () { return false; }); + } + /** Das Ergebnis ist das Wichtigste: bis zu 3 Versuche mit kurzer Pause. */ + async function ergebnisAnPc(paket) { + for (var i = 0; i < 3; i++) { + if (await meldeAnPc(paket, 'ergebnis')) return true; + await new Promise(function (r) { setTimeout(r, 1500); }); + } + return false; } function zeigeGuthabenBanner(n) { - guthabenText.textContent = einrichtung() ? '🤝 Kostenlos über ' + einrichtung() + ':' : '💳 Ihr Guthaben:'; + var bis = einrichtungBis(); + guthabenText.textContent = einrichtung() + ? '🤝 Kostenlos über ' + einrichtung() + (bis ? ' (bis ' + deDatum(bis) + ')' : '') + ':' + : '💳 Ihr Guthaben:'; guthabenZahl.textContent = n; guthabenBanner.style.display = 'block'; } @@ -626,10 +675,43 @@ // Schlüsselname bewusst 'v2': setzt evtl. falsch gezählte Stände (aus der // Testphase) bei bestehenden Besuchern automatisch einmalig zurück. var SPEICHER_SCHLUESSEL = 'bk_demo_v2'; - function benutzt() { return parseInt(localStorage.getItem(SPEICHER_SCHLUESSEL) || '0', 10); } - function setzeBenutzt(n) { localStorage.setItem(SPEICHER_SCHLUESSEL, String(n)); } + function benutzt() { return parseInt(speicher.get(SPEICHER_SCHLUESSEL) || '0', 10); } + function setzeBenutzt(n) { speicher.set(SPEICHER_SCHLUESSEL, String(n)); } + var LEER_HINWEIS = 'Die kostenlosen Analysen sind aufgebraucht oder abgelaufen. Ratsuchende: Bitte fragen Sie bei Ihrer Beratungsstelle nach. Beratungsstelle: Ein Anruf genügt, Justin Klein, 0170 4969375. Nach dem Aufladen einfach diese Seite neu laden oder den Code unten noch einmal eingeben.'; + var KEIN_ZUGANG_HANDY = 'Auf diesem Handy sind die kostenlosen Analysen verbraucht. Bitte am Computer den Code der Beratungsstelle einlösen und dann einen neuen QR-Code erzeugen.'; function zeigeStand() { + if (handyModus && handyModus.fertig) return; + // Handy-Modus: nie Kaufangebot oder Code-Feld (die Beratung ist dabei) + if (handyModus && !handyModus.w) { + kaufBlock.style.display = 'none'; + codeTeil.style.display = 'none'; + guthabenBanner.style.display = 'none'; + if (benutzt() >= 2) { + eingabe.style.display = 'none'; + fehlerBox.style.display = 'block'; + fehlerBox.textContent = KEIN_ZUGANG_HANDY; + meldeAnPc({ schritt: 'fehler', code: 'kein_zugang', meldung: 'Am Handy sind die kostenlosen Analysen verbraucht.' }); + } else { + eingabe.style.display = 'block'; + emailTeil.style.display = benutzt() === 1 ? 'block' : 'none'; + } + return; + } + // Einrichtungs-Zugang leer: kein Kaufangebot (Ratsuchende zahlen nichts), + // Code-Feld bleibt, damit nach dem Aufladen ein neues Öffnen reicht + if (einrichtung() && walletToken() && guthaben() <= 0 && !(handyModus && handyModus.w)) { + eingabe.style.display = 'none'; + guthabenBanner.style.display = 'none'; + kaufBlock.style.display = 'none'; + codeTeil.style.display = 'block'; + fehlerBox.style.display = 'block'; + fehlerBox.textContent = LEER_HINWEIS; + return; + } + if (einrichtung() && hatGuthaben()) { + document.querySelector('#demo .formular-kopf').textContent = 'Antrag auf Klartext · kostenlos über ' + einrichtung(); + } if (handyModus && handyModus.w) { eingabe.style.display = 'block'; guthabenBanner.style.display = 'none'; @@ -657,16 +739,25 @@ if (benutzt() >= 2) { eingabe.style.display = 'none'; fehlerBox.style.display = 'block'; - fehlerBox.innerHTML = 'Ihre 2 Gratis-Analysen sind verbraucht. Kaufen Sie unten Guthaben, um hier direkt weiterzumachen — oder tragen Sie sich für die App ein.'; + fehlerBox.innerHTML = 'Ihre 2 Gratis-Analysen sind verbraucht. Haben Sie einen Code von einer Beratungsstelle? Dann geben Sie ihn direkt hier unten ein. Sonst können Sie unten Guthaben kaufen.'; werbung.style.display = 'block'; } else if (benutzt() === 1) { emailTeil.style.display = 'block'; } } - konsens.addEventListener('change', function () { startKnopf.disabled = !konsens.checked; }); + konsens.addEventListener('change', function () { + if (konsens.checked && fehlerBox.dataset.art === 'konsens') fehlerBox.style.display = 'none'; + }); startKnopf.addEventListener('click', function () { fehlerBox.style.display = 'none'; + if (!konsens.checked) { + zeigeFehler('Bitte zuerst oben das Häkchen bei der Einwilligung setzen.'); + fehlerBox.dataset.art = 'konsens'; + konsens.closest('label').scrollIntoView({ behavior: 'smooth', block: 'center' }); + konsens.focus(); + return; + } if (!walletFuerAnalyse() && benutzt() >= 1 && !emailFeld.value.trim()) { emailTeil.style.display = 'block'; emailFeld.focus(); @@ -676,10 +767,39 @@ dateiFeld.click(); }); dateiFeld.addEventListener('change', function () { - if (dateiFeld.files && dateiFeld.files[0]) starteAnalyse(dateiFeld.files[0]); + var datei = dateiFeld.files && dateiFeld.files[0]; + // Feld leeren: sonst meldet der Browser dieselbe Datei beim zweiten Versuch nicht + dateiFeld.value = ''; + if (datei) starteAnalyse(datei); }); + /** Freundlicher Hinweis (kein Fehler) — nutzt das Feld über dem Upload. */ + function zeigeHinweis(text) { + kaufOk.textContent = 'ℹ️ ' + text; + kaufOk.style.display = 'block'; + } + + function zeigeFertigBanner(angekommen, ohneUebersetzung, paket) { + verbundenBanner.innerHTML = ''; + verbundenBanner.appendChild(document.createTextNode(angekommen + ? '✅ Fertig. Die Erklärung steht hier und auf dem Computer. Für einen weiteren Brief tippt die Beratung am Computer noch einmal auf „Lieber mit dem Handy fotografieren".' + : '⚠️ Die Erklärung steht hier, ist aber am Computer nicht angekommen.')); + if (ohneUebersetzung) verbundenBanner.appendChild(document.createTextNode(' Die Übersetzung hat diesmal nicht geklappt, deshalb steht die Erklärung auf Deutsch.')); + if (!angekommen) { + var knopf = document.createElement('button'); + knopf.type = 'button'; knopf.className = 'cta cta-zwei'; + knopf.textContent = '🔁 Noch einmal an den Computer senden'; + knopf.addEventListener('click', async function () { + knopf.disabled = true; knopf.textContent = 'Wird gesendet …'; + var ok = await ergebnisAnPc(paket); + zeigeFertigBanner(ok, ohneUebersetzung, paket); + }); + verbundenBanner.appendChild(knopf); + } + } + function zeigeFehler(text) { + fehlerBox.dataset.art = ''; fehlerBox.style.display = 'block'; fehlerBox.textContent = text; } @@ -761,12 +881,21 @@ status.style.display = 'none'; eingabe.style.display = 'block'; if (window.turnstile) window.turnstile.reset(); - meldeAnPc({ schritt: 'fehler', meldung: daten.meldung || '' }); + meldeAnPc({ schritt: 'fehler', code: daten.code || '', meldung: daten.meldung || '' }); // Guthaben vom PC: Fehler nur anzeigen, am Handy nichts umstellen if (handyModus && handyModus.w) { zeigeFehler(daten.meldung || 'Etwas ist schiefgelaufen. Bitte erneut versuchen.'); return; } + // Handy ohne Zugang vom PC: Kontingent-Fehler nicht als Kaufangebot zeigen + if (handyModus && /^(aufgebraucht|kein_guthaben|budget)$/.test(daten.code || '')) { + if (daten.code === 'aufgebraucht') setzeBenutzt(2); + eingabe.style.display = 'none'; + fehlerBox.style.display = 'block'; + fehlerBox.textContent = KEIN_ZUGANG_HANDY; + meldeAnPc({ schritt: 'fehler', code: 'kein_zugang', meldung: 'Am Handy sind die kostenlosen Analysen verbraucht.' }); + return; + } if (daten.code === 'email_noetig') { // Nur das E-Mail-Feld zeigen — NICHT als verbraucht markieren. // (Sonst würde ein serverseitiges "E-Mail nötig", z. B. bei @@ -781,11 +910,9 @@ return; } if (daten.code === 'kontingent_leer' || daten.code === 'kontingent_abgelaufen') { - setzeWallet(''); + vergissHandySitzung(); setzeGuthaben(0); - setzeEinrichtung(null); zeigeStand(); - zeigeFehler(daten.meldung); return; } if (daten.code === 'kein_guthaben') { @@ -801,17 +928,32 @@ // Token erneuern — sonst würde die nächste Analyse ein "Seite neu laden" verlangen if (window.turnstile) window.turnstile.reset(); + // Die Einwilligung gilt für genau diesen Brief: für den nächsten neu fragen + konsens.checked = false; // Handy fotografiert für den PC: Ergebnis verschlüsselt hinüberschicken - await meldeAnPc({ + var paketFuerPc = { analyse: daten.analyse, analyse_de: daten.analyse_de, sprache: daten.sprache, guthaben: daten.guthaben, einrichtung: daten.einrichtung, - }, 'ergebnis'); + }; + var amPcAngekommen = handyModus ? await ergebnisAnPc(paketFuerPc) : false; var analyse = bereiteVersionen(daten, false); + if (daten.uebersetzung_fehlgeschlagen) { + zeigeHinweis('Die Übersetzung hat diesmal nicht geklappt. Die Erklärung steht hier auf Deutsch.'); + } - if (handyModus && handyModus.w) { + // Handy hat für den PC fotografiert: Verbindung ist damit erledigt. + // (Der PC wartet nicht mehr — ein zweiter Brief käme dort nie an.) + if (handyModus) { + if (!handyModus.w && typeof daten.versuch === 'number') setzeBenutzt(Math.max(benutzt(), daten.versuch)); + handyModus.fertig = true; zeigeAnalyse(analyse); - eingabe.style.display = 'block'; + eingabe.style.display = 'none'; + kaufBlock.style.display = 'none'; + codeTeil.style.display = 'none'; + werbung.style.display = 'none'; + vergissHandySitzung(); + zeigeFertigBanner(amPcAngekommen, daten.uebersetzung_fehlgeschlagen, paketFuerPc); document.getElementById('ergebnis').scrollIntoView({ behavior: 'smooth' }); return; } @@ -829,25 +971,42 @@ status.style.display = 'none'; eingabe.style.display = 'block'; if (window.turnstile) window.turnstile.reset(); + meldeAnPc({ schritt: 'fehler', code: 'verbindung', meldung: 'Verbindung fehlgeschlagen.' }); zeigeFehler('Verbindung fehlgeschlagen. Bitte prüfen Sie Ihr Internet und versuchen Sie es erneut.'); } } /** Ampel wie in der App: rot ≤ 7 Tage, gelb ≤ 21, sonst grün. */ + /** Ganze Kalendertage von heute bis zum ISO-Datum (wie tageBis() in der App). + * Früher wurde bis 12 Uhr mittags gerechnet — dann zeigte ein Termin in + * 7 Tagen vormittags „8 Tage" und Gelb statt Rot. */ + function kalenderTage(iso) { + var t = String(iso).split('-'); + var heute = new Date(); + var a = Date.UTC(heute.getFullYear(), heute.getMonth(), heute.getDate()); + var b = Date.UTC(+t[0], +t[1] - 1, +t[2]); + return Math.round((b - a) / 86400000); + } function ampel(analyse) { var daten = []; - if (analyse.frist && analyse.frist.datum) daten.push(analyse.frist.datum); - if (analyse.termin && analyse.termin.datum) daten.push(analyse.termin.datum); + if (analyse.frist && isoGueltig(analyse.frist.datum)) daten.push({ iso: analyse.frist.datum, art: 'Frist' }); + if (analyse.termin && isoGueltig(analyse.termin.datum)) daten.push({ iso: analyse.termin.datum, art: 'Termin' }); if (!daten.length) return null; - daten.sort(); - var tage = Math.ceil((new Date(daten[0] + 'T12:00:00') - new Date()) / 86400000); + daten.sort(function (x, y) { return x.iso < y.iso ? -1 : x.iso > y.iso ? 1 : 0; }); + // Das nächste kommende Datum zählt; nur wenn alles vorbei ist, das zuletzt vergangene + var kommend = daten.filter(function (d) { return kalenderTage(d.iso) >= 0; }); + var erstes = kommend.length ? kommend[0] : daten[daten.length - 1]; + var tage = kalenderTage(erstes.iso); if (isNaN(tage)) return null; var farbe = tage <= 7 ? 'var(--rot)' : tage <= 21 ? 'var(--gelb)' : 'var(--gruen)'; var punkt = tage <= 7 ? '🔴' : tage <= 21 ? '🟡' : '🟢'; - var text = tage < 0 ? 'Die Frist ist womöglich schon vorbei!' : - tage === 0 ? 'HEUTE ist der wichtige Tag!' : - 'Noch ' + tage + ' Tage Zeit (' + daten[0].split('-').reverse().join('.') + ')'; - return { farbe: farbe, text: punkt + ' ' + text }; + var datum = deDatum(erstes.iso); + var wann = erstes.art === 'Termin' ? 'Termin am ' + datum : 'Frist bis ' + datum; + var text = tage < 0 ? (erstes.art === 'Termin' ? 'Der Termin war am ' + datum + '!' : 'Die Frist ist womöglich schon vorbei (' + datum + ')!') : + tage === 0 ? 'HEUTE: ' + wann + '!' : + tage === 1 ? 'Morgen: ' + wann : + 'Noch ' + tage + ' Tage · ' + wann; + return { farbe: farbe, text: punkt + ' ' + text, istFrist: erstes.art === 'Frist' }; } // Eine Analyse kann in zwei Sprachen vorliegen (PC Deutsch, Handy Muttersprache). @@ -857,7 +1016,10 @@ function bereiteVersionen(d, deutschZuerst) { letzteDaten = { analyse: d.analyse, analyse_de: d.analyse_de, sprache: d.sprache || 'de' }; versionen = [{ code: letzteDaten.sprache, analyse: d.analyse }]; - if (d.analyse_de) versionen[deutschZuerst ? 'unshift' : 'push']({ code: 'de', analyse: d.analyse_de }); + // Zweite Fassung nur, wenn sie wirklich eine andere Sprache ist + if (d.analyse_de && letzteDaten.sprache !== 'de') { + versionen[deutschZuerst ? 'unshift' : 'push']({ code: 'de', analyse: d.analyse_de }); + } aktiveVersion = 0; return versionen[0].analyse; } @@ -891,9 +1053,14 @@ if (a) { ampelEl.style.display = 'block'; ampelEl.style.color = a.farbe; - var teile = [a.text]; - if (analyse.frist && analyse.frist.aktion) teile.push(analyse.frist.aktion); - ampelEl.textContent = teile.join(' · '); + ampelEl.textContent = a.text; + // Die Aufgabe gehört zur Frist — nur anhängen, wenn die Frist das früheste Datum ist. + // KI-Text in eigenes , damit Arabisch/Farsi richtig herum läuft. + if (a.istFrist && analyse.frist && analyse.frist.aktion) { + ampelEl.appendChild(document.createTextNode(' · ')); + var bdi = document.createElement('bdi'); bdi.textContent = analyse.frist.aktion; + ampelEl.appendChild(bdi); + } } if (analyse.checkliste && analyse.checkliste.length) { @@ -910,6 +1077,7 @@ var sum = document.createElement('summary'); sum.textContent = b.begriff; var p = document.createElement('p'); + p.setAttribute('dir', 'auto'); p.textContent = b.erklaerung; det.appendChild(sum); det.appendChild(p); return det.outerHTML; @@ -938,6 +1106,8 @@ zeigeGuthabenBanner(guthabenRest); emailTeil.style.display = 'none'; kaufBlock.style.display = 'none'; + } else if (einrichtung()) { + zeigeStand(); // Leer-Hinweis mit Telefonnummer — kein Kaufangebot für Ratsuchende } else { eingabe.style.display = 'none'; guthabenBanner.style.display = 'none'; @@ -1055,7 +1225,11 @@ var termin = a.termin && isoGueltig(a.termin.datum) ? a.termin : null; var box = document.getElementById('ergKalenderText'); box.innerHTML = ''; - function zeile(text) { var p = document.createElement('p'); p.setAttribute('dir', 'auto'); p.textContent = text; box.appendChild(p); } + function zeile(text, kiText) { + var p = document.createElement('p'); p.setAttribute('dir', 'auto'); p.textContent = text; + if (kiText) { p.appendChild(document.createTextNode(' — ')); var b = document.createElement('bdi'); b.textContent = kiText; p.appendChild(b); } + box.appendChild(p); + } // Kein Datum im Brief: trotzdem sichtbar sagen, dass gesucht wurde. // Sonst weiss niemand, ob nichts da ist oder etwas kaputt ist — und @@ -1080,8 +1254,8 @@ knopf.style.display = ''; hinweis.style.display = ''; karte.style.border = '2px solid var(--amtsblau)'; - if (frist) zeile('⚠️ Frist: ' + deDatum(frist.datum) + (frist.aktion ? ' — ' + frist.aktion : '')); - if (termin) zeile('📅 Termin: ' + deDatum(termin.datum) + (termin.uhrzeit ? ', ' + termin.uhrzeit + ' Uhr' : '') + (termin.ort ? ' — ' + termin.ort : '')); + if (frist) zeile('⚠️ Frist: ' + deDatum(frist.datum), frist.aktion); + if (termin) zeile('📅 Termin: ' + deDatum(termin.datum) + (termin.uhrzeit ? ', ' + termin.uhrzeit + ' Uhr' : ''), termin.ort); karte.style.display = 'block'; } @@ -1130,7 +1304,8 @@ kopf.style.marginTop = '12px'; kopf.innerHTML = 'Das können Sie am Telefon sagen:'; liste.appendChild(kopf); - var mitBedeutung = zielsprache() !== 'de'; + var angezeigt = versionen.length ? versionen[aktiveVersion].code : zielsprache(); + var mitBedeutung = angezeigt !== 'de'; saetze.forEach(function (s) { var d = document.createElement('div'); d.className = 'anruf-satz'; var de = document.createElement('p'); de.className = 'anruf-de'; @@ -1231,8 +1406,13 @@ } // ---- Einrichtungs-Code einlösen ------------------------------------ + /** „mbe k7qx", „MBEK7QX", „MBE-K7QX" → „MBE-K7QX" (wie im Worker). */ + function normCode(c) { + var roh = String(c || '').toUpperCase().replace(/[^A-Z0-9]/g, ''); + return roh.length > 4 ? roh.slice(0, -4) + '-' + roh.slice(-4) : roh; + } function loeseCodeEin(code) { - code = String(code || '').trim().toUpperCase(); + code = normCode(code); if (!code) { codeFeld.focus(); return; } if (walletToken() && guthaben() > 0 && !einrichtung() && !window.confirm('Sie haben noch gekauftes Guthaben. Wenn Sie den Code einlösen, nutzt diese Seite den Code. Ihr gekauftes Guthaben bleibt über den Link in Ihrer Kauf-E-Mail erhalten. Weiter?')) { @@ -1257,11 +1437,11 @@ setzeWallet(res.d.token); setzeGuthaben(res.d.guthaben); setzeEinrichtung(res.d.einrichtung); + if (res.d.ablauf) speicher.set(ABLAUF_SCHLUESSEL, res.d.ablauf); + speicher.set(CODE_SCHLUESSEL, code); + zeichneHandyQr(); zeigeStand(); - if (res.d.guthaben <= 0) { - zeigeFehler('Die Analysen von ' + res.d.einrichtung + ' sind aufgebraucht. Bitte fragen Sie dort nach.'); - return; - } + if (res.d.guthaben <= 0) return; // zeigeStand zeigt den Leer-Hinweis mit Telefonnummer kaufOk.textContent = '✅ Code eingelöst. BehördenKlar ist für Sie kostenlos, über ' + res.d.einrichtung + '.'; kaufOk.style.display = 'block'; }) @@ -1294,13 +1474,19 @@ function zeichneHandyQr() { if (!pcWarten) return; var sprache = zielsprache(); - handyDeutschZeile.style.display = sprache === 'de' ? 'none' : 'flex'; + // d=1 auch bei „Deutsch" am PC: Stellt die Person am Handy ihre eigene + // Sprache ein, bekommt die Beraterin trotzdem die deutsche Fassung. zeigeQr(handyQr, BKUebergabe.adresse({ - a: 'k', s: pcWarten.ue.id, k: pcWarten.ue.k, l: sprache, - d: sprache !== 'de' && handyDeutsch.checked ? '1' : '', + // „Deutsch" am PC heißt nur: nichts vorgeben — dann erkennt das Handy seine Sprache selbst + a: 'k', s: pcWarten.ue.id, k: pcWarten.ue.k, l: sprache !== 'de' ? sprache : '', + d: handyDeutsch.checked ? '1' : '', w: hatGuthaben() ? walletToken() : '', + e: hatGuthaben() ? einrichtung() : '', })); } + window.addEventListener('beforeunload', function (ev) { + if (pcWarten) { ev.preventDefault(); ev.returnValue = ''; } + }); function stoppeWarten() { if (pcWarten && pcWarten.timer) clearInterval(pcWarten.timer); pcWarten = null; @@ -1345,19 +1531,54 @@ var ue = pcWarten.ue; var f = await BKUebergabe.holen(ue); if (!pcWarten || pcWarten.ue !== ue) return; // inzwischen abgebrochen + if ((pcWarten.fehlversuche || 0) >= 5 && !f.status) handyZustand.textContent = '⏳ Warte auf das Handy …'; + pcWarten.fehlversuche = 0; + // Jede neue Meldung vom Handy verlängert das Warten (nicht ab QR-Anzeige rechnen) + var schritt = f.status ? f.status.schritt + '|' + (f.status.meldung || '') : ''; + if (schritt && schritt !== pcWarten.letzterSchritt) { + pcWarten.letzterSchritt = schritt; + // Verbunden: großzügiger warten (Gespräch vor dem Foto kann dauern) + pcWarten.bis = Date.now() + 3 * UEBERGABE_MS; + if (f.status.schritt === 'analyse') pcWarten.analyseSeit = Date.now(); + } + if (f.status && f.status.schritt === 'fehler' && /^kontingent_/.test(f.status.code || '')) { + // Kontingent leer/abgelaufen: Warten ist sinnlos + stoppeWarten(); + BKUebergabe.loeschen(ue); + schliesseHandyBox(); + setzeGuthaben(0); + zeigeStand(); + return; + } + if (f.status && f.status.schritt === 'fehler' && f.status.code === 'kein_zugang') { + stoppeWarten(); + BKUebergabe.loeschen(ue); + schliesseHandyBox(); + zeigeFehler('Am Handy sind die kostenlosen Analysen verbraucht. Bitte hier unten den Code der Beratungsstelle einlösen und dann noch einmal auf „Lieber mit dem Handy fotografieren" tippen.'); + codeTeil.style.display = 'block'; + codeTeil.open = true; + return; + } if (f.ergebnis) { stoppeWarten(); BKUebergabe.loeschen(ue); schliesseHandyBox(); zeigeVomHandy(f.ergebnis); } else if (f.status) { + var langsam = f.status.schritt === 'analyse' && pcWarten.analyseSeit && Date.now() - pcWarten.analyseSeit > 3 * 60 * 1000; handyZustand.textContent = - f.status.schritt === 'analyse' ? '⏳ Das Handy hat fotografiert. Der Brief wird erklärt …' : + langsam ? '⚠️ Das dauert ungewöhnlich lange. Bitte am Handy nachsehen: Steht die Erklärung dort schon, auf „Noch einmal an den Computer senden" tippen. Sonst hier abbrechen und neu verbinden.' : + f.status.schritt === 'analyse' ? '⏳ Das Handy hat fotografiert. Der Brief wird erklärt (ein bis zwei Minuten) …' : f.status.schritt === 'fehler' ? '⚠️ Am Handy hat es nicht geklappt: ' + (f.status.meldung || '') + ' Bitte dort noch einmal versuchen.' : '✅ Handy verbunden. Jetzt am Handy den Brief fotografieren.'; } } catch (e) { - // kurz keine Verbindung — beim nächsten Durchlauf erneut versuchen + // kurz keine Verbindung — beim nächsten Durchlauf erneut versuchen, + // aber nach mehreren Fehlschlägen sichtbar sagen, woran es liegt + if (pcWarten) { + pcWarten.fehlversuche = (pcWarten.fehlversuche || 0) + 1; + if (pcWarten.fehlversuche >= 5) handyZustand.textContent = '⚠️ Keine Verbindung zum Server. Bitte das Internet dieses Computers prüfen.'; + } } finally { pruefeLaeuft = false; } @@ -1409,16 +1630,22 @@ } if (h.a === 'k') { - handyModus = { ue: ue, w: h.w || '', d: h.d === '1' }; + handyModus = { ue: ue, w: h.w || '', d: h.d === '1', e: h.e || '' }; if (h.l && SPRACH_NAMEN[h.l]) { - try { localStorage.setItem(SPRACH_SCHLUESSEL, h.l); } catch (e) {} + // nicht dauerhaft speichern: Es ist das Handy der Person, nicht der Beratung + sitzungsSprache = h.l; document.getElementById('sprachWahl').value = h.l; } - verbundenBanner.textContent = '🔗 Mit dem Computer verbunden. Fotografieren Sie jetzt Ihren Brief. Die Erklärung erscheint hier und auf dem Bildschirm.'; + verbundenBanner.textContent = '🔗 Mit dem Computer verbunden. Setzen Sie das Häkchen und fotografieren Sie Ihren Brief. Die Erklärung erscheint hier und auf dem Bildschirm.'; verbundenBanner.style.display = 'block'; handyKnopf.style.display = 'none'; + if (handyModus.e) document.querySelector('#demo .formular-kopf').textContent = 'Antrag auf Klartext · kostenlos über ' + handyModus.e; + // „verbunden" zuerst melden — zeigeStand meldet danach ggf. „kein Zugang", + // und das darf nicht von „verbunden" überschrieben werden + await meldeAnPc({ schritt: 'verbunden' }); zeigeStand(); - meldeAnPc({ schritt: 'verbunden' }); + if (handyModus.w) { kaufBlock.style.display = 'none'; codeTeil.style.display = 'none'; } + verbundenBanner.scrollIntoView({ behavior: 'smooth', block: 'start' }); return; } @@ -1438,6 +1665,8 @@ return; } BKUebergabe.loeschen(ue); + codeTeil.style.display = 'none'; + kaufBlock.style.display = 'none'; verbundenBanner.textContent = '📱 Vom Computer übernommen. Tragen Sie die Frist gleich in Ihren Kalender ein, dann vergessen Sie nichts.'; verbundenBanner.style.display = 'block'; zeigeAnalyse(bereiteVersionen(f.ergebnis, false)); @@ -1459,6 +1688,8 @@ zeigeStand(); } + function vergissHandySitzung() { try { sessionStorage.removeItem('bk_handy'); } catch (e) {} } + function pruefeRueckkehr() { var p = new URLSearchParams(window.location.search); var w = p.get('wallet'); @@ -1493,24 +1724,43 @@ var aktiv = zielsprache(); Object.keys(SPRACH_NAMEN).forEach(function (c) { var o = document.createElement('option'); - o.value = c; o.textContent = SPRACH_NAMEN[c]; + o.value = c; o.textContent = SPRACH_NAMEN[c] + (SPRACH_DEUTSCH[c] ? ' – ' + SPRACH_DEUTSCH[c] : ''); if (c === aktiv) o.selected = true; feld.appendChild(o); }); feld.addEventListener('change', function () { - try { localStorage.setItem(SPRACH_SCHLUESSEL, feld.value); } catch (e) {} + speicher.set(SPRACH_SCHLUESSEL, feld.value); + sitzungsSprache = null; + if (/[?&]sprache=/.test(window.location.search) && window.history.replaceState) { + var u = new URL(window.location.href); + u.searchParams.delete('sprache'); + window.history.replaceState(null, document.title, u.pathname + u.search + u.hash); + } zeichneHandyQr(); }); })(); rendrePakete(); var startHash = BKUebergabe.ausAdresse(); + var startSuche = window.location.search; // vor pruefeRueckkehr, das die Adresse aufräumt + // Handy-Verbindung übersteht ein Neuladen (z. B. wenn Android den Tab beim + // Kamera-Wechsel neu lädt). sessionStorage gilt nur für diesen einen Tab. + try { + if (startHash && startHash.a === 'k') sessionStorage.setItem('bk_handy', JSON.stringify(startHash)); + else if (!startHash) { var gemerkt = sessionStorage.getItem('bk_handy'); if (gemerkt) startHash = JSON.parse(gemerkt); } + } catch (e) {} if (startHash && window.history.replaceState) { // Schlüssel nicht im Browser-Verlauf stehen lassen window.history.replaceState(null, document.title, window.location.pathname + window.location.search); } pruefeRueckkehr(); zeigeStand(); + // Einrichtungs-Zugang leer? Einmal still nachfragen — vielleicht wurde + // inzwischen aufgeladen oder verlängert (genau ein Aufruf pro Seitenaufruf) + if (!startHash && einrichtung() && walletToken() && guthaben() <= 0 && speicher.get(CODE_SCHLUESSEL) + && !/[?&](code|wallet|kauf)=/.test(startSuche)) { + loeseCodeEin(speicher.get(CODE_SCHLUESSEL)); + } handyKnopf.style.display = istPc ? 'block' : 'none'; document.getElementById('guthabenHandyKnopf').style.display = istPc ? '' : 'none'; pruefeUebergabe(startHash); diff --git a/webseite/uebergabe.js b/webseite/uebergabe.js index e6b0ef5..3cbb4e6 100644 --- a/webseite/uebergabe.js +++ b/webseite/uebergabe.js @@ -69,7 +69,9 @@ /** Alle Fächer holen und entschlüsseln: { status: {...}, ergebnis: {...} }. */ async function holen(ue) { - var r = await fetch(BASIS + ue.id, { cache: 'no-store' }); + // Zeitlimit, damit eine hängende Anfrage die Abfrage am PC nicht blockiert + var signal = (w.AbortSignal && w.AbortSignal.timeout) ? w.AbortSignal.timeout(10000) : undefined; + var r = await fetch(BASIS + ue.id, { cache: 'no-store', signal: signal }); if (!r.ok) throw new Error('Übergabe nicht erreichbar (' + r.status + ')'); var faecher = (await r.json()).faecher || {}; var aus = {};