generated from knso/webklar-preview-template
Compare commits
17 Commits
88c49e1b54
...
2c48a729aa
| Author | SHA1 | Date | |
|---|---|---|---|
| 2c48a729aa | |||
| f21e0b6001 | |||
| 0931986396 | |||
| 72809d3868 | |||
| f2f7059fbf | |||
| 2021390d98 | |||
| 6ed940a77d | |||
| 4ce397026c | |||
| a670cc1758 | |||
| fdfd2d392a | |||
| 7cfe830e9f | |||
| 3dac4cfe14 | |||
| a7838e4db6 | |||
| ad1c2bf76c | |||
| 12ebf514fb | |||
| 8f9f988d57 | |||
| 9dc1d8d87b |
10
.gitignore
vendored
10
.gitignore
vendored
@@ -33,9 +33,19 @@ yarn-error.*
|
||||
# local env files
|
||||
.env*.local
|
||||
|
||||
# Cloudflare Worker (Proxy)
|
||||
.wrangler/
|
||||
.dev.vars
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
|
||||
# generated native folders
|
||||
/ios
|
||||
/android
|
||||
|
||||
# generierte Vorschau-Bilder
|
||||
*.docx.png
|
||||
|
||||
# Word-Sperrdateien
|
||||
~$*
|
||||
|
||||
15
App.tsx
15
App.tsx
@@ -9,6 +9,7 @@ import { createNativeStackNavigator } from '@react-navigation/native-stack';
|
||||
import { RootStackParamList } from './src/types';
|
||||
import { useAppStore } from './src/store/useAppStore';
|
||||
import { initialisiereBenachrichtigungen } from './src/services/erinnerungen';
|
||||
import { AppSchutz } from './src/components/AppSchutz';
|
||||
import { farben, schrift } from './src/theme';
|
||||
import { HomeScreen } from './src/screens/HomeScreen';
|
||||
import { ConsentScreen } from './src/screens/ConsentScreen';
|
||||
@@ -29,13 +30,16 @@ export default function App() {
|
||||
}, [initialisiere]);
|
||||
|
||||
return (
|
||||
<NavigationContainer>
|
||||
<StatusBar style="dark" />
|
||||
<AppSchutz>
|
||||
<NavigationContainer>
|
||||
<StatusBar style="dark" />
|
||||
<Stack.Navigator
|
||||
screenOptions={{
|
||||
headerStyle: { backgroundColor: farben.hintergrund },
|
||||
headerShadowVisible: false,
|
||||
// Apple-Konvention: Titel in Textfarbe, nur Interaktives im Blau-Tint
|
||||
headerTintColor: farben.primaer,
|
||||
headerTitleStyle: { fontSize: schrift.gross, fontWeight: '700' },
|
||||
headerTitleStyle: { fontSize: schrift.gross, fontWeight: '700', color: farben.text },
|
||||
headerBackTitle: 'Zurück',
|
||||
contentStyle: { backgroundColor: farben.hintergrund },
|
||||
}}
|
||||
@@ -47,7 +51,8 @@ export default function App() {
|
||||
<Stack.Screen name="Antwort" component={AntwortScreen} options={{ title: 'Antwort erstellen' }} />
|
||||
<Stack.Screen name="Glossar" component={GlossarScreen} options={{ title: 'Behörden-Glossar' }} />
|
||||
<Stack.Screen name="Einstellungen" component={EinstellungenScreen} options={{ title: 'Einstellungen' }} />
|
||||
</Stack.Navigator>
|
||||
</NavigationContainer>
|
||||
</Stack.Navigator>
|
||||
</NavigationContainer>
|
||||
</AppSchutz>
|
||||
);
|
||||
}
|
||||
|
||||
43
BACKLOG.md
Normal file
43
BACKLOG.md
Normal file
@@ -0,0 +1,43 @@
|
||||
# Feature-Backlog (nach dem Launch)
|
||||
|
||||
Grundsatz: Der Kern-Flow (Scannen → Verstehen → Reagieren) bleibt 3 Tipps
|
||||
kurz. Neues kommt hinter Kacheln/Tabs, nie in den Hauptweg. Erst bauen,
|
||||
wenn echtes Nutzer-Feedback den Bedarf zeigt.
|
||||
|
||||
## Naheliegend (hoher Nutzen, kleiner Aufwand — Kandidaten für v1.1)
|
||||
|
||||
- [ ] **Fristen-Übersicht**: alle Fristen/Termine aller Briefe in einer
|
||||
sortierten Liste (Daten sind schon da — nur ein neuer Screen).
|
||||
Passt perfekt zum Kern-Job "nichts verpassen".
|
||||
- [ ] **App-Icon** im Marken-Stil der Webseite (Briefumschlag/Stempel-Motiv)
|
||||
- [ ] **Home-Widget** (iOS): nächste Frist auf dem Homescreen
|
||||
|
||||
## Mittelfristig (validieren, dann bauen)
|
||||
|
||||
- [ ] Ordner/Kategorien im Archiv (erst relevant, wenn Nutzer >10 Briefe haben)
|
||||
- [ ] Briefe nach Behörde/Aktenzeichen gruppieren
|
||||
- [ ] Musterbrief-Bibliothek offline (häufige Antworten ohne KI-Kosten)
|
||||
- [ ] On-Device-Texterkennung (Apple Vision Framework): Foto verlässt das
|
||||
Handy nie — primär Datenschutz-Feature; braucht Dev-Build + Qualitätstest
|
||||
der Fristen-Erkennung gegen Claude Vision (siehe Gespräch 16.07.)
|
||||
|
||||
## Groß (eigene Projekte, nicht nebenbei)
|
||||
|
||||
- [ ] Formular-Ausfüllhilfe (Anträge) — rechtlich heikler, eigener Freigabe-Check
|
||||
- [ ] Familien-/Betreuer-Modus (Briefe für Angehörige verwalten)
|
||||
- [ ] EU-Datenverarbeitung via AWS Bedrock Frankfurt (Proxy-Umbau)
|
||||
- [ ] **Berater-Modus für Beratungsstellen** (B2B, Lizenz-Modell ~50–100 €/Monat
|
||||
pro Stelle): eigene Oberfläche mit Fall-Ordnern ("Nummer pro Klient" —
|
||||
Idee von Justin, 18.07.) auf dem Berater-Gerät; Beraterin scannt Briefe
|
||||
im Termin und legt sie pro Fall ab, kein Gratis-Limit. WICHTIG: kein
|
||||
Fernzugriff aufs Klienten-Handy (zerstört das Datenschutz-Versprechen) —
|
||||
stattdessen Fall-Ordner lokal beim Berater + optional aktives Teilen
|
||||
einzelner Analysen durch den Klienten. Vor dem Bauen in den
|
||||
Beratungsstellen-Besuchen validieren (Interview-Fragen: Nutzen?
|
||||
Anforderungen? Zahlungsbereitschaft des Trägers?)
|
||||
|
||||
## UI-Wachstumspfad (damit es nie überladen wirkt)
|
||||
|
||||
1. Jetzt: 1 Hero-Button + 2 Kacheln
|
||||
2. Bis 4 Funktionen: Kachel-Reihe erweitern (2x2)
|
||||
3. Darüber: Tab-Leiste (Home / Fristen / Mehr) — Standard-iOS-Muster
|
||||
18
README.md
18
README.md
@@ -36,19 +36,19 @@ App in Expo Go (oder Development Build) öffnen, dann:
|
||||
|
||||
## Produktion: Backend-Proxy statt API-Key in der App
|
||||
|
||||
Für Endkunden darf kein API-Key in der App stecken. Vorbereitet in
|
||||
`src/services/claudeClient.ts`:
|
||||
Für Endkunden darf kein API-Key in der App stecken. Der fertige Proxy
|
||||
(Cloudflare Worker) liegt in [`proxy/`](proxy/README.md) — er hält den Key
|
||||
serverseitig, erlaubt nur die App-Modelle, deckelt `max_tokens` und setzt ein
|
||||
Tageslimit pro Gerät durch.
|
||||
|
||||
Nach dem Deployment (Anleitung: `proxy/README.md`) in
|
||||
`src/services/claudeClient.ts` die Worker-URL eintragen:
|
||||
|
||||
```ts
|
||||
const PROXY_URL: string | null = null; // -> eigene Server-URL eintragen
|
||||
const PROXY_URL: string | null = 'https://behoerdenklar-proxy.<account>.workers.dev';
|
||||
```
|
||||
|
||||
Der Proxy (z. B. Cloudflare Worker / Vercel Function) muss nur:
|
||||
1. den Request-Body unverändert an `https://api.anthropic.com/v1/messages` weiterleiten,
|
||||
2. dabei serverseitig `x-api-key` + `anthropic-version: 2023-06-01` setzen,
|
||||
3. die Antwort unverändert zurückgeben.
|
||||
|
||||
Abrechnung der Endkunden dann per Abo/In-App-Kauf.
|
||||
Abrechnung der Endkunden dann per Abo/In-App-Kauf (Abo-Prüfung im Proxy ergänzen).
|
||||
|
||||
## Architektur
|
||||
|
||||
|
||||
20
app.json
20
app.json
@@ -7,9 +7,11 @@
|
||||
"icon": "./assets/icon.png",
|
||||
"userInterfaceStyle": "light",
|
||||
"ios": {
|
||||
"supportsTablet": true
|
||||
"supportsTablet": true,
|
||||
"bundleIdentifier": "de.behoerdenklar.app"
|
||||
},
|
||||
"android": {
|
||||
"package": "de.behoerdenklar.app",
|
||||
"adaptiveIcon": {
|
||||
"backgroundColor": "#E6F4FE",
|
||||
"foregroundImage": "./assets/android-icon-foreground.png",
|
||||
@@ -38,7 +40,21 @@
|
||||
}
|
||||
],
|
||||
"expo-notifications",
|
||||
"expo-status-bar"
|
||||
"expo-status-bar",
|
||||
[
|
||||
"expo-local-authentication",
|
||||
{
|
||||
"faceIDPermission": "BehördenKlar nutzt Face ID, um Ihre gescannten Briefe vor fremdem Zugriff zu schützen."
|
||||
}
|
||||
],
|
||||
[
|
||||
"expo-splash-screen",
|
||||
{
|
||||
"image": "./assets/splash-icon.png",
|
||||
"backgroundColor": "#E6F4FE",
|
||||
"imageWidth": 200
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
21
eas.json
Normal file
21
eas.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"cli": {
|
||||
"version": ">= 16.0.0",
|
||||
"appVersionSource": "remote"
|
||||
},
|
||||
"build": {
|
||||
"development": {
|
||||
"developmentClient": true,
|
||||
"distribution": "internal"
|
||||
},
|
||||
"preview": {
|
||||
"distribution": "internal"
|
||||
},
|
||||
"production": {
|
||||
"autoIncrement": true
|
||||
}
|
||||
},
|
||||
"submit": {
|
||||
"production": {}
|
||||
}
|
||||
}
|
||||
69
marketing/email-beratungsstellen.md
Normal file
69
marketing/email-beratungsstellen.md
Normal file
@@ -0,0 +1,69 @@
|
||||
# E-Mail-Vorlage: Beratungsstellen
|
||||
|
||||
Absender: behoerdenbriefhelfer@gmail.com
|
||||
Anhänge: BehoerdenKlar-Datenschutz als **PDF** (docx in Word öffnen →
|
||||
Datei → Exportieren → PDF) + die 2 Screenshots aus marketing/screenshots/
|
||||
|
||||
Vor dem Senden anpassen: [ANREDE] — wenn auf der Webseite ein Name der
|
||||
Beraterin/des Beraters steht, unbedingt persönlich anschreiben.
|
||||
|
||||
---
|
||||
|
||||
**An:** mbe@fid-donnersberg.de
|
||||
|
||||
**Betreff:** App aus dem Donnersbergkreis erklärt Behördenbriefe in einfacher Sprache — 10 Minuten Feedback aus Ihrer Praxis?
|
||||
|
||||
Guten Tag [ANREDE / sehr geehrtes Team der Migrationsberatung],
|
||||
|
||||
mein Name ist Justin Klein, ich komme aus Winnweiler und habe eine
|
||||
iPhone-App entwickelt, die Behördenbriefe verständlich macht:
|
||||
|
||||
Brief fotografieren — die App erklärt in einfacher Sprache (A2), was das
|
||||
Amt will, zeigt Fristen und Termine mit Erinnerung und erstellt auf Wunsch
|
||||
einen Antwort-Entwurf. Die Erklärung lässt sich in 9 Sprachen übersetzen,
|
||||
unter anderem Arabisch, Türkisch, Ukrainisch und Farsi — die deutschen
|
||||
Fachbegriffe bleiben dabei sichtbar, damit Ihre Klientinnen und Klienten
|
||||
sie im Brief wiederfinden.
|
||||
|
||||
Ich glaube, das könnte die Menschen entlasten, die zu Ihnen kommen — und
|
||||
vielleicht auch Ihre Beratungszeit: weniger Vorlesen, mehr Lösen.
|
||||
|
||||
Bevor die App im App Store erscheint, möchte ich sie Menschen aus der
|
||||
Praxis zeigen und ehrliches Feedback einholen: Würde das Ihren Klientinnen
|
||||
und Klienten wirklich helfen? Was fehlt? **Dürfte ich dafür einmal für
|
||||
10–15 Minuten bei Ihnen in Kirchheimbolanden vorbeikommen?** Ich richte
|
||||
mich ganz nach Ihrer Zeit.
|
||||
|
||||
Zum Datenschutz, weil das bei Behördenpost besonders zählt: Alle Briefe
|
||||
bleiben verschlüsselt auf dem Handy der Nutzer, es gibt kein Konto, kein
|
||||
Tracking und keine Werbung — eine Übersicht liegt als PDF bei. Die ersten
|
||||
Analysen sind kostenlos, Vielnutzer zahlen später ein kleines Abo
|
||||
(2,99 €/Monat).
|
||||
|
||||
Webseite (auch auf Arabisch, Türkisch u. a.): https://behoerdenklar.pages.dev
|
||||
|
||||
Mit freundlichen Grüßen
|
||||
Justin Klein
|
||||
Winnweiler · behoerdenbriefhelfer@gmail.com
|
||||
|
||||
---
|
||||
|
||||
## Tipps zum Versand
|
||||
|
||||
1. **Anhänge klein halten:** das PDF + maximal 2 Screenshots — keine
|
||||
riesigen Dateien.
|
||||
2. **Nach 5–7 Tagen ohne Antwort: einmal freundlich anrufen** (06352
|
||||
7897955) und auf die Mail verweisen. Das ist normal und nicht aufdringlich.
|
||||
3. Dieselbe Vorlage passt (mit angepasster Anrede/Ort) auch für:
|
||||
- Haus der Diakonie Donnersbergkreis — slb.kibo@diakonie-pfalz.de
|
||||
- Caritas-Zentrum Kaiserslautern (Migrationsfachdienst)
|
||||
4. Beim Besuch mitnehmen: Handy/Simulator mit der App, ausgedrucktes
|
||||
Factsheet, Zettel mit Webseiten-Link. Und die drei Fragen stellen:
|
||||
- „Was würde Ihre Klienten davon abhalten?"
|
||||
- „Würden Sie einen Berater-Modus nutzen (Briefe im Termin scannen,
|
||||
pro Fall ablegen)?"
|
||||
- Bei Begeisterung: „Wer entscheidet bei Ihrem Träger über solche
|
||||
Anschaffungen — und würden Sie mich dort vorstellen?"
|
||||
(Merke: Die Beraterin EMPFIEHLT die App ihren Klienten — dafür
|
||||
braucht sie niemandes Erlaubnis. Die Lizenz kauft später die
|
||||
Leitung — aber nur auf Empfehlung ihrer eigenen Berater.)
|
||||
BIN
marketing/screenshots/screenshot-brief-scannen.png
Normal file
BIN
marketing/screenshots/screenshot-brief-scannen.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 259 KiB |
BIN
marketing/screenshots/screenshot-startbildschirm.png
Normal file
BIN
marketing/screenshots/screenshot-startbildschirm.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 288 KiB |
4287
package-lock.json
generated
4287
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
42
package.json
42
package.json
@@ -6,34 +6,46 @@
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
"@react-navigation/native": "^7.3.8",
|
||||
"@react-navigation/native-stack": "^7.17.10",
|
||||
"expo": "^56.0.15",
|
||||
"expo-calendar": "~56.0.9",
|
||||
"expo-camera": "~56.0.8",
|
||||
"expo-document-picker": "~56.0.4",
|
||||
"expo-file-system": "~56.0.8",
|
||||
"expo-image-picker": "~56.0.20",
|
||||
"expo-network": "~56.0.5",
|
||||
"expo-notifications": "~56.0.20",
|
||||
"expo-print": "~56.0.4",
|
||||
"expo-secure-store": "~56.0.4",
|
||||
"expo-sharing": "~56.0.21",
|
||||
"expo-speech": "~56.0.3",
|
||||
"expo-status-bar": "~56.0.4",
|
||||
"expo": "^57.0.0",
|
||||
"expo-calendar": "~57.0.0",
|
||||
"expo-camera": "~57.0.1",
|
||||
"expo-crypto": "~57.0.1",
|
||||
"expo-document-picker": "~57.0.0",
|
||||
"expo-file-system": "~57.0.0",
|
||||
"expo-image-manipulator": "~57.0.2",
|
||||
"expo-image-picker": "~57.0.2",
|
||||
"expo-local-authentication": "~57.0.1",
|
||||
"expo-network": "~57.0.0",
|
||||
"expo-notifications": "~57.0.3",
|
||||
"expo-print": "~57.0.0",
|
||||
"expo-secure-store": "~57.0.0",
|
||||
"expo-sharing": "~57.0.3",
|
||||
"expo-speech": "~57.0.0",
|
||||
"expo-splash-screen": "~57.0.2",
|
||||
"expo-status-bar": "~57.0.0",
|
||||
"expo-symbols": "~57.0.1",
|
||||
"react": "19.2.3",
|
||||
"react-native": "0.85.3",
|
||||
"react-native": "0.86.0",
|
||||
"react-native-safe-area-context": "~5.7.0",
|
||||
"react-native-screens": "4.25.2",
|
||||
"zustand": "^5.0.14"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jest": "~29.5.14",
|
||||
"@types/react": "~19.2.2",
|
||||
"jest": "~29.7.0",
|
||||
"jest-expo": "~57.0.1",
|
||||
"typescript": "~6.0.3"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "expo start",
|
||||
"android": "expo start --android",
|
||||
"ios": "expo start --ios",
|
||||
"web": "expo start --web"
|
||||
"web": "expo start --web",
|
||||
"test": "jest"
|
||||
},
|
||||
"jest": {
|
||||
"preset": "jest-expo"
|
||||
},
|
||||
"private": true
|
||||
}
|
||||
|
||||
61
proxy/README.md
Normal file
61
proxy/README.md
Normal file
@@ -0,0 +1,61 @@
|
||||
# BehördenKlar Backend-Proxy
|
||||
|
||||
Cloudflare Worker, der den Anthropic-API-Key serverseitig hält. Die App schickt
|
||||
ihre Claude-Anfragen an diesen Worker statt direkt an Anthropic — kein API-Key
|
||||
in der App, Missbrauchsschutz inklusive.
|
||||
|
||||
## Was der Worker macht
|
||||
|
||||
1. Prüft die anonyme Geräte-ID (`x-geraete-id`-Header) und setzt ein
|
||||
**Tageslimit pro Gerät** durch (Standard: 20 Anfragen, siehe `wrangler.toml`)
|
||||
2. Erlaubt nur die **Modelle, die die App nutzt**, und deckelt `max_tokens`
|
||||
3. Leitet den Request-Body unverändert an `api.anthropic.com/v1/messages`
|
||||
weiter und setzt dabei serverseitig `x-api-key` + `anthropic-version`
|
||||
4. Gibt die Anthropic-Antwort unverändert zurück
|
||||
|
||||
## Deployment (einmalig)
|
||||
|
||||
```bash
|
||||
cd proxy
|
||||
npm install
|
||||
|
||||
# Bei Cloudflare anmelden (kostenloser Account reicht für den Start)
|
||||
npx wrangler login
|
||||
|
||||
# KV-Namespace für das Rate-Limit anlegen …
|
||||
npx wrangler kv namespace create RATE_LIMIT
|
||||
# … und die ausgegebene ID in wrangler.toml bei [[kv_namespaces]] eintragen
|
||||
|
||||
# Anthropic-API-Key als Secret hinterlegen (von console.anthropic.com)
|
||||
npx wrangler secret put ANTHROPIC_API_KEY
|
||||
|
||||
# Deployen
|
||||
npm run deploy
|
||||
```
|
||||
|
||||
Der Deploy gibt eine URL aus, z. B. `https://behoerdenklar-proxy.<account>.workers.dev`.
|
||||
|
||||
## App umstellen
|
||||
|
||||
In [`src/services/claudeClient.ts`](../src/services/claudeClient.ts) die URL eintragen:
|
||||
|
||||
```ts
|
||||
const PROXY_URL: string | null = 'https://behoerdenklar-proxy.<account>.workers.dev';
|
||||
```
|
||||
|
||||
Fertig — die App braucht dann keinen API-Key mehr in den Einstellungen.
|
||||
|
||||
## Lokal testen
|
||||
|
||||
```bash
|
||||
npm run dev # startet den Worker auf http://localhost:8787
|
||||
```
|
||||
|
||||
Dann in `claudeClient.ts` vorübergehend `PROXY_URL = 'http://localhost:8787'`
|
||||
setzen (im Simulator; auf echtem Gerät die LAN-IP des Rechners verwenden).
|
||||
|
||||
## Später ergänzen (wenn Abo/IAP kommt)
|
||||
|
||||
- Abo-Prüfung: RevenueCat-Webhook oder Receipt-Validierung vor dem Weiterleiten
|
||||
- Tageslimit je nach Abo-Stufe (Free: 3, Abo: 20+)
|
||||
- Die Geräte-ID durch eine echte Nutzer-ID ersetzen
|
||||
1576
proxy/package-lock.json
generated
Normal file
1576
proxy/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
15
proxy/package.json
Normal file
15
proxy/package.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "behoerdenklar-proxy",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "wrangler dev",
|
||||
"deploy": "wrangler deploy",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/workers-types": "^5.20260708.1",
|
||||
"typescript": "~5.9.0",
|
||||
"wrangler": "^4.0.0"
|
||||
}
|
||||
}
|
||||
128
proxy/src/index.ts
Normal file
128
proxy/src/index.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* BehördenKlar Backend-Proxy — Cloudflare Worker
|
||||
*
|
||||
* Hält den Anthropic-API-Key serverseitig, damit kein Key in der App steckt.
|
||||
* Die App schickt denselben Request-Body wie an die Anthropic Messages API;
|
||||
* der Worker prüft, limitiert und leitet weiter — die Antwort geht
|
||||
* unverändert zurück (die App parst sie wie eine direkte Anthropic-Antwort).
|
||||
*
|
||||
* Schutzmaßnahmen gegen Missbrauch des Endpunkts:
|
||||
* - Nur POST, nur erlaubte Modelle, max_tokens gedeckelt
|
||||
* - Tageslimit pro Geräte-ID (KV-basiert, weiches Limit)
|
||||
*
|
||||
* Deployment: siehe proxy/README.md
|
||||
*/
|
||||
|
||||
interface Env {
|
||||
/** Geheimnis: `wrangler secret put ANTHROPIC_API_KEY` */
|
||||
ANTHROPIC_API_KEY: string;
|
||||
/** KV-Namespace für das Rate-Limit (Binding in wrangler.toml) */
|
||||
RATE_LIMIT: KVNamespace;
|
||||
/** Erlaubte Anfragen pro Gerät und Tag (in wrangler.toml unter [vars]) */
|
||||
TAGES_LIMIT: string;
|
||||
/** Erlaubte Anfragen pro IP-Adresse und Tag (in wrangler.toml unter [vars]) */
|
||||
TAGES_LIMIT_IP: string;
|
||||
}
|
||||
|
||||
const ANTHROPIC_URL = 'https://api.anthropic.com/v1/messages';
|
||||
const ANTHROPIC_VERSION = '2023-06-01';
|
||||
|
||||
/** Nur Modelle, die die App tatsächlich nutzt — verhindert, dass ein
|
||||
* extrahierter Endpunkt als Gratis-Zugang für teure Modelle dient. */
|
||||
const ERLAUBTE_MODELLE = new Set([
|
||||
'claude-sonnet-5', // Brief-Analyse (Vision)
|
||||
'claude-haiku-4-5', // Übersetzung & Antwort-Entwürfe
|
||||
]);
|
||||
|
||||
const MAX_TOKENS_OBERGRENZE = 16000;
|
||||
|
||||
/** Fehlerantwort im Anthropic-Format, damit die App sie normal verarbeitet. */
|
||||
function fehler(status: number, typ: string, meldung: string): Response {
|
||||
return Response.json(
|
||||
{ type: 'error', error: { type: typ, message: meldung } },
|
||||
{ status }
|
||||
);
|
||||
}
|
||||
|
||||
export default {
|
||||
async fetch(request: Request, env: Env): Promise<Response> {
|
||||
if (request.method !== 'POST') {
|
||||
return fehler(405, 'invalid_request_error', 'Nur POST erlaubt.');
|
||||
}
|
||||
|
||||
// Geräte-ID der App (anonym, dient nur dem Tageslimit)
|
||||
const geraeteId = request.headers.get('x-geraete-id');
|
||||
if (!geraeteId || !/^g_[a-z0-9]{24}$/.test(geraeteId)) {
|
||||
return fehler(400, 'invalid_request_error', 'Fehlende oder ungültige Geräte-ID.');
|
||||
}
|
||||
|
||||
let body: Record<string, unknown>;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return fehler(400, 'invalid_request_error', 'Request-Body ist kein gültiges JSON.');
|
||||
}
|
||||
|
||||
if (typeof body.model !== 'string' || !ERLAUBTE_MODELLE.has(body.model)) {
|
||||
return fehler(400, 'invalid_request_error', 'Dieses Modell ist nicht erlaubt.');
|
||||
}
|
||||
// max_tokens deckeln statt ablehnen — schützt vor Kosten-Missbrauch
|
||||
if (typeof body.max_tokens !== 'number' || body.max_tokens > MAX_TOKENS_OBERGRENZE) {
|
||||
body.max_tokens = MAX_TOKENS_OBERGRENZE;
|
||||
}
|
||||
// Die App streamt nicht; Streaming würde das Durchreichen verkomplizieren
|
||||
if (body.stream) {
|
||||
return fehler(400, 'invalid_request_error', 'Streaming wird nicht unterstützt.');
|
||||
}
|
||||
|
||||
// Tageslimits prüfen (weiche Limits: KV ist eventually consistent,
|
||||
// parallele Anfragen können das Limit minimal überschreiten — okay).
|
||||
// Zwei Ebenen: pro Gerät (normale Nutzung) und pro IP-Adresse (dämmt
|
||||
// Angreifer ein, die sich beliebig neue Geräte-IDs ausdenken). Das
|
||||
// IP-Limit ist bewusst höher, weil sich viele Nutzer eine IP teilen
|
||||
// können (Familien-WLAN, Mobilfunk/CGNAT).
|
||||
const heute = new Date().toISOString().slice(0, 10);
|
||||
const ip = request.headers.get('cf-connecting-ip') ?? 'unbekannt';
|
||||
const kvKey = `rl:${geraeteId}:${heute}`;
|
||||
const kvKeyIp = `rlip:${ip}:${heute}`;
|
||||
const limit = parseInt(env.TAGES_LIMIT || '20', 10);
|
||||
const limitIp = parseInt(env.TAGES_LIMIT_IP || '100', 10);
|
||||
const [bisher, bisherIp] = (
|
||||
await Promise.all([env.RATE_LIMIT.get(kvKey), env.RATE_LIMIT.get(kvKeyIp)])
|
||||
).map((wert) => parseInt(wert ?? '0', 10));
|
||||
if (bisher >= limit || bisherIp >= limitIp) {
|
||||
return fehler(
|
||||
429,
|
||||
'rate_limit_error',
|
||||
'Tageslimit erreicht. Bitte versuchen Sie es morgen erneut.'
|
||||
);
|
||||
}
|
||||
|
||||
// An Anthropic weiterleiten — Header werden frisch gebaut, nichts vom
|
||||
// Client wird durchgereicht (außer dem geprüften Body)
|
||||
const antwort = await fetch(ANTHROPIC_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'x-api-key': env.ANTHROPIC_API_KEY,
|
||||
'anthropic-version': ANTHROPIC_VERSION,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
// Nur zählen, wenn die Anfrage Anthropic erreicht hat (5xx kostet kein Kontingent)
|
||||
if (antwort.status < 500) {
|
||||
await Promise.all([
|
||||
env.RATE_LIMIT.put(kvKey, String(bisher + 1), { expirationTtl: 60 * 60 * 48 }),
|
||||
env.RATE_LIMIT.put(kvKeyIp, String(bisherIp + 1), { expirationTtl: 60 * 60 * 48 }),
|
||||
]);
|
||||
}
|
||||
|
||||
return new Response(antwort.body, {
|
||||
status: antwort.status,
|
||||
headers: {
|
||||
'content-type': antwort.headers.get('content-type') ?? 'application/json',
|
||||
},
|
||||
});
|
||||
},
|
||||
} satisfies ExportedHandler<Env>;
|
||||
12
proxy/tsconfig.json
Normal file
12
proxy/tsconfig.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022"],
|
||||
"module": "ES2022",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"types": ["@cloudflare/workers-types"]
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
14
proxy/wrangler.toml
Normal file
14
proxy/wrangler.toml
Normal file
@@ -0,0 +1,14 @@
|
||||
name = "behoerdenklar-proxy"
|
||||
main = "src/index.ts"
|
||||
compatibility_date = "2026-07-01"
|
||||
|
||||
# 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)
|
||||
[vars]
|
||||
TAGES_LIMIT = "20"
|
||||
TAGES_LIMIT_IP = "100"
|
||||
|
||||
[[kv_namespaces]]
|
||||
binding = "RATE_LIMIT"
|
||||
id = "7577adef8ceb4ed6bfaeef81a1d496af"
|
||||
BIN
rechtliches/BehoerdenKlar-Datenschutz.docx
Normal file
BIN
rechtliches/BehoerdenKlar-Datenschutz.docx
Normal file
Binary file not shown.
40
rechtliches/avv-nachweis.md
Normal file
40
rechtliches/avv-nachweis.md
Normal file
@@ -0,0 +1,40 @@
|
||||
# Nachweis: Auftragsverarbeitungsverträge (AVV / DPA)
|
||||
|
||||
Dokumentation für die Rechenschaftspflicht (Art. 5 Abs. 2, Art. 28 DSGVO) —
|
||||
z. B. bei Rückfragen von Kooperationspartnern, Aufsichtsbehörden oder bei der
|
||||
anwaltlichen Prüfung vorlegen.
|
||||
|
||||
Geprüft am: **16.07.2026** (bei Gelegenheit jährlich neu prüfen)
|
||||
|
||||
## Verantwortlicher
|
||||
|
||||
Justin Klein, Am Schwimmbad 10, 67722 Winnweiler
|
||||
Konto-E-Mail bei beiden Anbietern: einkauf.justin@gmail.com
|
||||
|
||||
## Auftragsverarbeiter 1: Anthropic PBC (KI-Analyse)
|
||||
|
||||
| Punkt | Detail |
|
||||
|---|---|
|
||||
| Zweck | KI-Analyse der Brieffotos (Claude API) |
|
||||
| Vertragsgrundlage | Commercial Terms of Service — das **Data Processing Addendum (DPA) ist automatisch Bestandteil**; mit Annahme der Commercial Terms (Konto-Erstellung auf console.anthropic.com) gilt der AVV. Keine separate Unterschrift erforderlich. |
|
||||
| Drittlandtransfer | EU-Standardvertragsklauseln (SCCs) im DPA enthalten; zusätzlich EU-U.S. Data Privacy Framework |
|
||||
| Speicherdauer | API-Eingaben max. 30 Tage (Missbrauchserkennung), kein Training mit API-Daten |
|
||||
| Quellen | https://privacy.claude.com/en/articles/7996862 · https://www.anthropic.com/legal/commercial-terms · https://www.anthropic.com/legal/data-processing-addendum |
|
||||
|
||||
## Auftragsverarbeiter 2: Cloudflare, Inc. (Proxy-Server + Webseite)
|
||||
|
||||
| Punkt | Detail |
|
||||
|---|---|
|
||||
| Zweck | Weiterleitung der Analyse-Anfragen (Worker), Rate-Limit-Zähler (KV), Hosting der Webseite (Pages) |
|
||||
| Vertragsgrundlage | Self-Serve Subscription Agreement — das **Customer DPA ist per Verweis automatisch Bestandteil**, auch für kostenlose Konten. Keine separate Unterschrift erforderlich. |
|
||||
| Drittlandtransfer | EU-SCCs im DPA enthalten; Cloudflare ist zudem unter dem EU-U.S. Data Privacy Framework zertifiziert |
|
||||
| Quellen | https://www.cloudflare.com/cloudflare-customer-dpa/ · https://www.cloudflare.com/trust-hub/gdpr/ |
|
||||
|
||||
## Noch zu tun
|
||||
|
||||
- [ ] Aktuelle DPA-PDFs beider Anbieter herunterladen und in diesem Ordner
|
||||
ablegen (Versionsstand einfrieren — Webseiten ändern sich)
|
||||
- [ ] Bei der anwaltlichen Prüfung der Datenschutzerklärung diesen Nachweis
|
||||
mit vorlegen
|
||||
- [ ] Wenn später ein Newsletter-Dienst (z. B. Brevo) dazukommt: dessen AVV
|
||||
hier ergänzen
|
||||
280
rechtliches/datenschutz-docx-generator.js
Normal file
280
rechtliches/datenschutz-docx-generator.js
Normal file
@@ -0,0 +1,280 @@
|
||||
const {
|
||||
Document, Packer, Paragraph, TextRun, HeadingLevel, AlignmentType,
|
||||
Table, TableRow, TableCell, WidthType, BorderStyle, ShadingType,
|
||||
Numbering, LevelFormat, PageBreak,
|
||||
} = require('docx');
|
||||
const fs = require('fs');
|
||||
|
||||
// --- Markenfarben ---
|
||||
const BLAU = '1A365D'; // primär (dunkles Blau)
|
||||
const BLAU_HELL = 'E8EEF5';
|
||||
const GRUEN = '276749';
|
||||
const GELB = 'B7791F';
|
||||
const GRAU = '5A6472';
|
||||
const GRAU_HELL = 'F4F6F8';
|
||||
const TINTE = '1A202C';
|
||||
const WEISS = 'FFFFFF';
|
||||
|
||||
const FONT = 'Calibri';
|
||||
|
||||
// ---------- Hilfsfunktionen ----------
|
||||
function h1(text) {
|
||||
return new Paragraph({
|
||||
heading: HeadingLevel.HEADING_1,
|
||||
spacing: { before: 320, after: 140 },
|
||||
children: [new TextRun({ text, bold: true, size: 30, color: BLAU, font: FONT })],
|
||||
});
|
||||
}
|
||||
function absatz(runs, opts = {}) {
|
||||
return new Paragraph({
|
||||
spacing: { after: opts.after ?? 140, line: 276 },
|
||||
alignment: opts.align,
|
||||
children: Array.isArray(runs) ? runs : [new TextRun({ text: runs, size: 21, color: TINTE, font: FONT })],
|
||||
});
|
||||
}
|
||||
function bullet(text, opts = {}) {
|
||||
return new Paragraph({
|
||||
numbering: { reference: 'punkte', level: 0 },
|
||||
spacing: { after: 90, line: 264 },
|
||||
children: [
|
||||
opts.fett
|
||||
? new TextRun({ text: opts.fett + ' ', bold: true, size: 21, color: TINTE, font: FONT })
|
||||
: null,
|
||||
new TextRun({ text, size: 21, color: TINTE, font: FONT }),
|
||||
].filter(Boolean),
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- Tabelle: Schutzmaßnahmen ----------
|
||||
function zelle(text, { fett, breite, farbe, textColor, size } = {}) {
|
||||
return new TableCell({
|
||||
width: { size: breite, type: WidthType.DXA },
|
||||
margins: { top: 90, bottom: 90, left: 130, right: 130 },
|
||||
shading: farbe ? { type: ShadingType.CLEAR, fill: farbe, color: 'auto' } : undefined,
|
||||
children: [new Paragraph({
|
||||
spacing: { after: 0, line: 252 },
|
||||
children: [new TextRun({ text, bold: fett, size: size ?? 20, color: textColor ?? TINTE, font: FONT })],
|
||||
})],
|
||||
});
|
||||
}
|
||||
function massnahmeTabelle() {
|
||||
const B1 = 3100, B2 = 6260; // Summe = 9360 (Textbreite bei 2,5cm Rändern)
|
||||
const kopf = new TableRow({
|
||||
tableHeader: true,
|
||||
children: [
|
||||
zelle('Maßnahme', { fett: true, breite: B1, farbe: BLAU, textColor: WEISS }),
|
||||
zelle('Was das konkret bedeutet', { fett: true, breite: B2, farbe: BLAU, textColor: WEISS }),
|
||||
],
|
||||
});
|
||||
const daten = [
|
||||
['Lokale Verschlüsselung', 'Das gesamte Brief-Archiv ist mit AES-256-GCM verschlüsselt. Der Schlüssel liegt im Sicherheitsspeicher des Geräts (iOS Keychain / Android Keystore) und verlässt es nie.'],
|
||||
['App-Sperre (optional)', 'Face ID / Fingerabdruck / Geräte-Code beim Öffnen der App.'],
|
||||
['Sichtschutz', 'Im App-Umschalter wird der Inhalt verdeckt — keine Brief-Vorschau.'],
|
||||
['Auto-Löschen (optional)', 'Briefe werden nach 30 oder 90 Tagen automatisch entfernt.'],
|
||||
['„Alles löschen"', 'Ein Tipp entfernt sämtliche Daten inklusive Verschlüsselungs-Schlüssel.'],
|
||||
['Kein Konto', 'Keine Registrierung, keine E-Mail, kein Passwort nötig.'],
|
||||
['Kein Tracking', 'Keine Analyse- oder Werbe-Bausteine, keine Werbung, keine Datenweitergabe an Dritte.'],
|
||||
['Einwilligung zuerst', 'Vor dem ersten Scan wird verständlich erklärt und aktiv zugestimmt (Art. 6 & 9 DSGVO).'],
|
||||
['Server-Härtung', 'KI-Zugang nur serverseitig, Anfrage-Limits pro Gerät und pro IP-Adresse.'],
|
||||
];
|
||||
const zeilen = daten.map(([a, b], i) => new TableRow({
|
||||
children: [
|
||||
zelle(a, { fett: true, breite: B1, farbe: i % 2 ? GRAU_HELL : WEISS }),
|
||||
zelle(b, { breite: B2, farbe: i % 2 ? GRAU_HELL : WEISS }),
|
||||
],
|
||||
}));
|
||||
return new Table({
|
||||
columnWidths: [B1, B2],
|
||||
width: { size: B1 + B2, type: WidthType.DXA },
|
||||
borders: {
|
||||
top: { style: BorderStyle.SINGLE, size: 2, color: 'D5DBE2' },
|
||||
bottom: { style: BorderStyle.SINGLE, size: 2, color: 'D5DBE2' },
|
||||
left: { style: BorderStyle.SINGLE, size: 2, color: 'D5DBE2' },
|
||||
right: { style: BorderStyle.SINGLE, size: 2, color: 'D5DBE2' },
|
||||
insideHorizontal: { style: BorderStyle.SINGLE, size: 2, color: 'D5DBE2' },
|
||||
insideVertical: { style: BorderStyle.SINGLE, size: 2, color: 'D5DBE2' },
|
||||
},
|
||||
rows: [kopf, ...zeilen],
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- Datenfluss-Schritt (nummerierte Box) ----------
|
||||
function flussSchritt(nr, titel, text) {
|
||||
return new Table({
|
||||
columnWidths: [720, 8640],
|
||||
width: { size: 9360, type: WidthType.DXA },
|
||||
borders: {
|
||||
top: { style: BorderStyle.NONE }, bottom: { style: BorderStyle.NONE },
|
||||
left: { style: BorderStyle.NONE }, right: { style: BorderStyle.NONE },
|
||||
insideHorizontal: { style: BorderStyle.NONE }, insideVertical: { style: BorderStyle.NONE },
|
||||
},
|
||||
rows: [new TableRow({ children: [
|
||||
new TableCell({
|
||||
width: { size: 720, type: WidthType.DXA },
|
||||
verticalAlign: 'center',
|
||||
children: [new Paragraph({
|
||||
alignment: AlignmentType.CENTER, spacing: { after: 0 },
|
||||
children: [new TextRun({ text: String(nr), bold: true, size: 34, color: BLAU, font: FONT })],
|
||||
})],
|
||||
}),
|
||||
new TableCell({
|
||||
width: { size: 8640, type: WidthType.DXA },
|
||||
margins: { top: 40, bottom: 40, left: 120 },
|
||||
children: [new Paragraph({
|
||||
spacing: { after: 40, line: 264 },
|
||||
children: [
|
||||
new TextRun({ text: titel + ' ', bold: true, size: 21, color: TINTE, font: FONT }),
|
||||
new TextRun({ text, size: 21, color: GRAU, font: FONT }),
|
||||
],
|
||||
})],
|
||||
}),
|
||||
] })],
|
||||
});
|
||||
}
|
||||
|
||||
function trennlinie() {
|
||||
return new Paragraph({
|
||||
spacing: { before: 60, after: 200 },
|
||||
border: { bottom: { style: BorderStyle.SINGLE, size: 6, color: BLAU } },
|
||||
children: [new TextRun({ text: '', size: 2 })],
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- Dokument ----------
|
||||
const doc = new Document({
|
||||
creator: 'BehördenKlar',
|
||||
title: 'BehördenKlar — Datenschutz & Sicherheit',
|
||||
numbering: {
|
||||
config: [{
|
||||
reference: 'punkte',
|
||||
levels: [{
|
||||
level: 0, format: LevelFormat.BULLET, text: '•',
|
||||
alignment: AlignmentType.LEFT,
|
||||
style: { run: { color: BLAU }, paragraph: { indent: { left: 360, hanging: 220 } } },
|
||||
}],
|
||||
}],
|
||||
},
|
||||
styles: { default: { document: { run: { font: FONT, size: 21, color: TINTE } } } },
|
||||
sections: [{
|
||||
properties: {
|
||||
page: {
|
||||
size: { width: 11906, height: 16838 }, // A4
|
||||
margin: { top: 1250, bottom: 1250, left: 1418, right: 1418 }, // ~2,5 cm
|
||||
},
|
||||
},
|
||||
children: [
|
||||
// ===== Kopf =====
|
||||
new Paragraph({
|
||||
spacing: { after: 40 },
|
||||
children: [new TextRun({ text: '📬 BehördenKlar', bold: true, size: 40, color: BLAU, font: FONT })],
|
||||
}),
|
||||
new Paragraph({
|
||||
spacing: { after: 60 },
|
||||
children: [new TextRun({ text: 'Datenschutz & Sicherheit auf einen Blick', size: 26, color: TINTE, font: FONT })],
|
||||
}),
|
||||
new Paragraph({
|
||||
spacing: { after: 40 },
|
||||
children: [new TextRun({ text: 'Für Beratungsstellen, Sozialverbände und Kooperationspartner', italics: true, size: 21, color: GRAU, font: FONT })],
|
||||
}),
|
||||
new Paragraph({
|
||||
spacing: { after: 120 },
|
||||
children: [new TextRun({ text: 'Stand: 15.07.2026 · behoerdenbriefhelfer@gmail.com · behoerdenklar.pages.dev', size: 18, color: GRAU, font: FONT })],
|
||||
}),
|
||||
trennlinie(),
|
||||
|
||||
// ===== Was ist die App =====
|
||||
absatz([
|
||||
new TextRun({ text: 'BehördenKlar hilft Menschen, Behördenbriefe zu verstehen: ', size: 21, color: TINTE, font: FONT }),
|
||||
new TextRun({ text: 'Brief fotografieren, Erklärung in einfacher Sprache erhalten — mit Fristen, Checkliste und Antwort-Hilfe, auf Wunsch in 9 weiteren Sprachen. Weil Behördenbriefe hochsensible Daten enthalten, wurde die App von Grund auf datensparsam gebaut. Dieses Dokument erklärt transparent, was mit den Daten passiert — auch die Punkte, die noch offen sind.', size: 21, color: TINTE, font: FONT }),
|
||||
], { after: 160 }),
|
||||
|
||||
// ===== Kernaussage-Box =====
|
||||
new Table({
|
||||
columnWidths: [9360],
|
||||
width: { size: 9360, type: WidthType.DXA },
|
||||
borders: {
|
||||
top: { style: BorderStyle.SINGLE, size: 2, color: BLAU_HELL },
|
||||
bottom: { style: BorderStyle.SINGLE, size: 2, color: BLAU_HELL },
|
||||
left: { style: BorderStyle.SINGLE, size: 18, color: BLAU },
|
||||
right: { style: BorderStyle.SINGLE, size: 2, color: BLAU_HELL },
|
||||
},
|
||||
rows: [new TableRow({ children: [new TableCell({
|
||||
width: { size: 9360, type: WidthType.DXA },
|
||||
shading: { type: ShadingType.CLEAR, fill: BLAU_HELL, color: 'auto' },
|
||||
margins: { top: 130, bottom: 130, left: 170, right: 170 },
|
||||
children: [new Paragraph({
|
||||
spacing: { after: 0, line: 276 },
|
||||
children: [
|
||||
new TextRun({ text: 'Das Wichtigste in einem Satz: ', bold: true, size: 21, color: BLAU, font: FONT }),
|
||||
new TextRun({ text: 'Es gibt kein Nutzerkonto und keinen zentralen Datenspeicher — die Briefe Ihrer Klientinnen und Klienten liegen verschlüsselt auf deren eigenem Handy, nicht bei uns.', size: 21, color: TINTE, font: FONT }),
|
||||
],
|
||||
})],
|
||||
})] })],
|
||||
}),
|
||||
|
||||
// ===== Datenfluss =====
|
||||
h1('Der Datenfluss beim Scannen'),
|
||||
flussSchritt(1, 'Foto entsteht auf dem Gerät', 'und wird vor dem Versand verkleinert.'),
|
||||
flussSchritt(2, 'Übertragung TLS-verschlüsselt', 'an unseren Vermittlungsserver (Cloudflare). Dieser leitet nur weiter und speichert keine Briefinhalte — nur einen anonymen Tageszähler pro Gerät für 48 Stunden.'),
|
||||
flussSchritt(3, 'KI-Analyse bei Anthropic (USA)', 'Verarbeitung nur zur Analyse, Löschung nach spätestens 30 Tagen, keine Nutzung zum KI-Training.'),
|
||||
flussSchritt(4, 'Ergebnis zurück aufs Gerät', 'dort endet der Weg. Keine Kopie bei uns.'),
|
||||
|
||||
// ===== Schutzmaßnahmen =====
|
||||
h1('Schutzmaßnahmen in der App'),
|
||||
absatz([new TextRun({ text: 'Alle folgenden Maßnahmen sind umgesetzt und technisch überprüfbar:', size: 21, color: GRAU, font: FONT, italics: true })], { after: 120 }),
|
||||
massnahmeTabelle(),
|
||||
|
||||
// ===== Ehrliche Punkte (auf neuer Seite) =====
|
||||
new Paragraph({
|
||||
heading: HeadingLevel.HEADING_1,
|
||||
pageBreakBefore: true,
|
||||
spacing: { before: 320, after: 140 },
|
||||
children: [new TextRun({ text: 'Woran wir transparent erinnern', bold: true, size: 30, color: BLAU, font: FONT })],
|
||||
}),
|
||||
absatz([new TextRun({ text: 'Vertrauen entsteht durch Ehrlichkeit — deshalb benennen wir auch das, was nicht perfekt ist:', size: 21, color: GRAU, font: FONT, italics: true })], { after: 120 }),
|
||||
bullet('Die KI-Analyse läuft bei Anthropic (USA), auf Grundlage des EU-U.S. Data Privacy Framework bzw. EU-Standardvertragsklauseln. Eine Verarbeitung in der EU ist beim Anbieter derzeit nicht verfügbar; als Ausbaupfad prüfen wir den Betrieb über EU-Rechenzentren (AWS Frankfurt).', { fett: 'Verarbeitung in den USA:' }),
|
||||
bullet('Anthropic hält Anfragen bis zu 30 Tage zur Missbrauchserkennung vor — danach automatische Löschung. Wir sagen das offen, statt „Ihre Daten verlassen nie das Handy" zu behaupten.', { fett: '30-Tage-Fenster:' }),
|
||||
bullet('Die App erklärt Briefe, ersetzt aber keine Beratung — sie ist als Ergänzung Ihrer Arbeit gedacht, nicht als Ersatz.', { fett: 'Keine Rechtsberatung:' }),
|
||||
bullet('Dieses Dokument ist eine Selbstauskunft, kein Zertifikat. Die vollständige Datenschutzerklärung ist öffentlich unter behoerdenklar.pages.dev/datenschutz.', { fett: 'Transparenz:' }),
|
||||
|
||||
// ===== Nutzen für die Beratung =====
|
||||
h1('Warum das für Ihre Beratungsarbeit interessant ist'),
|
||||
bullet('Klientinnen und Klienten verstehen Briefe bereits vor dem Termin — Ihre Beratungszeit fließt in die Lösung, nicht ins Vorlesen.'),
|
||||
bullet('9 Übersetzungssprachen (u. a. Türkisch, Arabisch, Ukrainisch, Farsi) — die deutschen Fachbegriffe bleiben sichtbar und wiedererkennbar.'),
|
||||
bullet('Große Schrift, große Tasten, einfache Sprache (A2/B1), Vorlese-Funktion — gebaut für Menschen, für die normale Apps zu kompliziert sind.'),
|
||||
|
||||
// ===== Abschluss-Box =====
|
||||
new Paragraph({ spacing: { before: 200 } }),
|
||||
new Table({
|
||||
columnWidths: [9360],
|
||||
width: { size: 9360, type: WidthType.DXA },
|
||||
borders: {
|
||||
top: { style: BorderStyle.SINGLE, size: 2, color: GRUEN },
|
||||
bottom: { style: BorderStyle.SINGLE, size: 2, color: GRUEN },
|
||||
left: { style: BorderStyle.SINGLE, size: 18, color: GRUEN },
|
||||
right: { style: BorderStyle.SINGLE, size: 2, color: GRUEN },
|
||||
},
|
||||
rows: [new TableRow({ children: [new TableCell({
|
||||
width: { size: 9360, type: WidthType.DXA },
|
||||
shading: { type: ShadingType.CLEAR, fill: 'EAF6EE', color: 'auto' },
|
||||
margins: { top: 150, bottom: 150, left: 170, right: 170 },
|
||||
children: [
|
||||
new Paragraph({ spacing: { after: 60, line: 276 }, children: [
|
||||
new TextRun({ text: 'Sprechen Sie uns an. ', bold: true, size: 22, color: GRUEN, font: FONT }),
|
||||
new TextRun({ text: 'Wir zeigen die App gern persönlich und beantworten jede Datenschutz-Rückfrage.', size: 21, color: TINTE, font: FONT }),
|
||||
] }),
|
||||
new Paragraph({ spacing: { after: 0 }, children: [
|
||||
new TextRun({ text: '📧 behoerdenbriefhelfer@gmail.com 🌐 behoerdenklar.pages.dev', size: 21, color: TINTE, font: FONT, bold: true }),
|
||||
] }),
|
||||
],
|
||||
})] })],
|
||||
}),
|
||||
],
|
||||
}],
|
||||
});
|
||||
|
||||
Packer.toBuffer(doc).then((buf) => {
|
||||
const out = process.argv[2] || 'BehoerdenKlar-Datenschutz.docx';
|
||||
fs.writeFileSync(out, buf);
|
||||
console.log('geschrieben:', out);
|
||||
});
|
||||
74
rechtliches/datenschutz-factsheet.md
Normal file
74
rechtliches/datenschutz-factsheet.md
Normal file
@@ -0,0 +1,74 @@
|
||||
# BehördenKlar — Datenschutz auf einen Blick
|
||||
|
||||
**Für Beratungsstellen, Sozialverbände und Kooperationspartner**
|
||||
Stand: 15.07.2026 · Kontakt: behoerdenbriefhelfer@gmail.com · behoerdenklar.pages.dev
|
||||
|
||||
> BehördenKlar hilft Menschen, Behördenbriefe zu verstehen: Brief fotografieren,
|
||||
> Erklärung in einfacher Sprache erhalten — mit Fristen, Checkliste und
|
||||
> Antwort-Hilfe, auf Wunsch in 9 weiteren Sprachen.
|
||||
>
|
||||
> Weil Behördenbriefe hochsensible Daten enthalten, wurde die App von Grund
|
||||
> auf datensparsam gebaut. Dieses Dokument erklärt transparent, was mit den
|
||||
> Daten passiert — einschließlich der Punkte, die noch offen sind.
|
||||
|
||||
---
|
||||
|
||||
## Das Wichtigste in einem Satz
|
||||
|
||||
**Es gibt kein Nutzerkonto und keinen zentralen Datenspeicher — die Briefe
|
||||
Ihrer Klientinnen und Klienten liegen verschlüsselt auf deren eigenem Handy,
|
||||
nicht bei uns.**
|
||||
|
||||
## Der Datenfluss beim Scannen (vollständig)
|
||||
|
||||
1. **Foto entsteht auf dem Gerät** und wird vor dem Versand verkleinert.
|
||||
2. **Übertragung TLS-verschlüsselt** an unseren Vermittlungsserver
|
||||
(Cloudflare Workers). Dieser leitet nur weiter — **er speichert keine
|
||||
Briefinhalte** (gespeichert wird ausschließlich ein anonymer Tageszähler
|
||||
pro Gerät für 48 h, als Missbrauchsschutz).
|
||||
3. **KI-Analyse bei Anthropic PBC (USA)**, Betreiber des Modells „Claude":
|
||||
Verarbeitung nur zur Analyse, **Löschung nach spätestens 30 Tagen, keine
|
||||
Nutzung zum KI-Training.**
|
||||
4. **Ergebnis zurück aufs Gerät** — dort endet der Weg. Keine Kopie bei uns.
|
||||
|
||||
## Schutzmaßnahmen in der App (implementiert und überprüfbar)
|
||||
|
||||
| Maßnahme | Detail |
|
||||
|---|---|
|
||||
| **Lokale Verschlüsselung** | Das gesamte Brief-Archiv ist mit AES-256-GCM verschlüsselt; der Schlüssel liegt im Sicherheitsspeicher des Geräts (iOS Keychain / Android Keystore) und verlässt es nie |
|
||||
| **App-Sperre** (optional) | Face ID / Fingerabdruck / Geräte-Code beim Öffnen der App |
|
||||
| **Sichtschutz** | Im App-Umschalter wird der Inhalt verdeckt — keine Brief-Vorschau |
|
||||
| **Auto-Löschen** (optional) | Briefe werden nach 30 oder 90 Tagen automatisch entfernt |
|
||||
| **„Alles löschen"** | Ein Tipp entfernt sämtliche Daten inklusive Verschlüsselungs-Schlüssel |
|
||||
| **Kein Konto** | Keine Registrierung, keine E-Mail, kein Passwort nötig |
|
||||
| **Kein Tracking** | Keine Analyse-/Werbe-SDKs, keine Werbung, keine Datenweitergabe an Dritte |
|
||||
| **Einwilligung zuerst** | Vor dem ersten Scan wird verständlich erklärt und aktiv zugestimmt (Art. 6 Abs. 1 lit. a, Art. 9 Abs. 2 lit. a DSGVO) |
|
||||
| **Server-Härtung** | API-Zugang nur serverseitig, Anfrage-Limits pro Gerät und IP-Adresse |
|
||||
|
||||
## Woran wir transparent erinnern (die ehrlichen Punkte)
|
||||
|
||||
- **Verarbeitung in den USA:** Die KI-Analyse läuft bei Anthropic (USA), auf
|
||||
Grundlage des EU-U.S. Data Privacy Framework bzw. EU-Standardvertragsklauseln.
|
||||
Eine Verarbeitung in der EU ist beim Anbieter derzeit nicht verfügbar; wir
|
||||
beobachten das und prüfen als Ausbaupfad den Betrieb über EU-Rechenzentren
|
||||
(AWS Bedrock, Region Frankfurt).
|
||||
- **30-Tage-Fenster:** Anthropic hält Anfragen bis zu 30 Tage zur
|
||||
Missbrauchserkennung vor — danach automatische Löschung. Wir sagen das offen,
|
||||
statt „Ihre Daten verlassen nie das Handy" zu behaupten.
|
||||
- **Keine Rechtsberatung:** Die App erklärt Briefe, ersetzt aber keine
|
||||
Beratung — sie ist als *Ergänzung* Ihrer Arbeit gedacht, nicht als Ersatz.
|
||||
- **Dieses Dokument ist eine Selbstauskunft**, kein Zertifikat. Die
|
||||
vollständige Datenschutzerklärung ist öffentlich:
|
||||
https://behoerdenklar.pages.dev/datenschutz
|
||||
|
||||
## Warum das für Ihre Beratungsarbeit interessant ist
|
||||
|
||||
- Klientinnen und Klienten verstehen Briefe **vor** dem Termin — Ihre
|
||||
Beratungszeit geht in die Lösung, nicht ins Vorlesen.
|
||||
- 9 Übersetzungssprachen (u. a. Türkisch, Arabisch, Ukrainisch, Farsi) —
|
||||
die deutschen Fachbegriffe bleiben sichtbar und wiedererkennbar.
|
||||
- Große Schrift, große Tasten, einfache Sprache (A2/B1), Vorlese-Funktion —
|
||||
gebaut für Menschen, für die normale Apps zu kompliziert sind.
|
||||
|
||||
**Sprechen Sie uns an — wir zeigen die App gern persönlich und beantworten
|
||||
jede Datenschutz-Rückfrage:** behoerdenbriefhelfer@gmail.com
|
||||
127
rechtliches/datenschutzerklaerung.md
Normal file
127
rechtliches/datenschutzerklaerung.md
Normal file
@@ -0,0 +1,127 @@
|
||||
# Datenschutzerklärung — BehördenKlar
|
||||
|
||||
> **⚠️ ENTWURF — vor Veröffentlichung prüfen (lassen).**
|
||||
> Dieser Text ist eine sorgfältige Vorlage, aber keine Rechtsberatung.
|
||||
> Vor dem App-Store-Release von einer Anwältin/einem Anwalt für Datenschutzrecht
|
||||
> oder einem Dienst wie eRecht24/activeMind prüfen lassen. Alle Stellen in
|
||||
> [ECKIGEN KLAMMERN] müssen ausgefüllt werden.
|
||||
|
||||
**Stand: 14.07.2026**
|
||||
|
||||
## 1. Verantwortlicher
|
||||
|
||||
Justin Klein
|
||||
Am Schwimmbad 10
|
||||
67722 Winnweiler
|
||||
E-Mail: behoerdenbriefhelfer@gmail.com
|
||||
|
||||
## 2. Was macht die App?
|
||||
|
||||
BehördenKlar hilft Ihnen, Behördenbriefe zu verstehen: Sie fotografieren einen
|
||||
Brief, und eine Künstliche Intelligenz (KI) erklärt den Inhalt in einfacher
|
||||
Sprache, findet Fristen und Termine und hilft beim Antworten.
|
||||
|
||||
## 3. Welche Daten werden verarbeitet?
|
||||
|
||||
### a) Brieffotos und PDF-Dateien (nur mit Ihrer Einwilligung)
|
||||
|
||||
Wenn Sie einen Brief scannen, wird das Foto bzw. die PDF-Datei zur Analyse
|
||||
übertragen an:
|
||||
|
||||
1. **Unseren Server** (Cloudflare Workers, Anbieter: Cloudflare, Inc., USA) —
|
||||
er leitet die Anfrage nur weiter und **speichert den Briefinhalt nicht**.
|
||||
2. **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.
|
||||
|
||||
**Wichtig:** Behördenbriefe können sehr persönliche und besondere Kategorien
|
||||
von Daten enthalten (z. B. Angaben zu Sozialleistungen, Gesundheit oder
|
||||
finanzieller Situation). Die Analyse erfolgt deshalb nur nach Ihrer
|
||||
**ausdrücklichen Einwilligung**, die Sie vor dem ersten Scan erteilen
|
||||
(Art. 6 Abs. 1 lit. a, Art. 9 Abs. 2 lit. a DSGVO). Sie können die
|
||||
Einwilligung jederzeit widerrufen, indem Sie in den Einstellungen
|
||||
„Alle Daten löschen" wählen.
|
||||
|
||||
### b) Analyse-Ergebnisse, Übersetzungen, Antwort-Entwürfe
|
||||
|
||||
Alle Ergebnisse werden **ausschließlich lokal auf Ihrem Gerät** gespeichert —
|
||||
zusätzlich verschlüsselt (AES-256-GCM; Schlüssel im Sicherheitsspeicher des
|
||||
Geräts). Wir betreiben keine Nutzerkonten und keine Cloud-Speicherung Ihrer
|
||||
Briefe. Optional: App-Sperre (Face ID / Geräte-Code) und automatisches
|
||||
Löschen nach 30/90 Tagen in den Einstellungen.
|
||||
|
||||
### c) Anonyme Geräte-Kennung
|
||||
|
||||
Die App erzeugt eine zufällige, anonyme Kennung (z. B. „g_a1b2…"). Sie enthält
|
||||
keine Angaben über Sie oder Ihr Gerät und dient ausschließlich dazu, ein
|
||||
tägliches Nutzungslimit pro Installation durchzusetzen (Schutz vor Missbrauch,
|
||||
Art. 6 Abs. 1 lit. f DSGVO). Sie wird zusammen mit einem Tageszähler für
|
||||
maximal 48 Stunden auf unserem Server gespeichert.
|
||||
|
||||
### d) Kalender, Erinnerungen, Vorlesen
|
||||
|
||||
Kalendereinträge und Frist-Erinnerungen werden nur auf Ihrem Gerät angelegt
|
||||
(mit Ihrer Erlaubnis über die iOS-/Android-Berechtigungen). Die
|
||||
Vorlese-Funktion nutzt die Sprachausgabe Ihres Geräts. Dabei werden keine
|
||||
Daten an uns übertragen.
|
||||
|
||||
## 4. Übermittlung in die USA
|
||||
|
||||
Anthropic PBC und Cloudflare, Inc. haben ihren Sitz in den USA. Die
|
||||
Übermittlung stützt sich auf [ZU PRÜFEN: EU-U.S. Data Privacy Framework —
|
||||
Zertifizierung von Anthropic und Cloudflare verifizieren — und/oder
|
||||
EU-Standardvertragsklauseln].
|
||||
|
||||
## 5. Speicherdauer
|
||||
|
||||
- Brieffotos bei Anthropic: maximal 30 Tage, danach automatische Löschung
|
||||
- Anonyme Geräte-Kennung + Tageszähler auf unserem Server: maximal 48 Stunden
|
||||
- Daten auf Ihrem Gerät: bis Sie sie löschen („Alle Daten löschen" in den
|
||||
Einstellungen oder Deinstallation der App)
|
||||
|
||||
## 6. Ihre Rechte
|
||||
|
||||
Sie haben nach der DSGVO das Recht auf Auskunft (Art. 15), Berichtigung
|
||||
(Art. 16), Löschung (Art. 17), Einschränkung der Verarbeitung (Art. 18),
|
||||
Datenübertragbarkeit (Art. 20) und Widerspruch (Art. 21). Außerdem können Sie
|
||||
sich bei einer Datenschutz-Aufsichtsbehörde beschweren (Art. 77 DSGVO),
|
||||
z. B. beim Landesbeauftragten für den Datenschutz und die Informationsfreiheit
|
||||
Rheinland-Pfalz.
|
||||
|
||||
Da wir keine Nutzerkonten führen, liegen die meisten Daten nur auf Ihrem
|
||||
Gerät — dort können Sie sie selbst einsehen und löschen. Für Anfragen zu den
|
||||
serverseitig verarbeiteten Daten wenden Sie sich an:
|
||||
behoerdenbriefhelfer@gmail.com
|
||||
|
||||
## 7. Warteliste / Launch-Benachrichtigung (Webseite)
|
||||
|
||||
Auf unserer Webseite können Sie sich für die Warteliste anmelden. Dabei
|
||||
verarbeiten wir Ihre E-Mail-Adresse auf Grundlage Ihrer Einwilligung
|
||||
(Art. 6 Abs. 1 lit. a DSGVO), ausschließlich für die Launch-Benachrichtigung
|
||||
und Informationen zur App. Abmeldung jederzeit möglich; die Adresse wird dann
|
||||
gelöscht.
|
||||
|
||||
> [NACH BREVO/MAILERLITE-EINRICHTUNG ERGÄNZEN: Anbieter des
|
||||
> Newsletter-Dienstes als Auftragsverarbeiter benennen, z. B. „Brevo GmbH,
|
||||
> Köpenicker Str. 126, 10179 Berlin" + Double-Opt-in-Verfahren erwähnen.]
|
||||
|
||||
## 8. Reichweitenmessung (Webseite)
|
||||
|
||||
Auf der Webseite nutzen wir Cloudflare Web Analytics (Cloudflare, Inc.,
|
||||
USA) — Besucherstatistik **ohne Cookies** und ohne Verfolgung einzelner
|
||||
Personen; erfasst werden nur zusammengefasste Werte (Seitenaufrufe,
|
||||
Herkunftsland, Browser). Rechtsgrundlage: berechtigtes Interesse
|
||||
(Art. 6 Abs. 1 lit. f DSGVO).
|
||||
|
||||
## 9. Keine Rechtsberatung
|
||||
|
||||
BehördenKlar erklärt Briefe verständlich, ersetzt aber keine Rechtsberatung.
|
||||
Bei rechtlich wichtigen Entscheidungen (z. B. Widerspruch) wenden Sie sich an
|
||||
eine Beratungsstelle oder eine Anwältin/einen Anwalt.
|
||||
|
||||
## 10. Änderungen
|
||||
|
||||
Wir passen diese Datenschutzerklärung an, wenn sich die App oder die
|
||||
Rechtslage ändert. Die aktuelle Fassung finden Sie immer in der App unter
|
||||
Einstellungen → Datenschutz.
|
||||
@@ -1,22 +1,23 @@
|
||||
/**
|
||||
* Dringlichkeits-Ampel als gut sichtbares Banner.
|
||||
* Farbe + Symbol + Text — nicht nur Farbe (Barrierefreiheit: Farbenblindheit).
|
||||
* Farbe + Icon + Text — nicht nur Farbe (Barrierefreiheit: Farbenblindheit).
|
||||
*/
|
||||
import React from 'react';
|
||||
import { StyleSheet, Text, View } from 'react-native';
|
||||
import { AmpelStatus } from '../utils/ampel';
|
||||
import { abstand, schrift } from '../theme';
|
||||
import { Ikone, IkonenName } from './Ikone';
|
||||
|
||||
const SYMBOL: Record<string, string> = { rot: '⚠️', gelb: '⏰', gruen: 'ℹ️' };
|
||||
const IKONE: Record<string, IkonenName> = { rot: 'warnung', gelb: 'wecker', gruen: 'info' };
|
||||
|
||||
export function Ampel({ status }: { status: AmpelStatus }) {
|
||||
return (
|
||||
<View
|
||||
style={[styles.banner, { backgroundColor: status.hintergrund, borderColor: status.farbe }]}
|
||||
style={[styles.banner, { backgroundColor: status.hintergrund, borderLeftColor: status.farbe }]}
|
||||
accessibilityRole="alert"
|
||||
accessibilityLabel={`Dringlichkeit: ${status.text}`}
|
||||
>
|
||||
<Text style={styles.symbol}>{SYMBOL[status.stufe]}</Text>
|
||||
<Ikone name={IKONE[status.stufe]} groesse={26} farbe={status.farbe} />
|
||||
<Text style={[styles.text, { color: status.farbe }]}>{status.text}</Text>
|
||||
</View>
|
||||
);
|
||||
@@ -26,11 +27,10 @@ const styles = StyleSheet.create({
|
||||
banner: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
borderWidth: 2,
|
||||
borderLeftWidth: 6,
|
||||
borderRadius: 12,
|
||||
padding: abstand.m,
|
||||
gap: abstand.s,
|
||||
},
|
||||
symbol: { fontSize: 28 },
|
||||
text: { flex: 1, fontSize: schrift.gross, fontWeight: '700' },
|
||||
});
|
||||
|
||||
113
src/components/AppSchutz.tsx
Normal file
113
src/components/AppSchutz.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* Schützt den App-Inhalt auf zwei Ebenen:
|
||||
* 1. App-Sperre (optional, Einstellungen): Beim Öffnen und nach jedem
|
||||
* Wechsel in den Hintergrund muss das Gerät entsperrt werden
|
||||
* (Face ID / Touch ID / Geräte-Code).
|
||||
* 2. Sichtschutz (immer): Im App-Umschalter wird der Inhalt verdeckt,
|
||||
* damit Briefe dort nicht als Vorschau lesbar sind.
|
||||
*
|
||||
* Der Inhalt bleibt gemountet (Navigation geht nicht verloren) — die
|
||||
* Schutzschichten legen sich als Overlay darüber.
|
||||
*/
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { AppState, AppStateStatus, StyleSheet, Text, View } from 'react-native';
|
||||
import * as LocalAuthentication from 'expo-local-authentication';
|
||||
import { holeAppSperre } from '../services/storage';
|
||||
import { GrossButton } from './GrossButton';
|
||||
import { farben, schrift, abstand } from '../theme';
|
||||
|
||||
export function AppSchutz({ children }: { children: React.ReactNode }) {
|
||||
const [entsperrt, setEntsperrt] = useState(false);
|
||||
const [bereit, setBereit] = useState(false);
|
||||
const [verdeckt, setVerdeckt] = useState(false);
|
||||
const authLaeuft = useRef(false);
|
||||
|
||||
const entsperren = useCallback(async () => {
|
||||
if (authLaeuft.current) return;
|
||||
authLaeuft.current = true;
|
||||
try {
|
||||
const ergebnis = await LocalAuthentication.authenticateAsync({
|
||||
promptMessage: 'BehördenKlar entsperren',
|
||||
cancelLabel: 'Abbrechen',
|
||||
});
|
||||
if (ergebnis.success) setEntsperrt(true);
|
||||
} finally {
|
||||
authLaeuft.current = false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Beim Start: Sperre-Einstellung prüfen und ggf. direkt entsperren lassen
|
||||
useEffect(() => {
|
||||
holeAppSperre().then((aktiv) => {
|
||||
if (!aktiv) {
|
||||
setEntsperrt(true);
|
||||
} else {
|
||||
entsperren();
|
||||
}
|
||||
setBereit(true);
|
||||
});
|
||||
}, [entsperren]);
|
||||
|
||||
// Hintergrund-Wechsel: Sichtschutz an/aus, Sperre ggf. neu scharf stellen.
|
||||
// Die Einstellung wird hier frisch gelesen, damit ein Umschalten in den
|
||||
// Einstellungen sofort wirkt.
|
||||
useEffect(() => {
|
||||
const abo = AppState.addEventListener('change', (status: AppStateStatus) => {
|
||||
if (status === 'active') {
|
||||
setVerdeckt(false);
|
||||
} else {
|
||||
setVerdeckt(true);
|
||||
if (status === 'background') {
|
||||
holeAppSperre().then((aktiv) => {
|
||||
if (aktiv) setEntsperrt(false);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
return () => abo.remove();
|
||||
}, []);
|
||||
|
||||
if (!bereit) return <View style={styles.deckschicht} />;
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1 }}>
|
||||
{children}
|
||||
{!entsperrt && (
|
||||
<View style={[StyleSheet.absoluteFill, styles.deckschicht, styles.sperrschirm]}>
|
||||
<Text style={styles.symbol}>🔒</Text>
|
||||
<Text style={styles.titel}>BehördenKlar ist gesperrt</Text>
|
||||
<Text style={styles.text}>Ihre Briefe sind geschützt.</Text>
|
||||
<GrossButton titel="Entsperren" onPress={entsperren} />
|
||||
</View>
|
||||
)}
|
||||
{entsperrt && verdeckt && (
|
||||
<View style={[StyleSheet.absoluteFill, styles.deckschicht, styles.sichtschutz]}>
|
||||
<Text style={styles.symbol}>📬</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
deckschicht: { flex: 1, backgroundColor: farben.hintergrund },
|
||||
sperrschirm: {
|
||||
justifyContent: 'center',
|
||||
padding: abstand.xl,
|
||||
gap: abstand.m,
|
||||
},
|
||||
sichtschutz: { justifyContent: 'center', alignItems: 'center' },
|
||||
symbol: { fontSize: 56, textAlign: 'center' },
|
||||
titel: {
|
||||
fontSize: schrift.titel,
|
||||
fontWeight: '700',
|
||||
color: farben.text,
|
||||
textAlign: 'center',
|
||||
},
|
||||
text: {
|
||||
fontSize: schrift.basis,
|
||||
color: farben.textSekundaer,
|
||||
textAlign: 'center',
|
||||
marginBottom: abstand.m,
|
||||
},
|
||||
});
|
||||
@@ -1,10 +1,15 @@
|
||||
/**
|
||||
* Großer, barrierefreier Button: min. 56px hoch, große Schrift,
|
||||
* hoher Kontrast. Varianten: primär (gefüllt) und sekundär (Umriss).
|
||||
* Großer, barrierefreier Button nach iOS-Vorbild:
|
||||
* - primär: gefüllt mit der Marken-Tintfarbe (wie "prominente" iOS-Buttons)
|
||||
* - sekundär: hellgraue Fläche mit Tint-Text (wie Apples "graue Taste" —
|
||||
* bewusst keine Umrandung, das wirkt nativer)
|
||||
* - gefahr: rot gefüllt
|
||||
* Min. 56px hoch, große Schrift, System-Icons statt Emojis.
|
||||
*/
|
||||
import React from 'react';
|
||||
import { ActivityIndicator, Pressable, StyleSheet, Text } from 'react-native';
|
||||
import { ActivityIndicator, Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
import { farben, schrift, abstand, TOUCH_TARGET } from '../theme';
|
||||
import { Ikone, IkonenName } from './Ikone';
|
||||
|
||||
interface Props {
|
||||
titel: string;
|
||||
@@ -12,7 +17,7 @@ interface Props {
|
||||
variante?: 'primaer' | 'sekundaer' | 'gefahr';
|
||||
deaktiviert?: boolean;
|
||||
laedt?: boolean;
|
||||
symbol?: string;
|
||||
ikone?: IkonenName;
|
||||
}
|
||||
|
||||
export function GrossButton({
|
||||
@@ -21,10 +26,11 @@ export function GrossButton({
|
||||
variante = 'primaer',
|
||||
deaktiviert = false,
|
||||
laedt = false,
|
||||
symbol,
|
||||
ikone,
|
||||
}: Props) {
|
||||
const istPrimaer = variante === 'primaer';
|
||||
const istGefahr = variante === 'gefahr';
|
||||
const inhaltsFarbe = istPrimaer || istGefahr ? farben.primaerText : farben.primaer;
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
@@ -41,18 +47,12 @@ export function GrossButton({
|
||||
]}
|
||||
>
|
||||
{laedt ? (
|
||||
<ActivityIndicator color={istPrimaer || istGefahr ? farben.primaerText : farben.primaer} />
|
||||
<ActivityIndicator color={inhaltsFarbe} />
|
||||
) : (
|
||||
<Text
|
||||
style={[
|
||||
styles.text,
|
||||
(istPrimaer || istGefahr) && { color: farben.primaerText },
|
||||
variante === 'sekundaer' && { color: farben.primaer },
|
||||
]}
|
||||
>
|
||||
{symbol ? `${symbol} ` : ''}
|
||||
{titel}
|
||||
</Text>
|
||||
<View style={styles.zeile}>
|
||||
{ikone ? <Ikone name={ikone} farbe={inhaltsFarbe} /> : null}
|
||||
<Text style={[styles.text, { color: inhaltsFarbe }]}>{titel}</Text>
|
||||
</View>
|
||||
)}
|
||||
</Pressable>
|
||||
);
|
||||
@@ -67,10 +67,11 @@ const styles = StyleSheet.create({
|
||||
paddingHorizontal: abstand.l,
|
||||
paddingVertical: abstand.s,
|
||||
},
|
||||
zeile: { flexDirection: 'row', alignItems: 'center', gap: abstand.xs + 2 },
|
||||
primaer: { backgroundColor: farben.primaer },
|
||||
sekundaer: { backgroundColor: farben.hintergrund, borderWidth: 2, borderColor: farben.primaer },
|
||||
sekundaer: { backgroundColor: farben.flaecheSekundaer },
|
||||
gefahr: { backgroundColor: farben.fehler },
|
||||
deaktiviert: { opacity: 0.5 },
|
||||
gedrueckt: { opacity: 0.8 },
|
||||
text: { fontSize: schrift.gross, fontWeight: '700', textAlign: 'center' },
|
||||
deaktiviert: { opacity: 0.4 },
|
||||
gedrueckt: { opacity: 0.65 },
|
||||
text: { fontSize: schrift.gross, fontWeight: '600', textAlign: 'center' },
|
||||
});
|
||||
|
||||
72
src/components/Ikone.tsx
Normal file
72
src/components/Ikone.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Zentrale Icon-Komponente — echte System-Icons statt Emojis:
|
||||
* SF Symbols auf iOS (Apple-Look), Material Symbols auf Android.
|
||||
* Alle in der App verwendeten Icons sind hier benannt, damit die
|
||||
* Plattform-Zuordnung an einer Stelle liegt.
|
||||
*/
|
||||
import React from 'react';
|
||||
import { SymbolView } from 'expo-symbols';
|
||||
|
||||
export type IkonenName =
|
||||
| 'kamera'
|
||||
| 'galerie'
|
||||
| 'dokument'
|
||||
| 'buch'
|
||||
| 'zahnrad'
|
||||
| 'brief'
|
||||
| 'pfeilRechts'
|
||||
| 'vorlesen'
|
||||
| 'stopp'
|
||||
| 'kalender'
|
||||
| 'stift'
|
||||
| 'teilen'
|
||||
| 'muell'
|
||||
| 'globus'
|
||||
| 'checkAn'
|
||||
| 'checkAus'
|
||||
| 'warnung'
|
||||
| 'wecker'
|
||||
| 'info'
|
||||
| 'funken';
|
||||
|
||||
// `as const` hält die Namen als Literal-Typen — so prüft TypeScript gegen
|
||||
// die offizielle SF-Symbol-/Material-Namensliste von expo-symbols
|
||||
const NAMEN = {
|
||||
kamera: { ios: 'camera.fill', android: 'photo_camera' },
|
||||
galerie: { ios: 'photo.on.rectangle', android: 'image' },
|
||||
dokument: { ios: 'doc.fill', android: 'description' },
|
||||
buch: { ios: 'book.fill', android: 'menu_book' },
|
||||
zahnrad: { ios: 'gearshape.fill', android: 'settings' },
|
||||
brief: { ios: 'envelope.open.fill', android: 'mail' },
|
||||
pfeilRechts: { ios: 'chevron.right', android: 'chevron_right' },
|
||||
vorlesen: { ios: 'speaker.wave.2.fill', android: 'volume_up' },
|
||||
stopp: { ios: 'stop.fill', android: 'stop' },
|
||||
kalender: { ios: 'calendar.badge.plus', android: 'event' },
|
||||
stift: { ios: 'square.and.pencil', android: 'edit' },
|
||||
teilen: { ios: 'square.and.arrow.up', android: 'share' },
|
||||
muell: { ios: 'trash.fill', android: 'delete' },
|
||||
globus: { ios: 'globe', android: 'language' },
|
||||
checkAn: { ios: 'checkmark.square.fill', android: 'check_box' },
|
||||
checkAus: { ios: 'square', android: 'check_box_outline_blank' },
|
||||
warnung: { ios: 'exclamationmark.triangle.fill', android: 'warning' },
|
||||
wecker: { ios: 'alarm.fill', android: 'alarm' },
|
||||
info: { ios: 'info.circle.fill', android: 'info' },
|
||||
funken: { ios: 'wand.and.stars', android: 'auto_awesome' },
|
||||
} as const;
|
||||
|
||||
interface Props {
|
||||
name: IkonenName;
|
||||
groesse?: number;
|
||||
farbe: string;
|
||||
}
|
||||
|
||||
export function Ikone({ name, groesse = 22, farbe }: Props) {
|
||||
const eintrag = NAMEN[name];
|
||||
return (
|
||||
<SymbolView
|
||||
name={{ ios: eintrag.ios, android: eintrag.android }}
|
||||
size={groesse}
|
||||
tintColor={farbe}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -23,6 +23,7 @@ import { formatiereDatum } from '../services/erinnerungen';
|
||||
import { berechneAmpel } from '../utils/ampel';
|
||||
import { Ampel } from '../components/Ampel';
|
||||
import { GrossButton } from '../components/GrossButton';
|
||||
import { Ikone } from '../components/Ikone';
|
||||
import { farben, schrift, abstand, TOUCH_TARGET } from '../theme';
|
||||
|
||||
type Props = NativeStackScreenProps<RootStackParamList, 'Analyse'>;
|
||||
@@ -133,9 +134,13 @@ export function AnalyseScreen({ navigation, route }: Props) {
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`Sprache ändern. Aktuell: ${sprache.name}`}
|
||||
>
|
||||
<Text style={styles.sprachButtonText}>
|
||||
🌐 {sprache.eigenname} {uebersetzt ? ' (übersetzt…)' : ''}
|
||||
</Text>
|
||||
<View style={styles.sprachZeileInhalt}>
|
||||
<Ikone name="globus" groesse={20} farbe={farben.primaer} />
|
||||
<Text style={styles.sprachButtonText}>
|
||||
{sprache.eigenname} {uebersetzt ? ' (übersetzt…)' : ''}
|
||||
</Text>
|
||||
<Ikone name="pfeilRechts" groesse={16} farbe={farben.textTertiaer} />
|
||||
</View>
|
||||
</Pressable>
|
||||
|
||||
{/* Kernaussage: Was will das Amt von mir? */}
|
||||
@@ -145,7 +150,7 @@ export function AnalyseScreen({ navigation, route }: Props) {
|
||||
<View style={{ marginTop: abstand.s }}>
|
||||
<GrossButton
|
||||
titel={liest ? 'Vorlesen stoppen' : 'Vorlesen'}
|
||||
symbol={liest ? '⏹️' : '🔊'}
|
||||
ikone={liest ? 'stopp' : 'vorlesen'}
|
||||
variante="sekundaer"
|
||||
onPress={vorlesen}
|
||||
/>
|
||||
@@ -154,10 +159,15 @@ export function AnalyseScreen({ navigation, route }: Props) {
|
||||
|
||||
{/* Frist / Termin */}
|
||||
{(analyse.frist || analyse.termin) && (
|
||||
<View style={[styles.karte, { borderColor: ampel.farbe, borderWidth: 2 }]}>
|
||||
<View
|
||||
style={[
|
||||
styles.karte,
|
||||
{ backgroundColor: ampel.hintergrund, borderLeftWidth: 6, borderLeftColor: ampel.farbe },
|
||||
]}
|
||||
>
|
||||
{analyse.frist && (
|
||||
<Text style={styles.fristText}>
|
||||
📅 Frist: <Text style={styles.fett}>{formatiereDatum(analyse.frist.datum)}</Text>
|
||||
Frist: <Text style={styles.fett}>{formatiereDatum(analyse.frist.datum)}</Text>
|
||||
{'\n'}
|
||||
{analyse.frist.aktion}
|
||||
</Text>
|
||||
@@ -165,13 +175,13 @@ export function AnalyseScreen({ navigation, route }: Props) {
|
||||
{analyse.termin && (
|
||||
<>
|
||||
<Text style={styles.fristText}>
|
||||
🕐 Termin: <Text style={styles.fett}>{formatiereDatum(analyse.termin.datum)}</Text>
|
||||
Termin: <Text style={styles.fett}>{formatiereDatum(analyse.termin.datum)}</Text>
|
||||
{analyse.termin.uhrzeit ? ` um ${analyse.termin.uhrzeit} Uhr` : ''}
|
||||
{analyse.termin.ort ? `\n📍 ${analyse.termin.ort}` : ''}
|
||||
{analyse.termin.ort ? `\nOrt: ${analyse.termin.ort}` : ''}
|
||||
</Text>
|
||||
<GrossButton
|
||||
titel="Termin zum Kalender hinzufügen"
|
||||
symbol="🗓️"
|
||||
ikone="kalender"
|
||||
variante="sekundaer"
|
||||
onPress={kalenderExport}
|
||||
/>
|
||||
@@ -198,7 +208,11 @@ export function AnalyseScreen({ navigation, route }: Props) {
|
||||
accessibilityRole="checkbox"
|
||||
accessibilityState={{ checked: !!erledigt[i] }}
|
||||
>
|
||||
<Text style={styles.checkBox}>{erledigt[i] ? '☑️' : '⬜'}</Text>
|
||||
<Ikone
|
||||
name={erledigt[i] ? 'checkAn' : 'checkAus'}
|
||||
groesse={26}
|
||||
farbe={erledigt[i] ? farben.ampelGruen : farben.textTertiaer}
|
||||
/>
|
||||
<Text style={[styles.text, { flex: 1 }, erledigt[i] && styles.durchgestrichen]}>
|
||||
{punkt}
|
||||
</Text>
|
||||
@@ -224,13 +238,13 @@ export function AnalyseScreen({ navigation, route }: Props) {
|
||||
{analyse.antwort_noetig && (
|
||||
<GrossButton
|
||||
titel="Antwort erstellen"
|
||||
symbol="✍️"
|
||||
ikone="stift"
|
||||
onPress={() => navigation.navigate('Antwort', { briefId: brief.id })}
|
||||
/>
|
||||
)}
|
||||
|
||||
<View style={{ height: abstand.m }} />
|
||||
<GrossButton titel="Brief löschen" symbol="🗑️" variante="gefahr" onPress={loeschen} />
|
||||
<GrossButton titel="Brief löschen" ikone="muell" variante="gefahr" onPress={loeschen} />
|
||||
<View style={{ height: abstand.xl }} />
|
||||
|
||||
{/* Sprach-Auswahl-Dialog */}
|
||||
@@ -270,16 +284,17 @@ const styles = StyleSheet.create({
|
||||
zentriert: { flex: 1, justifyContent: 'center', alignItems: 'center' },
|
||||
karte: {
|
||||
backgroundColor: farben.flaeche,
|
||||
borderRadius: 14,
|
||||
borderRadius: 12,
|
||||
padding: abstand.m,
|
||||
marginTop: abstand.m,
|
||||
gap: abstand.s,
|
||||
},
|
||||
// DIE Antwort der App — dezent im Marken-Tint hervorgehoben
|
||||
karteWichtig: {
|
||||
backgroundColor: farben.flaeche,
|
||||
borderRadius: 14,
|
||||
borderWidth: 2,
|
||||
borderColor: farben.primaer,
|
||||
backgroundColor: farben.hervorhebung,
|
||||
borderRadius: 12,
|
||||
borderWidth: 1,
|
||||
borderColor: farben.hervorhebungRand,
|
||||
padding: abstand.m,
|
||||
marginTop: abstand.m,
|
||||
},
|
||||
@@ -289,7 +304,7 @@ const styles = StyleSheet.create({
|
||||
color: farben.primaer,
|
||||
marginBottom: abstand.xs,
|
||||
},
|
||||
kernaussage: { fontSize: schrift.gross, color: farben.text, lineHeight: 30 },
|
||||
kernaussage: { fontSize: schrift.gross, color: farben.text, lineHeight: 30, fontWeight: '500' },
|
||||
text: { fontSize: schrift.basis, color: farben.text, lineHeight: 27 },
|
||||
fett: { fontWeight: '700' },
|
||||
fristText: { fontSize: schrift.basis, color: farben.text, lineHeight: 28 },
|
||||
@@ -300,20 +315,19 @@ const styles = StyleSheet.create({
|
||||
minHeight: 44,
|
||||
paddingVertical: 4,
|
||||
},
|
||||
checkBox: { fontSize: 24 },
|
||||
durchgestrichen: { textDecorationLine: 'line-through', color: farben.textSekundaer },
|
||||
begriffBlock: { marginBottom: abstand.s },
|
||||
begriff: { fontSize: schrift.basis, fontWeight: '700', color: farben.text },
|
||||
sprachButton: {
|
||||
marginTop: abstand.m,
|
||||
minHeight: TOUCH_TARGET,
|
||||
borderRadius: 14,
|
||||
borderWidth: 2,
|
||||
borderColor: farben.rand,
|
||||
borderRadius: 12,
|
||||
backgroundColor: farben.flaeche,
|
||||
justifyContent: 'center',
|
||||
paddingHorizontal: abstand.m,
|
||||
},
|
||||
sprachButtonText: { fontSize: schrift.basis, color: farben.text, fontWeight: '600' },
|
||||
sprachZeileInhalt: { flexDirection: 'row', alignItems: 'center', gap: abstand.s },
|
||||
sprachButtonText: { flex: 1, fontSize: schrift.basis, color: farben.text, fontWeight: '600' },
|
||||
modalHintergrund: {
|
||||
flex: 1,
|
||||
backgroundColor: 'rgba(0,0,0,0.5)',
|
||||
@@ -321,7 +335,7 @@ const styles = StyleSheet.create({
|
||||
padding: abstand.l,
|
||||
},
|
||||
modalKarte: {
|
||||
backgroundColor: farben.hintergrund,
|
||||
backgroundColor: farben.flaeche,
|
||||
borderRadius: 14,
|
||||
padding: abstand.m,
|
||||
maxHeight: '75%',
|
||||
|
||||
@@ -146,7 +146,7 @@ export function AntwortScreen({ route }: Props) {
|
||||
|
||||
<GrossButton
|
||||
titel={text ? 'Neuen Entwurf erstellen' : 'Entwurf erstellen'}
|
||||
symbol="✨"
|
||||
ikone="funken"
|
||||
onPress={erzeugen}
|
||||
deaktiviert={!typ}
|
||||
laedt={laedt}
|
||||
@@ -173,8 +173,8 @@ export function AntwortScreen({ route }: Props) {
|
||||
accessibilityLabel="Brieftext"
|
||||
/>
|
||||
<View style={{ gap: abstand.s }}>
|
||||
<GrossButton titel="Als PDF exportieren" symbol="📄" onPress={alsPdf} />
|
||||
<GrossButton titel="Als Text teilen / kopieren" symbol="📤" variante="sekundaer" onPress={alsText} />
|
||||
<GrossButton titel="Als PDF exportieren" ikone="dokument" onPress={alsPdf} />
|
||||
<GrossButton titel="Als Text teilen / kopieren" ikone="teilen" variante="sekundaer" onPress={alsText} />
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1,13 +1,36 @@
|
||||
/**
|
||||
* Einstellungen: API-Schlüssel (Dev-Modus), Datenschutz-Info,
|
||||
* "Alle Daten löschen".
|
||||
* Einstellungen: Datenschutz-Info und "Alle Daten löschen".
|
||||
* Das API-Schlüssel-Feld erscheint nur im Entwickler-Modus (ohne Proxy) —
|
||||
* im Produktionsmodus läuft der KI-Zugang über den Backend-Proxy.
|
||||
*/
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Alert, ScrollView, StyleSheet, Text, TextInput, View } from 'react-native';
|
||||
import {
|
||||
Alert,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Switch,
|
||||
Text,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import * as Notifications from 'expo-notifications';
|
||||
import * as LocalAuthentication from 'expo-local-authentication';
|
||||
import { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||
import { RootStackParamList } from '../types';
|
||||
import { holeApiKey, speichereApiKey, loescheAlleDaten } from '../services/storage';
|
||||
import {
|
||||
BONUS_ANALYSEN,
|
||||
holeApiKey,
|
||||
holeAppSperre,
|
||||
holeAutoLoeschTage,
|
||||
holeBonusAktiv,
|
||||
loeseBonusCodeEin,
|
||||
loescheAlleDaten,
|
||||
setzeAppSperre,
|
||||
setzeAutoLoeschTage,
|
||||
speichereApiKey,
|
||||
} from '../services/storage';
|
||||
import { NUTZT_PROXY } from '../services/claudeClient';
|
||||
import { useAppStore } from '../store/useAppStore';
|
||||
import { GrossButton } from '../components/GrossButton';
|
||||
import { farben, schrift, abstand } from '../theme';
|
||||
@@ -19,11 +42,64 @@ export function EinstellungenScreen({ navigation }: Props) {
|
||||
const [apiKey, setApiKey] = useState('');
|
||||
const [keyVorhanden, setKeyVorhanden] = useState(false);
|
||||
const [gespeichert, setGespeichert] = useState(false);
|
||||
const [bonusCode, setBonusCode] = useState('');
|
||||
const [bonusAktiv, setBonusAktiv] = useState(false);
|
||||
const [sperreAktiv, setSperreAktiv] = useState(false);
|
||||
const [autoTage, setAutoTage] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
holeApiKey().then((k) => setKeyVorhanden(!!k));
|
||||
holeBonusAktiv().then(setBonusAktiv);
|
||||
holeAppSperre().then(setSperreAktiv);
|
||||
holeAutoLoeschTage().then(setAutoTage);
|
||||
}, []);
|
||||
|
||||
/** App-Sperre umschalten — beide Richtungen erfordern eine Authentifizierung. */
|
||||
const sperreUmschalten = async (an: boolean) => {
|
||||
if (an) {
|
||||
const stufe = await LocalAuthentication.getEnrolledLevelAsync();
|
||||
if (stufe === LocalAuthentication.SecurityLevel.NONE) {
|
||||
Alert.alert(
|
||||
'Keine Geräte-Sperre eingerichtet',
|
||||
'Bitte richten Sie zuerst in den Geräte-Einstellungen einen Code, Face ID oder Fingerabdruck ein.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
const ergebnis = await LocalAuthentication.authenticateAsync({
|
||||
promptMessage: an ? 'App-Sperre aktivieren' : 'App-Sperre deaktivieren',
|
||||
cancelLabel: 'Abbrechen',
|
||||
});
|
||||
if (!ergebnis.success) return;
|
||||
await setzeAppSperre(an);
|
||||
setSperreAktiv(an);
|
||||
};
|
||||
|
||||
/** Aufbewahrungsdauer setzen und sofort anwenden. */
|
||||
const autoTageSetzen = async (tage: number) => {
|
||||
await setzeAutoLoeschTage(tage);
|
||||
setAutoTage(tage);
|
||||
// Sofort anwenden statt erst beim nächsten App-Start
|
||||
await useAppStore.getState().initialisiere();
|
||||
};
|
||||
|
||||
const codeEinloesen = async () => {
|
||||
const erfolg = await loeseBonusCodeEin(bonusCode);
|
||||
if (erfolg) {
|
||||
setBonusAktiv(true);
|
||||
setBonusCode('');
|
||||
Alert.alert(
|
||||
'Code eingelöst! 🎉',
|
||||
`Sie haben jetzt ${BONUS_ANALYSEN} kostenlose Brief-Analysen.`
|
||||
);
|
||||
} else {
|
||||
Alert.alert(
|
||||
'Code ungültig',
|
||||
'Bitte prüfen Sie die Schreibweise. Den Code finden Sie in Ihrer Anmelde-E-Mail.'
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const speichern = async () => {
|
||||
await speichereApiKey(apiKey);
|
||||
setKeyVorhanden(!!apiKey.trim());
|
||||
@@ -35,7 +111,7 @@ export function EinstellungenScreen({ navigation }: Props) {
|
||||
const allesLoeschen = () => {
|
||||
Alert.alert(
|
||||
'Wirklich ALLE Daten löschen?',
|
||||
'Alle gescannten Briefe, Übersetzungen, Entwürfe, der API-Schlüssel und die Einwilligung werden dauerhaft gelöscht. Das kann nicht rückgängig gemacht werden.',
|
||||
'Alle gescannten Briefe, Übersetzungen, Entwürfe und die Einwilligung werden dauerhaft gelöscht. Das kann nicht rückgängig gemacht werden.',
|
||||
[
|
||||
{ text: 'Abbrechen', style: 'cancel' },
|
||||
{
|
||||
@@ -57,43 +133,126 @@ export function EinstellungenScreen({ navigation }: Props) {
|
||||
|
||||
return (
|
||||
<ScrollView style={styles.container} contentContainerStyle={{ padding: abstand.m }}>
|
||||
<Text style={styles.abschnittTitel}>KI-Zugang (API-Schlüssel)</Text>
|
||||
<Text style={styles.text}>
|
||||
Status:{' '}
|
||||
<Text style={{ fontWeight: '700', color: keyVorhanden ? farben.ampelGruen : farben.fehler }}>
|
||||
{keyVorhanden ? 'Schlüssel hinterlegt ✓' : 'Kein Schlüssel hinterlegt'}
|
||||
{!NUTZT_PROXY && (
|
||||
<>
|
||||
<Text style={styles.abschnittTitel}>KI-Zugang (API-Schlüssel)</Text>
|
||||
<Text style={styles.text}>
|
||||
Status:{' '}
|
||||
<Text style={{ fontWeight: '700', color: keyVorhanden ? farben.ampelGruen : farben.fehler }}>
|
||||
{keyVorhanden ? 'Schlüssel hinterlegt ✓' : 'Kein Schlüssel hinterlegt'}
|
||||
</Text>
|
||||
</Text>
|
||||
<Text style={styles.hinweis}>
|
||||
Der Schlüssel wird verschlüsselt auf dem Gerät gespeichert (Secure Store)
|
||||
und nur für die Brief-Analyse verwendet. Einen Schlüssel erhalten Sie
|
||||
unter console.anthropic.com.
|
||||
</Text>
|
||||
<TextInput
|
||||
style={styles.eingabe}
|
||||
placeholder="sk-ant-…"
|
||||
placeholderTextColor={farben.textSekundaer}
|
||||
value={apiKey}
|
||||
onChangeText={setApiKey}
|
||||
secureTextEntry
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
accessibilityLabel="API-Schlüssel eingeben"
|
||||
/>
|
||||
<GrossButton
|
||||
titel={gespeichert ? 'Gespeichert ✓' : 'Schlüssel speichern'}
|
||||
onPress={speichern}
|
||||
deaktiviert={!apiKey.trim()}
|
||||
/>
|
||||
|
||||
<View style={styles.trenner} />
|
||||
</>
|
||||
)}
|
||||
|
||||
<Text style={styles.abschnittTitel}>Aktions-Code</Text>
|
||||
{bonusAktiv ? (
|
||||
<Text style={styles.text}>
|
||||
<Text style={{ fontWeight: '700', color: farben.ampelGruen }}>
|
||||
Code eingelöst ✓
|
||||
</Text>{' '}
|
||||
— Sie haben {BONUS_ANALYSEN} kostenlose Analysen.
|
||||
</Text>
|
||||
) : (
|
||||
<>
|
||||
<Text style={styles.hinweis}>
|
||||
Sie haben sich auf behoerdenklar.pages.dev angemeldet? Lösen Sie
|
||||
hier den Code aus Ihrer E-Mail ein.
|
||||
</Text>
|
||||
<TextInput
|
||||
style={styles.eingabe}
|
||||
placeholder="Code eingeben"
|
||||
placeholderTextColor={farben.textSekundaer}
|
||||
value={bonusCode}
|
||||
onChangeText={setBonusCode}
|
||||
autoCapitalize="characters"
|
||||
autoCorrect={false}
|
||||
accessibilityLabel="Aktions-Code eingeben"
|
||||
/>
|
||||
<GrossButton
|
||||
titel="Code einlösen"
|
||||
onPress={codeEinloesen}
|
||||
deaktiviert={!bonusCode.trim()}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<View style={styles.trenner} />
|
||||
|
||||
<Text style={styles.abschnittTitel}>Sicherheit</Text>
|
||||
<View style={styles.schalterZeile}>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={styles.text}>App-Sperre</Text>
|
||||
<Text style={styles.hinweis}>
|
||||
Face ID / Geräte-Code beim Öffnen der App — schützt Ihre Briefe,
|
||||
falls jemand Ihr Handy in die Hand bekommt.
|
||||
</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={sperreAktiv}
|
||||
onValueChange={sperreUmschalten}
|
||||
accessibilityLabel="App-Sperre umschalten"
|
||||
/>
|
||||
</View>
|
||||
|
||||
<Text style={[styles.text, { marginTop: abstand.m, fontWeight: '700' }]}>
|
||||
Briefe automatisch löschen
|
||||
</Text>
|
||||
<Text style={styles.hinweis}>
|
||||
Der Schlüssel wird verschlüsselt auf dem Gerät gespeichert (Secure Store)
|
||||
und nur für die Brief-Analyse verwendet. Einen Schlüssel erhalten Sie
|
||||
unter console.anthropic.com.
|
||||
Alte Briefe werden dann von selbst vom Gerät entfernt.
|
||||
</Text>
|
||||
<TextInput
|
||||
style={styles.eingabe}
|
||||
placeholder="sk-ant-…"
|
||||
placeholderTextColor={farben.textSekundaer}
|
||||
value={apiKey}
|
||||
onChangeText={setApiKey}
|
||||
secureTextEntry
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
accessibilityLabel="API-Schlüssel eingeben"
|
||||
/>
|
||||
<GrossButton
|
||||
titel={gespeichert ? 'Gespeichert ✓' : 'Schlüssel speichern'}
|
||||
onPress={speichern}
|
||||
deaktiviert={!apiKey.trim()}
|
||||
/>
|
||||
{[
|
||||
{ tage: 0, label: 'Nie (Standard)' },
|
||||
{ tage: 30, label: 'Nach 30 Tagen' },
|
||||
{ tage: 90, label: 'Nach 90 Tagen' },
|
||||
].map(({ tage, label }) => (
|
||||
<TouchableOpacity
|
||||
key={tage}
|
||||
style={[styles.option, autoTage === tage && styles.optionAktiv]}
|
||||
onPress={() => autoTageSetzen(tage)}
|
||||
accessibilityRole="radio"
|
||||
accessibilityState={{ selected: autoTage === tage }}
|
||||
>
|
||||
<Text style={[styles.text, autoTage === tage && styles.optionAktivText]}>
|
||||
{autoTage === tage ? '● ' : '○ '}
|
||||
{label}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
|
||||
<View style={styles.trenner} />
|
||||
|
||||
<Text style={styles.abschnittTitel}>Datenschutz</Text>
|
||||
<View style={styles.karte}>
|
||||
<Text style={styles.text}>
|
||||
• Brieffotos werden nur zur Analyse an Anthropic gesendet und dort
|
||||
nicht dauerhaft gespeichert.{'\n\n'}
|
||||
• Alle Ergebnisse liegen ausschließlich auf Ihrem Gerät.{'\n\n'}
|
||||
• Brieffotos werden nur zur Analyse an den KI-Dienst Anthropic (USA)
|
||||
gesendet und dort nach spätestens 30 Tagen gelöscht. Sie werden nicht
|
||||
zum Training der KI verwendet.{'\n\n'}
|
||||
• Alle Ergebnisse liegen ausschließlich auf Ihrem Gerät — verschlüsselt
|
||||
gespeichert (AES-256).{'\n\n'}
|
||||
• Diese App ersetzt keine Rechtsberatung.
|
||||
</Text>
|
||||
</View>
|
||||
@@ -101,7 +260,7 @@ export function EinstellungenScreen({ navigation }: Props) {
|
||||
<View style={styles.trenner} />
|
||||
|
||||
<Text style={styles.abschnittTitel}>Daten löschen</Text>
|
||||
<GrossButton titel="Alle Daten löschen" symbol="🗑️" variante="gefahr" onPress={allesLoeschen} />
|
||||
<GrossButton titel="Alle Daten löschen" ikone="muell" variante="gefahr" onPress={allesLoeschen} />
|
||||
<View style={{ height: abstand.xl }} />
|
||||
</ScrollView>
|
||||
);
|
||||
@@ -134,4 +293,20 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
karte: { backgroundColor: farben.flaeche, borderRadius: 14, padding: abstand.m },
|
||||
trenner: { height: 1, backgroundColor: farben.rand, marginVertical: abstand.l },
|
||||
schalterZeile: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: abstand.m,
|
||||
},
|
||||
option: {
|
||||
borderWidth: 1,
|
||||
borderColor: farben.rand,
|
||||
borderRadius: 12,
|
||||
padding: abstand.s,
|
||||
minHeight: 56,
|
||||
justifyContent: 'center',
|
||||
marginTop: abstand.s,
|
||||
},
|
||||
optionAktiv: { borderColor: farben.primaer, backgroundColor: farben.flaeche },
|
||||
optionAktivText: { fontWeight: '700', color: farben.primaer },
|
||||
});
|
||||
|
||||
@@ -18,10 +18,37 @@ import { holeConsent } from '../services/storage';
|
||||
import { berechneAmpel } from '../utils/ampel';
|
||||
import { formatiereDatum } from '../services/erinnerungen';
|
||||
import { GrossButton } from '../components/GrossButton';
|
||||
import { farben, schrift, abstand } from '../theme';
|
||||
import { Ikone, IkonenName } from '../components/Ikone';
|
||||
import { farben, schrift, abstand, TOUCH_TARGET } from '../theme';
|
||||
|
||||
type Props = NativeStackScreenProps<RootStackParamList, 'Home'>;
|
||||
|
||||
/** Quadratische Navigations-Kachel: Icon oben, Beschriftung darunter.
|
||||
* (Löst das Platzproblem nebeneinanderstehender Text-Buttons.) */
|
||||
function Kachel({
|
||||
ikone,
|
||||
titel,
|
||||
onPress,
|
||||
}: {
|
||||
ikone: IkonenName;
|
||||
titel: string;
|
||||
onPress: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.kachel, pressed && { opacity: 0.65 }]}
|
||||
onPress={onPress}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={titel}
|
||||
>
|
||||
<Ikone name={ikone} groesse={26} farbe={farben.primaer} />
|
||||
<Text style={styles.kachelTitel} numberOfLines={1}>
|
||||
{titel}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
export function HomeScreen({ navigation }: Props) {
|
||||
const briefe = useAppStore((s) => s.briefe);
|
||||
const removeBrief = useAppStore((s) => s.removeBrief);
|
||||
@@ -58,13 +85,16 @@ export function HomeScreen({ navigation }: Props) {
|
||||
const ampel = berechneAmpel(item.analyse);
|
||||
return (
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.karte, pressed && { opacity: 0.7 }]}
|
||||
style={({ pressed }) => [
|
||||
styles.karte,
|
||||
{ borderLeftColor: ampel.farbe },
|
||||
pressed && { opacity: 0.7 },
|
||||
]}
|
||||
onPress={() => navigation.navigate('Analyse', { briefId: item.id })}
|
||||
onLongPress={() => loeschenBestaetigen(item)}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`Brief: ${item.analyse.brieftyp}. ${ampel.text}. Lange drücken zum Löschen.`}
|
||||
>
|
||||
<View style={[styles.ampelPunkt, { backgroundColor: ampel.farbe }]} />
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={styles.kartenTitel} numberOfLines={1}>
|
||||
{item.analyse.brieftyp}
|
||||
@@ -76,73 +106,91 @@ export function HomeScreen({ navigation }: Props) {
|
||||
{ampel.text}
|
||||
</Text>
|
||||
</View>
|
||||
<Text style={styles.pfeil}>›</Text>
|
||||
<Ikone name="pfeilRechts" groesse={18} farbe={farben.textTertiaer} />
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.kopf}>
|
||||
<GrossButton titel="Brief scannen" symbol="📷" onPress={scanStarten} />
|
||||
<View style={styles.reihe}>
|
||||
<View style={{ flex: 1 }}>
|
||||
<GrossButton
|
||||
titel="Glossar"
|
||||
variante="sekundaer"
|
||||
symbol="📖"
|
||||
onPress={() => navigation.navigate('Glossar')}
|
||||
/>
|
||||
</View>
|
||||
<View style={{ width: abstand.s }} />
|
||||
<View style={{ flex: 1 }}>
|
||||
<GrossButton
|
||||
<FlatList
|
||||
style={styles.container}
|
||||
// iOS: sorgt beim großen Titel für korrekte Abstände + Kollabieren beim Scrollen
|
||||
contentInsetAdjustmentBehavior="automatic"
|
||||
keyboardShouldPersistTaps="handled"
|
||||
data={gefiltert}
|
||||
keyExtractor={(b) => b.id}
|
||||
renderItem={renderBrief}
|
||||
contentContainerStyle={styles.inhalt}
|
||||
ListHeaderComponent={
|
||||
<View style={styles.kopf}>
|
||||
<GrossButton titel="Brief scannen" ikone="kamera" onPress={scanStarten} />
|
||||
<View style={styles.reihe}>
|
||||
<Kachel ikone="buch" titel="Glossar" onPress={() => navigation.navigate('Glossar')} />
|
||||
<Kachel
|
||||
ikone="zahnrad"
|
||||
titel="Einstellungen"
|
||||
variante="sekundaer"
|
||||
symbol="⚙️"
|
||||
onPress={() => navigation.navigate('Einstellungen')}
|
||||
/>
|
||||
</View>
|
||||
{briefe.length > 0 && (
|
||||
<>
|
||||
<Text style={styles.abschnittsLabel}>Ihre Briefe</Text>
|
||||
<TextInput
|
||||
style={styles.suche}
|
||||
placeholder="Im Archiv suchen…"
|
||||
placeholderTextColor={farben.textSekundaer}
|
||||
value={suche}
|
||||
onChangeText={setSuche}
|
||||
accessibilityLabel="Archiv durchsuchen"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{briefe.length > 0 && (
|
||||
<TextInput
|
||||
style={styles.suche}
|
||||
placeholder="Im Archiv suchen…"
|
||||
placeholderTextColor={farben.textSekundaer}
|
||||
value={suche}
|
||||
onChangeText={setSuche}
|
||||
accessibilityLabel="Archiv durchsuchen"
|
||||
/>
|
||||
)}
|
||||
|
||||
<FlatList
|
||||
data={gefiltert}
|
||||
keyExtractor={(b) => b.id}
|
||||
renderItem={renderBrief}
|
||||
contentContainerStyle={{ padding: abstand.m, paddingTop: 0 }}
|
||||
ListEmptyComponent={
|
||||
<Text style={styles.leer}>
|
||||
{briefe.length === 0
|
||||
? 'Noch keine Briefe.\nScannen Sie Ihren ersten Behördenbrief!'
|
||||
: 'Keine Treffer.'}
|
||||
</Text>
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
}
|
||||
ListEmptyComponent={
|
||||
briefe.length === 0 ? (
|
||||
<View style={styles.leerBox}>
|
||||
<Ikone name="brief" groesse={48} farbe={farben.textTertiaer} />
|
||||
<Text style={styles.leerTitel}>Noch keine Briefe</Text>
|
||||
<Text style={styles.leer}>
|
||||
Scannen Sie Ihren ersten Behördenbrief —{'\n'}in einer Minute
|
||||
wissen Sie, was er bedeutet.
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<Text style={styles.leer}>Keine Treffer.</Text>
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: farben.hintergrund },
|
||||
kopf: { padding: abstand.m, gap: abstand.s },
|
||||
reihe: { flexDirection: 'row' },
|
||||
inhalt: { paddingHorizontal: abstand.m, paddingBottom: abstand.l },
|
||||
kopf: { paddingTop: abstand.s, paddingBottom: abstand.s, gap: abstand.s },
|
||||
reihe: { flexDirection: 'row', gap: abstand.s },
|
||||
kachel: {
|
||||
flex: 1,
|
||||
minHeight: TOUCH_TARGET + 22,
|
||||
backgroundColor: farben.flaeche,
|
||||
borderRadius: 12,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: abstand.s,
|
||||
gap: 6,
|
||||
},
|
||||
kachelTitel: { fontSize: schrift.klein + 1, fontWeight: '600', color: farben.primaer },
|
||||
abschnittsLabel: {
|
||||
marginTop: abstand.m,
|
||||
fontSize: schrift.klein - 1,
|
||||
fontWeight: '600',
|
||||
letterSpacing: 1.2,
|
||||
textTransform: 'uppercase',
|
||||
color: farben.textSekundaer,
|
||||
},
|
||||
suche: {
|
||||
marginHorizontal: abstand.m,
|
||||
marginBottom: abstand.s,
|
||||
borderWidth: 1,
|
||||
borderColor: farben.rand,
|
||||
backgroundColor: farben.flaeche,
|
||||
borderRadius: 12,
|
||||
padding: abstand.s,
|
||||
fontSize: schrift.basis,
|
||||
@@ -153,21 +201,21 @@ const styles = StyleSheet.create({
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: farben.flaeche,
|
||||
borderRadius: 14,
|
||||
borderRadius: 12,
|
||||
borderLeftWidth: 6,
|
||||
padding: abstand.m,
|
||||
marginBottom: abstand.s,
|
||||
gap: abstand.s,
|
||||
},
|
||||
ampelPunkt: { width: 16, height: 16, borderRadius: 8 },
|
||||
kartenTitel: { fontSize: schrift.basis, fontWeight: '700', color: farben.text },
|
||||
kartenUntertitel: { fontSize: schrift.klein, color: farben.textSekundaer, marginTop: 2 },
|
||||
kartenAmpelText: { fontSize: schrift.klein, fontWeight: '600', marginTop: 2 },
|
||||
pfeil: { fontSize: 32, color: farben.textSekundaer },
|
||||
leerBox: { alignItems: 'center', marginTop: abstand.xl, gap: abstand.s },
|
||||
leerTitel: { fontSize: schrift.gross, fontWeight: '700', color: farben.text },
|
||||
leer: {
|
||||
textAlign: 'center',
|
||||
fontSize: schrift.basis,
|
||||
color: farben.textSekundaer,
|
||||
marginTop: abstand.xl,
|
||||
lineHeight: 28,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -2,15 +2,23 @@
|
||||
* Brief erfassen: Foto aufnehmen, aus Galerie wählen oder PDF hochladen.
|
||||
* Danach läuft die KI-Analyse; bei Erfolg geht es direkt zum Ergebnis.
|
||||
*/
|
||||
import React, { useState } from 'react';
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { ActivityIndicator, Alert, StyleSheet, Text, View } from 'react-native';
|
||||
import { useFocusEffect } from '@react-navigation/native';
|
||||
import * as ImagePicker from 'expo-image-picker';
|
||||
import * as DocumentPicker from 'expo-document-picker';
|
||||
import { ImageManipulator, SaveFormat } from 'expo-image-manipulator';
|
||||
import { File } from 'expo-file-system';
|
||||
import { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||
import { RootStackParamList, BriefEintrag } from '../types';
|
||||
import { analysiereBrief } from '../services/analyse';
|
||||
import { ClaudeFehler } from '../services/claudeClient';
|
||||
import { ClaudeFehler, NUTZT_PROXY } from '../services/claudeClient';
|
||||
import {
|
||||
GRATIS_ANALYSEN,
|
||||
holeAnzahlAnalysen,
|
||||
holeGratisKontingent,
|
||||
zaehleAnalyse,
|
||||
} from '../services/storage';
|
||||
import { planeErinnerungen } from '../services/erinnerungen';
|
||||
import { useAppStore, neueId } from '../store/useAppStore';
|
||||
import { GrossButton } from '../components/GrossButton';
|
||||
@@ -18,15 +26,72 @@ import { farben, schrift, abstand } from '../theme';
|
||||
|
||||
type Props = NativeStackScreenProps<RootStackParamList, 'Scan'>;
|
||||
|
||||
/**
|
||||
* Maximale Kantenlänge fürs KI-Bild. Ein A4-Brief bleibt bei 2000px gut
|
||||
* lesbar (~170 dpi); moderne Handyfotos (4000px+) würden unverkleinert
|
||||
* bis zu 3x mehr Bild-Token kosten.
|
||||
*/
|
||||
const MAX_BILD_KANTE = 2000;
|
||||
|
||||
/**
|
||||
* Verkleinert ein Foto auf max. MAX_BILD_KANTE (lange Kante, Seitenverhältnis
|
||||
* bleibt erhalten), komprimiert als JPEG und liefert base64 für die Analyse.
|
||||
*/
|
||||
async function bildFuerAnalyse(asset: ImagePicker.ImagePickerAsset): Promise<string> {
|
||||
const kontext = ImageManipulator.manipulate(asset.uri);
|
||||
if (Math.max(asset.width, asset.height) > MAX_BILD_KANTE) {
|
||||
kontext.resize(
|
||||
asset.width >= asset.height
|
||||
? { width: MAX_BILD_KANTE }
|
||||
: { height: MAX_BILD_KANTE }
|
||||
);
|
||||
}
|
||||
const bild = await kontext.renderAsync();
|
||||
const ergebnis = await bild.saveAsync({
|
||||
format: SaveFormat.JPEG,
|
||||
compress: 0.6,
|
||||
base64: true,
|
||||
});
|
||||
return ergebnis.base64!;
|
||||
}
|
||||
|
||||
export function ScanScreen({ navigation }: Props) {
|
||||
const addBrief = useAppStore((s) => s.addBrief);
|
||||
const [laedt, setLaedt] = useState(false);
|
||||
const [verbraucht, setVerbraucht] = useState(0);
|
||||
const [kontingent, setKontingent] = useState(GRATIS_ANALYSEN);
|
||||
|
||||
// Zähler + Kontingent bei jedem Screen-Besuch frisch laden
|
||||
// (Kontingent kann sich ändern, wenn in den Einstellungen ein
|
||||
// Aktions-Code eingelöst wurde)
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
holeAnzahlAnalysen().then(setVerbraucht);
|
||||
holeGratisKontingent().then(setKontingent);
|
||||
}, [])
|
||||
);
|
||||
|
||||
// Freemium gilt nur im Produktionsmodus (Proxy). Im Dev-Modus mit
|
||||
// eigenem API-Key zahlt der Entwickler selbst — kein Limit nötig.
|
||||
const uebrig = kontingent - verbraucht;
|
||||
const kontingentLeer = NUTZT_PROXY && uebrig <= 0;
|
||||
|
||||
/** Prüft das Gratis-Kontingent; false = Analyse nicht starten. */
|
||||
const pruefeKontingent = (): boolean => {
|
||||
if (!kontingentLeer) return true;
|
||||
Alert.alert(
|
||||
'Gratis-Analysen aufgebraucht',
|
||||
`Sie haben Ihre ${kontingent} kostenlosen Brief-Analysen genutzt. Bald können Sie hier ein Abo abschließen, um weiter Briefe zu analysieren.`
|
||||
);
|
||||
return false;
|
||||
};
|
||||
|
||||
/** Gemeinsamer Abschluss: analysieren, speichern, Erinnerungen, weiter. */
|
||||
const verarbeite = async (base64: string, mimeType: string) => {
|
||||
setLaedt(true);
|
||||
try {
|
||||
const analyse = await analysiereBrief(base64, mimeType);
|
||||
await zaehleAnalyse();
|
||||
const brief: BriefEintrag = {
|
||||
id: neueId(),
|
||||
erstelltAm: new Date().toISOString(),
|
||||
@@ -49,6 +114,7 @@ export function ScanScreen({ navigation }: Props) {
|
||||
};
|
||||
|
||||
const fotoAufnehmen = async () => {
|
||||
if (!pruefeKontingent()) return;
|
||||
const { granted } = await ImagePicker.requestCameraPermissionsAsync();
|
||||
if (!granted) {
|
||||
Alert.alert('Kamera-Zugriff nötig', 'Bitte erlauben Sie den Kamera-Zugriff in den Geräte-Einstellungen.');
|
||||
@@ -56,16 +122,17 @@ export function ScanScreen({ navigation }: Props) {
|
||||
}
|
||||
const ergebnis = await ImagePicker.launchCameraAsync({
|
||||
mediaTypes: 'images',
|
||||
quality: 0.6, // Kompromiss: lesbar für die KI, aber kleine Datenmenge
|
||||
base64: true,
|
||||
// Volle Qualität vom Picker — verkleinert & komprimiert wird einmal
|
||||
// zentral in bildFuerAnalyse (doppelte JPEG-Kompression vermeiden)
|
||||
quality: 1,
|
||||
});
|
||||
if (!ergebnis.canceled && ergebnis.assets[0]?.base64) {
|
||||
// base64 der ImagePicker-Assets ist laut Doku immer JPEG
|
||||
await verarbeite(ergebnis.assets[0].base64, 'image/jpeg');
|
||||
if (!ergebnis.canceled && ergebnis.assets[0]) {
|
||||
await verarbeite(await bildFuerAnalyse(ergebnis.assets[0]), 'image/jpeg');
|
||||
}
|
||||
};
|
||||
|
||||
const ausGalerie = async () => {
|
||||
if (!pruefeKontingent()) return;
|
||||
const { granted } = await ImagePicker.requestMediaLibraryPermissionsAsync();
|
||||
if (!granted) {
|
||||
Alert.alert('Foto-Zugriff nötig', 'Bitte erlauben Sie den Zugriff auf Ihre Fotos in den Geräte-Einstellungen.');
|
||||
@@ -73,15 +140,15 @@ export function ScanScreen({ navigation }: Props) {
|
||||
}
|
||||
const ergebnis = await ImagePicker.launchImageLibraryAsync({
|
||||
mediaTypes: 'images',
|
||||
quality: 0.6,
|
||||
base64: true,
|
||||
quality: 1,
|
||||
});
|
||||
if (!ergebnis.canceled && ergebnis.assets[0]?.base64) {
|
||||
await verarbeite(ergebnis.assets[0].base64, 'image/jpeg');
|
||||
if (!ergebnis.canceled && ergebnis.assets[0]) {
|
||||
await verarbeite(await bildFuerAnalyse(ergebnis.assets[0]), 'image/jpeg');
|
||||
}
|
||||
};
|
||||
|
||||
const pdfWaehlen = async () => {
|
||||
if (!pruefeKontingent()) return;
|
||||
const ergebnis = await DocumentPicker.getDocumentAsync({
|
||||
type: 'application/pdf',
|
||||
copyToCacheDirectory: true,
|
||||
@@ -115,9 +182,16 @@ export function ScanScreen({ navigation }: Props) {
|
||||
Fotografieren Sie den Brief gut beleuchtet und von oben. Der ganze Text
|
||||
muss auf dem Foto sein.
|
||||
</Text>
|
||||
<GrossButton titel="Foto aufnehmen" symbol="📷" onPress={fotoAufnehmen} />
|
||||
<GrossButton titel="Aus Galerie wählen" symbol="🖼️" variante="sekundaer" onPress={ausGalerie} />
|
||||
<GrossButton titel="PDF hochladen" symbol="📄" variante="sekundaer" onPress={pdfWaehlen} />
|
||||
<GrossButton titel="Foto aufnehmen" ikone="kamera" onPress={fotoAufnehmen} />
|
||||
<GrossButton titel="Aus Galerie wählen" ikone="galerie" variante="sekundaer" onPress={ausGalerie} />
|
||||
<GrossButton titel="PDF hochladen" ikone="dokument" variante="sekundaer" onPress={pdfWaehlen} />
|
||||
{NUTZT_PROXY && (
|
||||
<Text style={styles.kontingent}>
|
||||
{kontingentLeer
|
||||
? 'Gratis-Analysen aufgebraucht — Abo folgt in Kürze'
|
||||
: `Noch ${uebrig} von ${kontingent} kostenlosen Analysen`}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -145,6 +219,12 @@ const styles = StyleSheet.create({
|
||||
padding: abstand.xl,
|
||||
gap: abstand.m,
|
||||
},
|
||||
kontingent: {
|
||||
fontSize: schrift.klein,
|
||||
color: farben.textSekundaer,
|
||||
textAlign: 'center',
|
||||
marginTop: abstand.m,
|
||||
},
|
||||
ladeText: { fontSize: schrift.gross, fontWeight: '700', color: farben.text },
|
||||
ladeHinweis: {
|
||||
fontSize: schrift.basis,
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* Sprache, Fachbegriffe, Fristen, Termine, Checkliste und Antwort-Optionen.
|
||||
*/
|
||||
import { BriefAnalyse } from '../types';
|
||||
import { claudeJsonAufruf, ContentBlock } from './claudeClient';
|
||||
import { claudeJsonAufruf, ContentBlock, MODELL_ANALYSE } from './claudeClient';
|
||||
|
||||
/**
|
||||
* JSON-Schema für die Analyse. Die Claude API erzwingt dieses Schema
|
||||
@@ -159,6 +159,7 @@ export async function analysiereBrief(
|
||||
};
|
||||
|
||||
return claudeJsonAufruf<BriefAnalyse>({
|
||||
modell: MODELL_ANALYSE,
|
||||
system: SYSTEM_PROMPT,
|
||||
schema: ANALYSE_SCHEMA,
|
||||
messages: [
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* Entwurf danach frei bearbeiten und als PDF exportieren.
|
||||
*/
|
||||
import { AntwortTyp, ANTWORT_TYP_LABEL, BriefAnalyse } from '../types';
|
||||
import { claudeJsonAufruf } from './claudeClient';
|
||||
import { claudeJsonAufruf, MODELL_EINFACH } from './claudeClient';
|
||||
|
||||
const ANTWORT_SCHEMA = {
|
||||
type: 'object',
|
||||
@@ -36,6 +36,8 @@ export async function generiereAntwort(
|
||||
hinweise: string
|
||||
): Promise<AntwortEntwurf> {
|
||||
return claudeJsonAufruf<AntwortEntwurf>({
|
||||
modell: MODELL_EINFACH,
|
||||
maxTokens: 4000,
|
||||
system: `Du schreibst formelle, höfliche Antwortbriefe an deutsche Behörden im Namen von Privatpersonen. Regeln:
|
||||
- Korrektes, formelles Deutsch (der Empfänger ist eine Behörde — hier KEINE einfache Sprache).
|
||||
- Kurz und präzise, keine Floskeln über das Nötige hinaus.
|
||||
|
||||
@@ -13,16 +13,32 @@
|
||||
* deshalb hier bewusst rohes fetch gegen die Messages API.
|
||||
*/
|
||||
import * as Network from 'expo-network';
|
||||
import { holeApiKey } from './storage';
|
||||
import { holeApiKey, holeGeraeteId } from './storage';
|
||||
|
||||
/** Für Produktion hier die eigene Server-URL eintragen, z. B. 'https://api.meine-app.de/claude'. */
|
||||
const PROXY_URL: string | null = null;
|
||||
/**
|
||||
* Für Produktion hier die URL des deployten Workers eintragen,
|
||||
* z. B. 'https://behoerdenklar-proxy.<account>.workers.dev'.
|
||||
* Setup-Anleitung: proxy/README.md
|
||||
*/
|
||||
const PROXY_URL: string | null =
|
||||
'https://behoerdenklar-proxy.behoerdenbrief.workers.dev';
|
||||
|
||||
/** true = Produktionsmodus über den Backend-Proxy (kein API-Key in der App nötig). */
|
||||
export const NUTZT_PROXY = PROXY_URL !== null;
|
||||
|
||||
const ANTHROPIC_URL = 'https://api.anthropic.com/v1/messages';
|
||||
const ANTHROPIC_VERSION = '2023-06-01';
|
||||
|
||||
/** Standard-Modell für alle Aufrufe. */
|
||||
export const MODELL = 'claude-opus-4-8';
|
||||
/**
|
||||
* Modell-Split zur Kostenoptimierung:
|
||||
* - Brief-Analyse (Vision, Genauigkeit wichtig): Sonnet 5 (~2,5 Cent/Brief)
|
||||
* - Übersetzung & Antwort-Entwürfe (einfache Textaufgaben): Haiku 4.5 (~halber Preis)
|
||||
*
|
||||
* Hinweis: Der Backend-Proxy erlaubt nur diese Modelle (Whitelist in
|
||||
* proxy/src/index.ts) — bei Änderungen dort mitziehen.
|
||||
*/
|
||||
export const MODELL_ANALYSE = 'claude-sonnet-5';
|
||||
export const MODELL_EINFACH = 'claude-haiku-4-5';
|
||||
|
||||
// ---- Typen für den Request-Body (Teilmenge der Messages API) ----
|
||||
|
||||
@@ -44,6 +60,8 @@ export interface DocumentBlock {
|
||||
export type ContentBlock = TextBlock | ImageBlock | DocumentBlock;
|
||||
|
||||
export interface ClaudeRequest {
|
||||
/** Welches Modell: MODELL_ANALYSE (Vision) oder MODELL_EINFACH (Text). */
|
||||
modell: string;
|
||||
system?: string;
|
||||
messages: { role: 'user' | 'assistant'; content: string | ContentBlock[] }[];
|
||||
/** JSON-Schema für Structured Output — garantiert valides JSON in der Antwort. */
|
||||
@@ -75,10 +93,11 @@ export async function claudeJsonAufruf<T>(anfrage: ClaudeRequest): Promise<T> {
|
||||
|
||||
// 2. Request-Body bauen
|
||||
const body: Record<string, unknown> = {
|
||||
model: MODELL,
|
||||
model: anfrage.modell,
|
||||
max_tokens: anfrage.maxTokens ?? 16000,
|
||||
// Adaptives Denken verbessert die Extraktions-Genauigkeit deutlich
|
||||
thinking: { type: 'adaptive' },
|
||||
// Kein explizites thinking-Feld: Sonnet 5 denkt adaptiv von selbst
|
||||
// (gut für die Extraktions-Genauigkeit), Haiku 4.5 unterstützt den
|
||||
// adaptiven Modus nicht und würde die Anfrage ablehnen.
|
||||
messages: anfrage.messages,
|
||||
};
|
||||
if (anfrage.system) body.system = anfrage.system;
|
||||
@@ -96,6 +115,8 @@ export async function claudeJsonAufruf<T>(anfrage: ClaudeRequest): Promise<T> {
|
||||
};
|
||||
if (PROXY_URL) {
|
||||
url = PROXY_URL; // Der Proxy hält den API-Key serverseitig
|
||||
// Anonyme ID, damit der Proxy das Tageslimit pro Gerät durchsetzen kann
|
||||
headers['x-geraete-id'] = await holeGeraeteId();
|
||||
} else {
|
||||
const apiKey = await holeApiKey();
|
||||
if (!apiKey) {
|
||||
@@ -137,8 +158,11 @@ export async function claudeJsonAufruf<T>(anfrage: ClaudeRequest): Promise<T> {
|
||||
technisch
|
||||
);
|
||||
case 429:
|
||||
// Direkt-Modus: API-Rate-Limit. Proxy-Modus: Tageslimit pro Gerät.
|
||||
throw new ClaudeFehler(
|
||||
'Zu viele Anfragen. Bitte warten Sie einen Moment und versuchen Sie es erneut.',
|
||||
PROXY_URL
|
||||
? 'Das tägliche Kontingent ist aufgebraucht. Bitte versuchen Sie es morgen erneut.'
|
||||
: 'Zu viele Anfragen. Bitte warten Sie einen Moment und versuchen Sie es erneut.',
|
||||
technisch
|
||||
);
|
||||
case 413:
|
||||
|
||||
61
src/services/krypto.ts
Normal file
61
src/services/krypto.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Verschlüsselung des Brief-Archivs (AES-256-GCM, nativ via expo-crypto).
|
||||
*
|
||||
* Warum: Die analysierten Briefe enthalten hochsensible Daten (Sozialleistungen,
|
||||
* Gesundheit, Finanzen). AsyncStorage ist unverschlüsselter Klartext auf der
|
||||
* Platte — deshalb wird das Archiv vor dem Speichern verschlüsselt.
|
||||
*
|
||||
* Schlüsselverwaltung: Ein 256-Bit-Schlüssel wird beim ersten Bedarf erzeugt
|
||||
* und im Secure Store des Geräts abgelegt (iOS Keychain / Android Keystore).
|
||||
* Er verlässt das Gerät nie. GCM liefert zusätzlich Integritätsschutz —
|
||||
* manipulierte Daten fallen beim Entschlüsseln auf.
|
||||
*/
|
||||
import {
|
||||
AESEncryptionKey,
|
||||
AESSealedData,
|
||||
aesDecryptAsync,
|
||||
aesEncryptAsync,
|
||||
} from 'expo-crypto';
|
||||
import * as SecureStore from 'expo-secure-store';
|
||||
import { utf8Dekodieren, utf8Kodieren } from '../utils/utf8';
|
||||
|
||||
const KEY_ARCHIV_SCHLUESSEL = 'behoerdenklar_archiv_schluessel';
|
||||
|
||||
/** Im Speicher gehaltener Schlüssel — spart SecureStore-Zugriffe pro Aufruf. */
|
||||
let schluesselCache: AESEncryptionKey | null = null;
|
||||
|
||||
async function holeSchluessel(): Promise<AESEncryptionKey> {
|
||||
if (schluesselCache) return schluesselCache;
|
||||
const hex = await SecureStore.getItemAsync(KEY_ARCHIV_SCHLUESSEL);
|
||||
if (hex) {
|
||||
schluesselCache = await AESEncryptionKey.import(hex, 'hex');
|
||||
} else {
|
||||
schluesselCache = await AESEncryptionKey.generate(); // 256 Bit (Default)
|
||||
await SecureStore.setItemAsync(
|
||||
KEY_ARCHIV_SCHLUESSEL,
|
||||
await schluesselCache.encoded('hex')
|
||||
);
|
||||
}
|
||||
return schluesselCache;
|
||||
}
|
||||
|
||||
/** Verschlüsselt Text -> base64-String (IV + Ciphertext + GCM-Tag kombiniert). */
|
||||
export async function verschluesseleText(klartext: string): Promise<string> {
|
||||
const schluessel = await holeSchluessel();
|
||||
const versiegelt = await aesEncryptAsync(utf8Kodieren(klartext), schluessel);
|
||||
return (await versiegelt.combined('base64')) as string;
|
||||
}
|
||||
|
||||
/** Entschlüsselt einen base64-String zurück zu Text. Wirft bei Manipulation. */
|
||||
export async function entschluesseleText(chiffrat: string): Promise<string> {
|
||||
const schluessel = await holeSchluessel();
|
||||
const versiegelt = AESSealedData.fromCombined(chiffrat);
|
||||
const bytes = (await aesDecryptAsync(versiegelt, schluessel)) as Uint8Array;
|
||||
return utf8Dekodieren(bytes);
|
||||
}
|
||||
|
||||
/** Für "Alle Daten löschen": Schlüssel unwiderruflich entfernen. */
|
||||
export async function loescheArchivSchluessel(): Promise<void> {
|
||||
schluesselCache = null;
|
||||
await SecureStore.deleteItemAsync(KEY_ARCHIV_SCHLUESSEL);
|
||||
}
|
||||
@@ -1,19 +1,62 @@
|
||||
/**
|
||||
* Lokale Speicherung.
|
||||
* - API-Key: expo-secure-store (verschlüsselt, nie im Klartext auf der Platte)
|
||||
* - Brief-Archiv + Einwilligung: AsyncStorage (bleibt auf dem Gerät)
|
||||
* - Brief-Archiv: AsyncStorage, aber AES-256-GCM-verschlüsselt (siehe krypto.ts)
|
||||
* - Einwilligung & Einstellungen: AsyncStorage (unkritische Flags)
|
||||
*
|
||||
* Datenschutz: Alle Daten bleiben lokal. Nichts wird an Server gesendet,
|
||||
* außer dem Briefinhalt zur KI-Analyse (nach expliziter Einwilligung).
|
||||
* loescheAlleDaten() entfernt restlos alles.
|
||||
* loescheAlleDaten() entfernt restlos alles — inklusive Archiv-Schlüssel.
|
||||
*/
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import * as SecureStore from 'expo-secure-store';
|
||||
import { BriefEintrag } from '../types';
|
||||
import {
|
||||
entschluesseleText,
|
||||
loescheArchivSchluessel,
|
||||
verschluesseleText,
|
||||
} from './krypto';
|
||||
|
||||
const KEY_API = 'anthropic_api_key';
|
||||
const KEY_BRIEFE = 'behoerdenklar_briefe';
|
||||
const KEY_CONSENT = 'behoerdenklar_consent';
|
||||
const KEY_GERAETE_ID = 'behoerdenklar_geraete_id';
|
||||
const KEY_SCAN_ANZAHL = 'behoerdenklar_scan_anzahl';
|
||||
|
||||
/**
|
||||
* Freemium: so viele Brief-Analysen sind kostenlos. Danach braucht es das
|
||||
* Abo (kommt mit dem IAP-Einbau). Der Zähler liegt lokal — das ist bewusst
|
||||
* "weich" (Neuinstallation setzt ihn zurück); die harte Grenze ist das
|
||||
* Tageslimit des Backend-Proxys. Echte Abo-Prüfung folgt serverseitig.
|
||||
*/
|
||||
export const GRATIS_ANALYSEN = 3;
|
||||
|
||||
/** Erhöhtes Kontingent für Warteliste-Anmelder (Code aus der Willkommens-Mail). */
|
||||
export const BONUS_ANALYSEN = 5;
|
||||
const KEY_BONUS = 'behoerdenklar_bonus';
|
||||
|
||||
/**
|
||||
* Aktions-Codes der Landingpage-Warteliste. Bewusst ein geteilter Code
|
||||
* (kein Einmal-Code): Der "Schaden" bei Weitergabe sind 2 Extra-Analysen
|
||||
* (~5 Cent) — dafür brauchen wir keinerlei Server-Logik.
|
||||
*/
|
||||
const GUELTIGE_CODES = ['FRUEHSTART'];
|
||||
|
||||
export async function holeBonusAktiv(): Promise<boolean> {
|
||||
return (await AsyncStorage.getItem(KEY_BONUS)) === 'ja';
|
||||
}
|
||||
|
||||
/** true = Code war gültig und wurde eingelöst. */
|
||||
export async function loeseBonusCodeEin(code: string): Promise<boolean> {
|
||||
if (!GUELTIGE_CODES.includes(code.trim().toUpperCase())) return false;
|
||||
await AsyncStorage.setItem(KEY_BONUS, 'ja');
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Aktuelles Gratis-Kontingent dieses Geräts (3, mit Aktions-Code 5). */
|
||||
export async function holeGratisKontingent(): Promise<number> {
|
||||
return (await holeBonusAktiv()) ? BONUS_ANALYSEN : GRATIS_ANALYSEN;
|
||||
}
|
||||
|
||||
// ---- API-Key (sensibel -> SecureStore) ----
|
||||
|
||||
@@ -29,6 +72,42 @@ export async function speichereApiKey(key: string): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Geräte-ID (anonym, nur fürs Tageslimit des Backend-Proxys) ----
|
||||
|
||||
/**
|
||||
* Liefert eine zufällige, anonyme Geräte-ID (wird beim ersten Aufruf erzeugt).
|
||||
* Sie enthält keine Gerätedaten und dient dem Proxy ausschließlich dazu,
|
||||
* das Anfrage-Tageslimit pro Installation durchzusetzen.
|
||||
*/
|
||||
export async function holeGeraeteId(): Promise<string> {
|
||||
let id = await AsyncStorage.getItem(KEY_GERAETE_ID);
|
||||
if (!id) {
|
||||
// Math.random reicht hier: die ID ist kein Geheimnis, nur ein Zähl-Schlüssel
|
||||
id =
|
||||
'g_' +
|
||||
Array.from({ length: 24 }, () =>
|
||||
Math.floor(Math.random() * 36).toString(36)
|
||||
).join('');
|
||||
await AsyncStorage.setItem(KEY_GERAETE_ID, id);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
// ---- Freemium-Zähler ----
|
||||
|
||||
/** Wie viele Brief-Analysen wurden auf diesem Gerät schon durchgeführt? */
|
||||
export async function holeAnzahlAnalysen(): Promise<number> {
|
||||
const roh = await AsyncStorage.getItem(KEY_SCAN_ANZAHL);
|
||||
const anzahl = roh ? parseInt(roh, 10) : 0;
|
||||
return Number.isFinite(anzahl) ? anzahl : 0;
|
||||
}
|
||||
|
||||
/** Nach jeder erfolgreichen Analyse aufrufen. */
|
||||
export async function zaehleAnalyse(): Promise<void> {
|
||||
const bisher = await holeAnzahlAnalysen();
|
||||
await AsyncStorage.setItem(KEY_SCAN_ANZAHL, String(bisher + 1));
|
||||
}
|
||||
|
||||
// ---- Einwilligung (Datenschutz-Consent) ----
|
||||
|
||||
export async function holeConsent(): Promise<boolean> {
|
||||
@@ -39,26 +118,83 @@ export async function speichereConsent(): Promise<void> {
|
||||
await AsyncStorage.setItem(KEY_CONSENT, 'ja');
|
||||
}
|
||||
|
||||
// ---- Brief-Archiv ----
|
||||
// ---- Brief-Archiv (verschlüsselt) ----
|
||||
|
||||
export async function ladeBriefe(): Promise<BriefEintrag[]> {
|
||||
const roh = await AsyncStorage.getItem(KEY_BRIEFE);
|
||||
if (!roh) return [];
|
||||
try {
|
||||
return JSON.parse(roh) as BriefEintrag[];
|
||||
// Migration: Bestände aus Versionen vor der Verschlüsselung liegen als
|
||||
// Klartext-JSON-Array vor ('['), base64-Chiffrate nie. Einmalig
|
||||
// verschlüsselt neu schreiben.
|
||||
if (roh.startsWith('[')) {
|
||||
const briefe = JSON.parse(roh) as BriefEintrag[];
|
||||
await speichereBriefe(briefe);
|
||||
return briefe;
|
||||
}
|
||||
return JSON.parse(await entschluesseleText(roh)) as BriefEintrag[];
|
||||
} catch {
|
||||
// Korrupte Daten nicht crashen lassen — leeres Archiv liefern
|
||||
// Korrupte/unlesbare Daten nicht crashen lassen — leeres Archiv liefern
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function speichereBriefe(briefe: BriefEintrag[]): Promise<void> {
|
||||
await AsyncStorage.setItem(KEY_BRIEFE, JSON.stringify(briefe));
|
||||
await AsyncStorage.setItem(
|
||||
KEY_BRIEFE,
|
||||
await verschluesseleText(JSON.stringify(briefe))
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Sicherheits-Einstellungen ----
|
||||
|
||||
const KEY_APP_SPERRE = 'behoerdenklar_app_sperre';
|
||||
const KEY_AUTO_LOESCHEN = 'behoerdenklar_auto_loeschen_tage';
|
||||
|
||||
/** Ist die App-Sperre (Face ID / Geräte-Code beim Öffnen) aktiviert? */
|
||||
export async function holeAppSperre(): Promise<boolean> {
|
||||
return (await AsyncStorage.getItem(KEY_APP_SPERRE)) === 'ja';
|
||||
}
|
||||
|
||||
export async function setzeAppSperre(aktiv: boolean): Promise<void> {
|
||||
if (aktiv) {
|
||||
await AsyncStorage.setItem(KEY_APP_SPERRE, 'ja');
|
||||
} else {
|
||||
await AsyncStorage.removeItem(KEY_APP_SPERRE);
|
||||
}
|
||||
}
|
||||
|
||||
/** Nach wie vielen Tagen Briefe automatisch gelöscht werden (0 = nie). */
|
||||
export async function holeAutoLoeschTage(): Promise<number> {
|
||||
const roh = await AsyncStorage.getItem(KEY_AUTO_LOESCHEN);
|
||||
const tage = roh ? parseInt(roh, 10) : 0;
|
||||
return Number.isFinite(tage) && tage > 0 ? tage : 0;
|
||||
}
|
||||
|
||||
export async function setzeAutoLoeschTage(tage: number): Promise<void> {
|
||||
if (tage > 0) {
|
||||
await AsyncStorage.setItem(KEY_AUTO_LOESCHEN, String(tage));
|
||||
} else {
|
||||
await AsyncStorage.removeItem(KEY_AUTO_LOESCHEN);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Alles löschen (Datenschutz-Anforderung) ----
|
||||
|
||||
export async function loescheAlleDaten(): Promise<void> {
|
||||
await AsyncStorage.multiRemove([KEY_BRIEFE, KEY_CONSENT]);
|
||||
// "Alle Daten löschen" heißt wirklich alle — auch Zähler und Geräte-ID.
|
||||
// Dass damit das Gratis-Kontingent zurücksetzbar ist, nehmen wir in Kauf
|
||||
// (Neuinstallation könnte das ohnehin); der Proxy limitiert weiterhin.
|
||||
await AsyncStorage.multiRemove([
|
||||
KEY_BRIEFE,
|
||||
KEY_CONSENT,
|
||||
KEY_GERAETE_ID,
|
||||
KEY_SCAN_ANZAHL,
|
||||
KEY_BONUS,
|
||||
KEY_APP_SPERRE,
|
||||
KEY_AUTO_LOESCHEN,
|
||||
]);
|
||||
await SecureStore.deleteItemAsync(KEY_API);
|
||||
// Archiv-Schlüssel zuletzt: danach wären Restdaten ohnehin unlesbar
|
||||
await loescheArchivSchluessel();
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* Archiv gecacht (siehe store), damit kein Aufruf doppelt läuft.
|
||||
*/
|
||||
import { BriefAnalyse, Sprache, Uebersetzung } from '../types';
|
||||
import { claudeJsonAufruf } from './claudeClient';
|
||||
import { claudeJsonAufruf, MODELL_EINFACH } from './claudeClient';
|
||||
|
||||
const UEBERSETZUNG_SCHEMA = {
|
||||
type: 'object',
|
||||
@@ -36,6 +36,8 @@ export async function uebersetzeAnalyse(
|
||||
sprache: Sprache
|
||||
): Promise<Uebersetzung> {
|
||||
return claudeJsonAufruf<Uebersetzung>({
|
||||
modell: MODELL_EINFACH,
|
||||
maxTokens: 6000,
|
||||
system: `Du übersetzt Erklärungen deutscher Behördenbriefe in andere Sprachen. Übersetze in einfache, klare Sprache (Niveau A2/B1 der Zielsprache). Deutsche Fachbegriffe (Feld "begriff") NICHT übersetzen — sie stehen so im Original-Brief und der Nutzer muss sie wiedererkennen. Nur die Erklärungen übersetzen.`,
|
||||
schema: UEBERSETZUNG_SCHEMA,
|
||||
messages: [
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
*/
|
||||
import { create } from 'zustand';
|
||||
import { BriefEintrag, Uebersetzung, AntwortTyp } from '../types';
|
||||
import { ladeBriefe, speichereBriefe } from '../services/storage';
|
||||
import { holeAutoLoeschTage, ladeBriefe, speichereBriefe } from '../services/storage';
|
||||
import { filtereAlteBriefe } from '../utils/aufbewahrung';
|
||||
|
||||
interface AppState {
|
||||
briefe: BriefEintrag[];
|
||||
@@ -28,7 +29,14 @@ export const useAppStore = create<AppState>((set, get) => ({
|
||||
geladen: false,
|
||||
|
||||
initialisiere: async () => {
|
||||
const briefe = await ladeBriefe();
|
||||
let briefe = await ladeBriefe();
|
||||
// Auto-Löschen (Datensparsamkeit): abgelaufene Briefe beim Start entfernen
|
||||
const aufbewahrungTage = await holeAutoLoeschTage();
|
||||
const gefiltert = filtereAlteBriefe(briefe, aufbewahrungTage);
|
||||
if (gefiltert.length !== briefe.length) {
|
||||
briefe = gefiltert;
|
||||
await speichereBriefe(briefe);
|
||||
}
|
||||
set({ briefe, geladen: true });
|
||||
},
|
||||
|
||||
|
||||
40
src/theme.ts
40
src/theme.ts
@@ -5,24 +5,31 @@
|
||||
* - Große Touch-Targets (min. 56px Höhe)
|
||||
*/
|
||||
export const farben = {
|
||||
// Grundfarben
|
||||
hintergrund: '#FFFFFF',
|
||||
flaeche: '#F2F5F9',
|
||||
primaer: '#1A365D', // dunkles, ruhiges Blau
|
||||
// Grundfarben — natives iOS-Vorbild: gruppierter System-Hintergrund,
|
||||
// Inhalte als weiße Karten ohne Rahmen und Schatten (wie Einstellungen/Health)
|
||||
hintergrund: '#F2F2F7',
|
||||
flaeche: '#FFFFFF',
|
||||
flaecheSekundaer: '#E9E9EB', // "graue Taste" nach Apple-Art (statt Umrandung)
|
||||
primaer: '#007AFF', // Apple-System-Blau (Tint für Buttons, Icons, Links)
|
||||
primaerText: '#FFFFFF',
|
||||
text: '#1A202C',
|
||||
textSekundaer: '#4A5568',
|
||||
rand: '#CBD5E0',
|
||||
textTertiaer: '#AEAEB2', // z. B. Chevrons (iOS systemGray2)
|
||||
rand: '#E2E2E7',
|
||||
// Dezente Hervorhebung für DIE Kernaussage (leichter Blau-Tint)
|
||||
hervorhebung: '#EAF3FF',
|
||||
hervorhebungRand: '#D4E6FB',
|
||||
|
||||
// Dringlichkeits-Ampel (kräftig, gut unterscheidbar)
|
||||
ampelRot: '#C0392B',
|
||||
ampelGelb: '#B7791F',
|
||||
ampelGruen: '#276749',
|
||||
ampelRotHintergrund: '#FDECEA',
|
||||
ampelGelbHintergrund: '#FDF6E3',
|
||||
ampelGruenHintergrund: '#EAF6EE',
|
||||
// Dringlichkeits-Ampel: frische Signalfarben in Apples "zugänglichen"
|
||||
// Varianten — kräftig, aber als Text auf Weiß noch gut lesbar (WCAG)
|
||||
ampelRot: '#D70015',
|
||||
ampelGelb: '#C25400',
|
||||
ampelGruen: '#1E7E34',
|
||||
ampelRotHintergrund: '#FFEAE8',
|
||||
ampelGelbHintergrund: '#FFF1E0',
|
||||
ampelGruenHintergrund: '#E7F6EB',
|
||||
|
||||
fehler: '#C0392B',
|
||||
fehler: '#D70015',
|
||||
};
|
||||
|
||||
export const schrift = {
|
||||
@@ -43,3 +50,10 @@ export const abstand = {
|
||||
|
||||
/** Mindesthöhe für alle interaktiven Elemente (Barrierefreiheit). */
|
||||
export const TOUCH_TARGET = 56;
|
||||
|
||||
/**
|
||||
* Bewusst leer: Nach Apple-Vorbild heben sich weiße Karten allein durch
|
||||
* den grauen System-Hintergrund ab — ohne Schatten und Rahmen.
|
||||
* (Als No-Op behalten, damit bestehende Spreads harmlos bleiben.)
|
||||
*/
|
||||
export const kartenSchatten = {} as const;
|
||||
|
||||
161
src/utils/__tests__/ampel.test.ts
Normal file
161
src/utils/__tests__/ampel.test.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* Tests für die Dringlichkeits-Ampel — die kritischste Logik der App:
|
||||
* Eine falsche Ampel könnte dazu führen, dass ein Nutzer eine Behörden-Frist
|
||||
* verpasst. Deshalb hier alle Grenzfälle deterministisch abgesichert.
|
||||
*/
|
||||
import { berechneAmpel, tageBis } from '../ampel';
|
||||
import { BriefAnalyse } from '../../types';
|
||||
|
||||
/** Fixes "Heute" für alle Tests: 15.07.2026 (Mitte des Monats, kein Randfall). */
|
||||
const HEUTE = new Date('2026-07-15T10:30:00');
|
||||
|
||||
/** Minimal gültige Analyse; frist/termin pro Test überschreiben. */
|
||||
function analyse(teile: Partial<BriefAnalyse>): BriefAnalyse {
|
||||
return {
|
||||
brieftyp: 'Test',
|
||||
absender: 'Testamt',
|
||||
kernaussage: '',
|
||||
erklaerung_einfach: '',
|
||||
fachbegriffe: [],
|
||||
frist: null,
|
||||
termin: null,
|
||||
checkliste: [],
|
||||
antwort_noetig: false,
|
||||
antwort_optionen: [],
|
||||
...teile,
|
||||
};
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
jest.useFakeTimers();
|
||||
jest.setSystemTime(HEUTE);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
describe('tageBis', () => {
|
||||
it('liefert 0 für heute', () => {
|
||||
expect(tageBis('2026-07-15')).toBe(0);
|
||||
});
|
||||
|
||||
it('liefert positive Tage für die Zukunft', () => {
|
||||
expect(tageBis('2026-07-16')).toBe(1);
|
||||
expect(tageBis('2026-07-22')).toBe(7);
|
||||
});
|
||||
|
||||
it('liefert negative Tage für die Vergangenheit', () => {
|
||||
expect(tageBis('2026-07-14')).toBe(-1);
|
||||
expect(tageBis('2026-07-01')).toBe(-14);
|
||||
});
|
||||
|
||||
it('zählt ganze Tage unabhängig von der Uhrzeit "jetzt"', () => {
|
||||
// 10:30 Uhr darf keine halben Tage erzeugen
|
||||
expect(tageBis('2026-07-18')).toBe(3);
|
||||
});
|
||||
|
||||
it('funktioniert über Monatsgrenzen hinweg', () => {
|
||||
expect(tageBis('2026-08-01')).toBe(17);
|
||||
});
|
||||
});
|
||||
|
||||
describe('berechneAmpel — Stufen', () => {
|
||||
it('grün ohne Frist und Termin', () => {
|
||||
const status = berechneAmpel(analyse({}));
|
||||
expect(status.stufe).toBe('gruen');
|
||||
expect(status.text).toContain('Keine Frist');
|
||||
});
|
||||
|
||||
it('rot bei abgelaufener Frist (inkl. Hinweis)', () => {
|
||||
const status = berechneAmpel(
|
||||
analyse({ frist: { datum: '2026-07-10', aktion: 'Unterlagen einreichen' } })
|
||||
);
|
||||
expect(status.stufe).toBe('rot');
|
||||
expect(status.text).toContain('abgelaufen');
|
||||
});
|
||||
|
||||
it('rot bei Frist heute', () => {
|
||||
const status = berechneAmpel(
|
||||
analyse({ frist: { datum: '2026-07-15', aktion: 'Antwort' } })
|
||||
);
|
||||
expect(status.stufe).toBe('rot');
|
||||
expect(status.text).toContain('HEUTE');
|
||||
});
|
||||
|
||||
it('rot bei Frist in 6 Tagen (< 7)', () => {
|
||||
const status = berechneAmpel(
|
||||
analyse({ frist: { datum: '2026-07-21', aktion: 'Antwort' } })
|
||||
);
|
||||
expect(status.stufe).toBe('rot');
|
||||
});
|
||||
|
||||
it('gelb bei Frist in genau 7 Tagen (Grenzwert)', () => {
|
||||
const status = berechneAmpel(
|
||||
analyse({ frist: { datum: '2026-07-22', aktion: 'Antwort' } })
|
||||
);
|
||||
expect(status.stufe).toBe('gelb');
|
||||
});
|
||||
|
||||
it('gelb bei Frist in genau 21 Tagen (Grenzwert)', () => {
|
||||
const status = berechneAmpel(
|
||||
analyse({ frist: { datum: '2026-08-05', aktion: 'Antwort' } })
|
||||
);
|
||||
expect(status.stufe).toBe('gelb');
|
||||
});
|
||||
|
||||
it('grün bei Frist in 22 Tagen (> 21)', () => {
|
||||
const status = berechneAmpel(
|
||||
analyse({ frist: { datum: '2026-08-06', aktion: 'Antwort' } })
|
||||
);
|
||||
expect(status.stufe).toBe('gruen');
|
||||
});
|
||||
});
|
||||
|
||||
describe('berechneAmpel — Frist und Termin kombiniert', () => {
|
||||
it('das dringendste Datum bestimmt die Ampel (Termin näher als Frist)', () => {
|
||||
const status = berechneAmpel(
|
||||
analyse({
|
||||
frist: { datum: '2026-08-20', aktion: 'Unterlagen' }, // 36 Tage → grün
|
||||
termin: { datum: '2026-07-18', uhrzeit: '09:30', ort: 'Raum 1' }, // 3 Tage → rot
|
||||
})
|
||||
);
|
||||
expect(status.stufe).toBe('rot');
|
||||
expect(status.text).toContain('Termin');
|
||||
});
|
||||
|
||||
it('das dringendste Datum bestimmt die Ampel (Frist näher als Termin)', () => {
|
||||
const status = berechneAmpel(
|
||||
analyse({
|
||||
frist: { datum: '2026-07-25', aktion: 'Antwort' }, // 10 Tage → gelb
|
||||
termin: { datum: '2026-09-01', uhrzeit: null, ort: null }, // weit weg
|
||||
})
|
||||
);
|
||||
expect(status.stufe).toBe('gelb');
|
||||
expect(status.text).toContain('Frist');
|
||||
});
|
||||
|
||||
it('nur Termin ohne Frist funktioniert', () => {
|
||||
const status = berechneAmpel(
|
||||
analyse({ termin: { datum: '2026-07-16', uhrzeit: null, ort: null } })
|
||||
);
|
||||
expect(status.stufe).toBe('rot');
|
||||
expect(status.text).toContain('Termin');
|
||||
});
|
||||
});
|
||||
|
||||
describe('berechneAmpel — Textdetails', () => {
|
||||
it('Einzahl bei 1 Tag ("in 1 Tag", nicht "Tagen")', () => {
|
||||
const status = berechneAmpel(
|
||||
analyse({ frist: { datum: '2026-07-16', aktion: 'Antwort' } })
|
||||
);
|
||||
expect(status.text).toContain('in 1 Tag ');
|
||||
});
|
||||
|
||||
it('Einzahl bei 1 Tag abgelaufen', () => {
|
||||
const status = berechneAmpel(
|
||||
analyse({ frist: { datum: '2026-07-14', aktion: 'Antwort' } })
|
||||
);
|
||||
expect(status.text).toContain('vor 1 Tag)');
|
||||
});
|
||||
});
|
||||
61
src/utils/__tests__/aufbewahrung.test.ts
Normal file
61
src/utils/__tests__/aufbewahrung.test.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Tests fürs Auto-Löschen — löschen ist unumkehrbar, deshalb müssen die
|
||||
* Grenzfälle sitzen (nichts zu früh löschen, "nie" respektieren).
|
||||
*/
|
||||
import { filtereAlteBriefe } from '../aufbewahrung';
|
||||
import { BriefEintrag } from '../../types';
|
||||
|
||||
const JETZT = new Date('2026-07-15T12:00:00Z').getTime();
|
||||
const TAG = 86400000;
|
||||
|
||||
function brief(id: string, alterInTagen: number): BriefEintrag {
|
||||
return {
|
||||
id,
|
||||
erstelltAm: new Date(JETZT - alterInTagen * TAG).toISOString(),
|
||||
analyse: {
|
||||
brieftyp: 'Test',
|
||||
absender: 'Testamt',
|
||||
kernaussage: '',
|
||||
erklaerung_einfach: '',
|
||||
fachbegriffe: [],
|
||||
frist: null,
|
||||
termin: null,
|
||||
checkliste: [],
|
||||
antwort_noetig: false,
|
||||
antwort_optionen: [],
|
||||
},
|
||||
uebersetzungen: {},
|
||||
};
|
||||
}
|
||||
|
||||
describe('filtereAlteBriefe', () => {
|
||||
it('0 Tage = nie löschen, alles bleibt', () => {
|
||||
const briefe = [brief('a', 1), brief('b', 500)];
|
||||
expect(filtereAlteBriefe(briefe, 0, JETZT)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('löscht Briefe, die älter als die Frist sind', () => {
|
||||
const briefe = [brief('neu', 5), brief('alt', 40)];
|
||||
const ergebnis = filtereAlteBriefe(briefe, 30, JETZT);
|
||||
expect(ergebnis.map((b) => b.id)).toEqual(['neu']);
|
||||
});
|
||||
|
||||
it('behält Briefe exakt am Grenzwert (30 Tage alt bei 30-Tage-Frist)', () => {
|
||||
const briefe = [brief('grenze', 30)];
|
||||
expect(filtereAlteBriefe(briefe, 30, JETZT)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('löscht bei 90-Tage-Frist erst nach 90 Tagen', () => {
|
||||
const briefe = [brief('a', 89), brief('b', 91)];
|
||||
expect(filtereAlteBriefe(briefe, 90, JETZT).map((b) => b.id)).toEqual(['a']);
|
||||
});
|
||||
|
||||
it('behält Briefe mit kaputtem Datum (lieber zu viel als zu wenig)', () => {
|
||||
const kaputt = { ...brief('kaputt', 0), erstelltAm: 'kein-datum' };
|
||||
expect(filtereAlteBriefe([kaputt], 30, JETZT)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('leeres Archiv bleibt leer', () => {
|
||||
expect(filtereAlteBriefe([], 30, JETZT)).toEqual([]);
|
||||
});
|
||||
});
|
||||
42
src/utils/__tests__/utf8.test.ts
Normal file
42
src/utils/__tests__/utf8.test.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Tests für die UTF-8-Helfer — kritisch für die Archiv-Verschlüsselung:
|
||||
* Ein Kodierungsfehler würde gespeicherte Briefe unlesbar machen.
|
||||
*/
|
||||
import { utf8Kodieren, utf8Dekodieren } from '../utf8';
|
||||
|
||||
describe('utf8Kodieren / utf8Dekodieren', () => {
|
||||
const rundreise = (text: string) => utf8Dekodieren(utf8Kodieren(text));
|
||||
|
||||
it('einfaches ASCII', () => {
|
||||
expect(rundreise('Hallo Welt 123')).toBe('Hallo Welt 123');
|
||||
});
|
||||
|
||||
it('deutsche Umlaute und ß', () => {
|
||||
expect(rundreise('Bescheid über Bürgergeld gemäß § 32 — Änderung')).toBe(
|
||||
'Bescheid über Bürgergeld gemäß § 32 — Änderung'
|
||||
);
|
||||
});
|
||||
|
||||
it('arabisch, ukrainisch, türkisch (Übersetzungssprachen)', () => {
|
||||
const text = 'العربية · Українська · Türkçe İĞÜŞÖÇ';
|
||||
expect(rundreise(text)).toBe(text);
|
||||
});
|
||||
|
||||
it('Emoji (Surrogate-Paare)', () => {
|
||||
expect(rundreise('Ampel: 🔴🟡🟢 📬')).toBe('Ampel: 🔴🟡🟢 📬');
|
||||
});
|
||||
|
||||
it('JSON-Sonderzeichen', () => {
|
||||
const json = JSON.stringify({ a: 'Zeile\nUmbruch "Anführung" \\ Slash' });
|
||||
expect(rundreise(json)).toBe(json);
|
||||
});
|
||||
|
||||
it('leerer String', () => {
|
||||
expect(rundreise('')).toBe('');
|
||||
});
|
||||
|
||||
it('kodiert Umlaute als Mehrbyte-Sequenzen (echtes UTF-8)', () => {
|
||||
// "ü" ist in UTF-8 die Bytefolge 0xC3 0xBC
|
||||
expect(Array.from(utf8Kodieren('ü'))).toEqual([0xc3, 0xbc]);
|
||||
});
|
||||
});
|
||||
27
src/utils/aufbewahrung.ts
Normal file
27
src/utils/aufbewahrung.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Auto-Löschen alter Briefe (Datensparsamkeit, DSGVO-Grundsatz).
|
||||
* Reine Funktion — die Anwendung passiert beim App-Start im Store.
|
||||
*/
|
||||
import { BriefEintrag } from '../types';
|
||||
|
||||
/**
|
||||
* Filtert Briefe heraus, die älter als die Aufbewahrungsdauer sind.
|
||||
*
|
||||
* @param briefe - das aktuelle Archiv
|
||||
* @param aufbewahrungTage - 0 = nie löschen (alles behalten)
|
||||
* @param jetztMs - aktueller Zeitpunkt (injizierbar für Tests)
|
||||
*/
|
||||
export function filtereAlteBriefe(
|
||||
briefe: BriefEintrag[],
|
||||
aufbewahrungTage: number,
|
||||
jetztMs: number = Date.now()
|
||||
): BriefEintrag[] {
|
||||
if (aufbewahrungTage <= 0) return briefe;
|
||||
const grenzeMs = jetztMs - aufbewahrungTage * 86400000;
|
||||
return briefe.filter((brief) => {
|
||||
const erstellt = new Date(brief.erstelltAm).getTime();
|
||||
// Unlesbare Datumswerte lieber behalten als versehentlich löschen
|
||||
if (!Number.isFinite(erstellt)) return true;
|
||||
return erstellt >= grenzeMs;
|
||||
});
|
||||
}
|
||||
33
src/utils/utf8.ts
Normal file
33
src/utils/utf8.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* UTF-8-Kodierung ohne Plattform-Abhängigkeiten.
|
||||
*
|
||||
* Warum selbst gebaut: expo-crypto akzeptiert Klartext nur als Uint8Array
|
||||
* (oder base64-String), und TextEncoder/TextDecoder sind in Hermes nicht
|
||||
* zuverlässig über alle Versionen verfügbar. Der encodeURIComponent-Trick
|
||||
* ist korrekt für alle Unicode-Zeichen (Umlaute, Arabisch, Emoji —
|
||||
* inklusive Surrogate-Paaren) und in Jest testbar.
|
||||
*/
|
||||
|
||||
/** JavaScript-String -> UTF-8-Bytes. */
|
||||
export function utf8Kodieren(text: string): Uint8Array {
|
||||
const prozentKodiert = encodeURIComponent(text);
|
||||
const bytes: number[] = [];
|
||||
for (let i = 0; i < prozentKodiert.length; i++) {
|
||||
if (prozentKodiert[i] === '%') {
|
||||
bytes.push(parseInt(prozentKodiert.slice(i + 1, i + 3), 16));
|
||||
i += 2;
|
||||
} else {
|
||||
bytes.push(prozentKodiert.charCodeAt(i));
|
||||
}
|
||||
}
|
||||
return new Uint8Array(bytes);
|
||||
}
|
||||
|
||||
/** UTF-8-Bytes -> JavaScript-String. Wirft bei ungültigem UTF-8 (URIError). */
|
||||
export function utf8Dekodieren(bytes: Uint8Array): string {
|
||||
let prozentKodiert = '';
|
||||
for (const byte of bytes) {
|
||||
prozentKodiert += '%' + byte.toString(16).padStart(2, '0');
|
||||
}
|
||||
return decodeURIComponent(prozentKodiert);
|
||||
}
|
||||
122
store/app-store-texte.md
Normal file
122
store/app-store-texte.md
Normal file
@@ -0,0 +1,122 @@
|
||||
# App-Store-Texte — BehördenKlar (iOS)
|
||||
|
||||
Vorbereitet zum Einfügen in App Store Connect, sobald das Apple-Developer-Konto
|
||||
da ist. Zeichenlimits sind von Apple vorgegeben und hier eingehalten.
|
||||
|
||||
---
|
||||
|
||||
## App-Name (max. 30 Zeichen)
|
||||
|
||||
```
|
||||
BehördenKlar – Brief-Hilfe
|
||||
```
|
||||
*(26 Zeichen ✓ — Alternative: „BehördenKlar" pur; der Untertitel trägt dann den Slogan)*
|
||||
|
||||
## Untertitel (max. 30 Zeichen)
|
||||
|
||||
```
|
||||
Amtsdeutsch einfach erklärt
|
||||
```
|
||||
|
||||
## Kategorie
|
||||
|
||||
- Primär: **Dienstprogramme** (Utilities)
|
||||
- Sekundär: **Produktivität**
|
||||
|
||||
## Keywords (max. 100 Zeichen, kommagetrennt, ohne Leerzeichen)
|
||||
|
||||
```
|
||||
behörde,amt,brief,bescheid,jobcenter,widerspruch,frist,amtsdeutsch,einfache sprache,übersetzen
|
||||
```
|
||||
|
||||
## Werbetext / Promo-Text (max. 170 Zeichen, jederzeit änderbar)
|
||||
|
||||
```
|
||||
Brief vom Amt? Einfach fotografieren – BehördenKlar erklärt ihn in einfacher Sprache, findet Fristen und hilft beim Antworten. In 10 Sprachen.
|
||||
```
|
||||
|
||||
## Beschreibung (max. 4000 Zeichen)
|
||||
|
||||
```
|
||||
Ein Brief vom Amt – und nichts verstanden? Damit sind Sie nicht allein. BehördenKlar übersetzt Amtsdeutsch in einfache, klare Sprache.
|
||||
|
||||
SO FUNKTIONIERT ES
|
||||
Fotografieren Sie den Brief. Nach weniger als einer Minute wissen Sie:
|
||||
|
||||
• Was will das Amt von mir? – in 2-3 einfachen Sätzen
|
||||
• Was muss ich jetzt tun? – als klare Checkliste
|
||||
• Bis wann? – alle Fristen und Termine auf einen Blick
|
||||
|
||||
DIE AMPEL ZEIGT, WIE DRINGEND ES IST
|
||||
🔴 Rot: Frist in weniger als 7 Tagen – jetzt handeln
|
||||
🟡 Gelb: bald erledigen
|
||||
🟢 Grün: keine Frist, nur zur Information
|
||||
|
||||
VERSTEHEN IN IHRER SPRACHE
|
||||
Alle Erklärungen lassen sich in 9 weitere Sprachen übersetzen: Türkisch, Arabisch, Englisch, Russisch, Ukrainisch, Französisch, Farsi, Rumänisch und Polnisch. Die deutschen Fachbegriffe bleiben stehen, damit Sie sie im Brief wiederfinden.
|
||||
|
||||
ANTWORTEN LEICHT GEMACHT
|
||||
BehördenKlar erstellt Ihnen einen fertigen Antwort-Entwurf – zum Beispiel um einen Termin zu bestätigen oder zu verschieben, Unterlagen nachzureichen oder Widerspruch einzulegen. Sie können den Text frei bearbeiten und als PDF teilen oder drucken.
|
||||
|
||||
NICHTS MEHR VERPASSEN
|
||||
• Termine mit einem Tipp in Ihren Kalender übernehmen
|
||||
• Automatische Erinnerungen vor jeder Frist
|
||||
• Alle gescannten Briefe übersichtlich im Archiv
|
||||
|
||||
IHRE DATEN GEHÖREN IHNEN
|
||||
• Alle Ergebnisse werden nur auf Ihrem Gerät gespeichert
|
||||
• Kein Konto, keine Registrierung nötig
|
||||
• Mit einem Tipp alles löschen
|
||||
|
||||
AUSSERDEM
|
||||
• Behörden-Glossar: die wichtigsten Amtsbegriffe einfach erklärt – auch offline
|
||||
• Vorlese-Funktion für alle Erklärungen
|
||||
• Große Schrift und große Tasten – einfach zu bedienen
|
||||
|
||||
3 Brief-Analysen sind kostenlos. Danach analysieren Sie mit dem Abo unbegrenzt weiter.
|
||||
|
||||
Wichtig: BehördenKlar erklärt Briefe verständlich, ersetzt aber keine Rechtsberatung. Bei rechtlich wichtigen Entscheidungen wenden Sie sich bitte an eine Beratungsstelle oder eine Anwältin/einen Anwalt.
|
||||
```
|
||||
|
||||
## Screenshot-Plan (6,7"-iPhone Pflicht, je 1290×2796 px)
|
||||
|
||||
1. **Hero:** Scan-Screen mit Slogan „Brief fotografieren – sofort verstehen"
|
||||
2. **Analyse:** Ergebnis mit Kernaussage + roter Ampel „Frist in 5 Tagen"
|
||||
3. **Checkliste:** „Das müssen Sie tun" mit Häkchen-Liste
|
||||
4. **Übersetzung:** dieselbe Analyse auf Türkisch oder Arabisch
|
||||
5. **Antwort:** fertiger Antwort-Entwurf mit „Als PDF teilen"-Button
|
||||
6. **Archiv:** Briefliste mit Ampeln
|
||||
|
||||
*Tipp: Screenshots im Simulator (iPhone 17 Pro Max) mit dem Testbrief erstellen —
|
||||
Cmd+S speichert einen Screenshot in der passenden Auflösung.*
|
||||
|
||||
## Altersfreigabe
|
||||
|
||||
4+ (keine bedenklichen Inhalte)
|
||||
|
||||
## App-Review-Hinweise (Feld „Notes" bei der Einreichung)
|
||||
|
||||
```
|
||||
Die App analysiert Fotos von deutschen Behördenbriefen mit einem KI-Dienst
|
||||
(Anthropic Claude) über unseren eigenen Backend-Server. Zum Testen:
|
||||
Ein Beispiel-Behördenbrief zum Abfotografieren liegt unter [URL ZU TESTBRIEF
|
||||
EINFÜGEN] bereit, oder nutzen Sie einen beliebigen deutschen Behördenbrief.
|
||||
Vor dem ersten Scan erscheint eine Datenschutz-Einwilligung. 3 Analysen sind
|
||||
ohne Kauf möglich.
|
||||
```
|
||||
|
||||
## URLs (live, für die Formularfelder in App Store Connect)
|
||||
|
||||
- **Support-URL:** https://behoerdenklar.pages.dev
|
||||
- **Datenschutz-URL:** https://behoerdenklar.pages.dev/datenschutz
|
||||
- Kontakt-E-Mail: behoerdenbriefhelfer@gmail.com
|
||||
- Quelldateien: `webseite/` — neu deployen mit
|
||||
`npx wrangler pages deploy ../webseite --project-name behoerdenklar` (aus proxy/)
|
||||
|
||||
## Noch offen (vor Einreichung)
|
||||
|
||||
- [ ] „App-Datenschutz"-Fragebogen in App Store Connect ausfüllen
|
||||
(Datenerfassung: Fotos → werden verarbeitet, nicht mit Identität verknüpft,
|
||||
kein Tracking)
|
||||
- [ ] Screenshots erstellen (siehe Plan oben)
|
||||
```
|
||||
@@ -1,6 +1,8 @@
|
||||
{
|
||||
"extends": "expo/tsconfig.base",
|
||||
"compilerOptions": {
|
||||
"strict": true
|
||||
}
|
||||
"strict": true,
|
||||
"types": ["jest"]
|
||||
},
|
||||
"exclude": ["proxy", "node_modules"]
|
||||
}
|
||||
|
||||
231
webseite/ar.html
Normal file
231
webseite/ar.html
Normal file
@@ -0,0 +1,231 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ar" dir="rtl">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>BehördenKlar – افهم الرسائل الرسمية الألمانية</title>
|
||||
<meta name="description" content="BehördenKlar يشرح الرسائل الرسمية الألمانية بكلمات بسيطة — بالعربية. صوّر الرسالة، افهمها، وردّ بشكل صحيح. قريباً على iPhone.">
|
||||
<link rel="alternate" hreflang="de" href="https://behoerdenklar.pages.dev/">
|
||||
<link rel="alternate" hreflang="tr" href="https://behoerdenklar.pages.dev/tr">
|
||||
<link rel="alternate" hreflang="ar" href="https://behoerdenklar.pages.dev/ar">
|
||||
<link rel="alternate" hreflang="en" href="https://behoerdenklar.pages.dev/en">
|
||||
<link rel="alternate" hreflang="uk" href="https://behoerdenklar.pages.dev/uk">
|
||||
<link rel="alternate" hreflang="ru" href="https://behoerdenklar.pages.dev/ru">
|
||||
<link rel="alternate" hreflang="pl" href="https://behoerdenklar.pages.dev/pl">
|
||||
<style>
|
||||
:root {
|
||||
--papier: #faf7f0; --papier-dunkel: #f1ecdf; --tinte: #21211d;
|
||||
--grau: #6b675e; --amtsblau: #1d4e89; --stempelrot: #b3372b;
|
||||
--marker: #ffe66b; --gruen: #2e7d4f; color-scheme: light;
|
||||
}
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
html { scroll-behavior: smooth; }
|
||||
body {
|
||||
font-family: Georgia, 'Times New Roman', serif;
|
||||
background:
|
||||
repeating-linear-gradient(0deg, transparent 0 34px, rgba(33,33,29,0.025) 34px 35px),
|
||||
var(--papier);
|
||||
color: var(--tinte); line-height: 1.7; font-size: 17px;
|
||||
}
|
||||
.mono { font-family: 'Courier New', ui-monospace, monospace; letter-spacing: 0.02em; }
|
||||
.wrap { max-width: 720px; margin: 0 auto; padding: 0 20px 72px; }
|
||||
.briefkopf {
|
||||
display: flex; justify-content: space-between; align-items: baseline;
|
||||
padding: 20px 0 12px; border-bottom: 2px solid var(--tinte); font-size: 14px;
|
||||
}
|
||||
.briefkopf .absender { font-weight: 700; font-size: 16px; }
|
||||
.briefkopf .az { color: var(--grau); font-size: 13px; }
|
||||
.sprachen {
|
||||
padding: 12px 0 0; font-size: 15px; color: var(--grau);
|
||||
display: flex; flex-wrap: wrap; gap: 6px 14px; align-items: center;
|
||||
}
|
||||
.sprachen a { color: var(--amtsblau); text-decoration: none; border-bottom: 1px dotted var(--amtsblau); }
|
||||
.sprachen .aktiv { font-weight: 700; color: var(--tinte); border-bottom: none; }
|
||||
header { padding: 44px 0 8px; }
|
||||
h1 { font-size: clamp(32px, 6.5vw, 48px); line-height: 1.14; font-weight: 700; }
|
||||
.markiert {
|
||||
background: linear-gradient(104deg, transparent 2%, var(--marker) 4%, var(--marker) 96%, transparent 98%);
|
||||
padding: 0 6px; box-decoration-break: clone; -webkit-box-decoration-break: clone;
|
||||
}
|
||||
.intro { margin-top: 24px; font-size: 19px; max-width: 580px; }
|
||||
.hero-fuss { display: flex; align-items: center; gap: 22px; flex-wrap: wrap; margin-top: 28px; }
|
||||
.cta {
|
||||
display: inline-block; padding: 17px 30px; background: var(--amtsblau);
|
||||
color: #fff; text-decoration: none; font-size: 18px; font-weight: 700;
|
||||
border: 2px solid var(--tinte); box-shadow: 5px 5px 0 var(--tinte);
|
||||
transition: transform 0.12s ease, box-shadow 0.12s ease;
|
||||
}
|
||||
.cta:hover { transform: translate(2px, 2px); box-shadow: 3px 3px 0 var(--tinte); }
|
||||
.stempel {
|
||||
display: inline-block; padding: 7px 14px; border: 2.5px solid var(--stempelrot);
|
||||
color: var(--stempelrot); font-family: 'Courier New', ui-monospace, monospace;
|
||||
font-weight: 700; font-size: 14px; letter-spacing: 0.08em;
|
||||
text-transform: uppercase; transform: rotate(-4deg); opacity: 0.9;
|
||||
}
|
||||
section { margin-top: 60px; }
|
||||
h2 {
|
||||
font-size: 15px; text-transform: uppercase; letter-spacing: 0.14em;
|
||||
color: var(--grau); font-weight: 400; margin-bottom: 20px;
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
}
|
||||
h2::after { content: ''; flex: 1; border-top: 1px solid rgba(33,33,29,0.25); }
|
||||
.verwandlung { display: grid; gap: 14px; }
|
||||
.brief-karte {
|
||||
background: #fff; border: 1px solid rgba(33,33,29,0.28);
|
||||
padding: 22px 24px; position: relative;
|
||||
box-shadow: 4px 5px 0 rgba(33,33,29,0.08);
|
||||
}
|
||||
.brief-karte.amtsdeutsch { transform: rotate(-0.7deg); background: var(--papier-dunkel); }
|
||||
.brief-karte.klartext { transform: rotate(0.5deg); }
|
||||
.karten-label {
|
||||
position: absolute; top: -12px; left: 16px;
|
||||
font-family: 'Courier New', ui-monospace, monospace; font-size: 12px;
|
||||
text-transform: uppercase; letter-spacing: 0.1em;
|
||||
background: var(--tinte); color: var(--papier); padding: 3px 10px;
|
||||
}
|
||||
[dir="rtl"] .karten-label { left: auto; right: 16px; }
|
||||
.brief-karte.klartext .karten-label { background: var(--gruen); }
|
||||
.amtsdeutsch p {
|
||||
font-family: 'Courier New', ui-monospace, monospace;
|
||||
font-size: 14.5px; color: #45423b; line-height: 1.75;
|
||||
}
|
||||
.klartext p { font-size: 18px; }
|
||||
.pfeil { text-align: center; font-size: 15px; color: var(--grau); font-family: 'Courier New', ui-monospace, monospace; }
|
||||
.hinweis { margin-top: 10px; font-size: 14.5px; color: var(--grau); }
|
||||
.schritte { counter-reset: schritt; }
|
||||
.schritt {
|
||||
counter-increment: schritt; display: flex; gap: 20px; padding: 20px 4px;
|
||||
border-bottom: 1px solid rgba(33,33,29,0.15); align-items: baseline;
|
||||
}
|
||||
.schritt:last-child { border-bottom: none; }
|
||||
.schritt::before {
|
||||
content: counter(schritt, decimal-leading-zero);
|
||||
font-family: 'Courier New', ui-monospace, monospace;
|
||||
font-size: 30px; font-weight: 700; color: var(--amtsblau);
|
||||
flex-shrink: 0; width: 52px;
|
||||
}
|
||||
.schritt strong { font-size: 19px; display: block; margin-bottom: 2px; }
|
||||
.schritt span { color: var(--grau); font-size: 16px; }
|
||||
.antrag {
|
||||
background: #fff; border: 2px solid var(--tinte);
|
||||
box-shadow: 7px 8px 0 rgba(33,33,29,0.9);
|
||||
padding: 30px 28px; position: relative; margin-top: 8px;
|
||||
}
|
||||
.antrag .formular-kopf {
|
||||
font-family: 'Courier New', ui-monospace, monospace; font-size: 13px;
|
||||
text-transform: uppercase; letter-spacing: 0.1em; color: var(--grau); margin-bottom: 6px;
|
||||
}
|
||||
.antrag h3 { font-size: 26px; line-height: 1.25; }
|
||||
.antrag p { margin-top: 12px; max-width: 540px; }
|
||||
.bonus {
|
||||
margin: 18px 0; padding: 14px 16px; font-size: 17px;
|
||||
background: linear-gradient(104deg, transparent 1%, var(--marker) 3%, var(--marker) 97%, transparent 99%);
|
||||
}
|
||||
.antrag .klein { font-size: 13.5px; color: var(--grau); margin-top: 14px; }
|
||||
.antrag .klein a { color: var(--grau); }
|
||||
a { color: var(--amtsblau); }
|
||||
footer {
|
||||
margin-top: 56px; padding-top: 14px; border-top: 2px solid var(--tinte);
|
||||
font-size: 14.5px; color: var(--grau);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<div class="briefkopf">
|
||||
<span class="absender">📬 BehördenKlar</span>
|
||||
<span class="az mono">Az: KLAR-01/2026</span>
|
||||
</div>
|
||||
<nav class="sprachen" aria-label="اختر اللغة">
|
||||
🌐
|
||||
<a href="index.html" lang="de">Deutsch</a>
|
||||
<a href="tr.html" lang="tr">Türkçe</a>
|
||||
<span class="aktiv">العربية</span>
|
||||
<a href="en.html" lang="en">English</a>
|
||||
<a href="uk.html" lang="uk">Українська</a>
|
||||
<a href="ru.html" lang="ru">Русский</a>
|
||||
<a href="pl.html" lang="pl">Polski</a>
|
||||
</nav>
|
||||
|
||||
<header>
|
||||
<h1>رسالة من دائرة ألمانية؟<br><span class="markiert">الآن فهمها سهل.</span></h1>
|
||||
<p class="intro">
|
||||
صوّر الرسالة بهاتفك. يشرح لك BehördenKlar الرسالة بألمانية بسيطة —
|
||||
<strong>وبالعربية أيضاً</strong>. مع كل المواعيد وقائمة بما يجب أن
|
||||
تفعله.
|
||||
</p>
|
||||
<div class="hero-fuss">
|
||||
<a class="cta" href="#anmelden">سجّل في قائمة الانتظار ←</a>
|
||||
<span class="stempel">قريباً على iPhone</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section>
|
||||
<h2>§ 1 التحويل</h2>
|
||||
<div class="verwandlung">
|
||||
<div class="brief-karte amtsdeutsch">
|
||||
<span class="karten-label">قبل · الرسالة الأصلية (بالألمانية)</span>
|
||||
<p dir="ltr" lang="de">
|
||||
„Gemäß § 32 SGB II wird bei Nichterscheinen ohne wichtigen Grund
|
||||
eine Minderung des maßgebenden Regelbedarfs in Höhe von 10 vom
|
||||
Hundert festgestellt."
|
||||
</p>
|
||||
</div>
|
||||
<p class="pfeil">— 📷 بعد صورة واحدة —</p>
|
||||
<div class="brief-karte klartext">
|
||||
<span class="karten-label">بعد · BehördenKlar بالعربية</span>
|
||||
<p>
|
||||
إذا لم تذهب إلى الموعد بدون سبب مهم، ستحصل على نقود أقل بنسبة
|
||||
١٠٪ لمدة شهر واحد. اذهب إلى الموعد — أو ألغِه في الوقت المناسب.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="hinweis">
|
||||
يشرح التطبيق الرسائل بألمانية بسيطة ويترجمها إلى: العربية، التركية،
|
||||
الإنجليزية، الروسية، الأوكرانية، الفرنسية، الفارسية، الرومانية
|
||||
والبولندية.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>§ 2 كيف يعمل</h2>
|
||||
<div class="schritte">
|
||||
<div class="schritt"><div><strong>صوّر الرسالة</strong> <span>أو ارفع ملف PDF.</span></div></div>
|
||||
<div class="schritt"><div><strong>افهمها</strong> <span>كلمات بسيطة، وبلغتك. إشارة ضوئية تبيّن لك: هل الأمر مستعجل؟</span></div></div>
|
||||
<div class="schritt"><div><strong>ردّ بشكل صحيح</strong> <span>قائمة مهام، تذكير بالموعد النهائي، وردّ جاهز.</span></div></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="anmelden">
|
||||
<h2>§ 3 الوصول المبكر</h2>
|
||||
<div class="antrag">
|
||||
<p class="formular-kopf">نموذج F-01 · قائمة الانتظار</p>
|
||||
<h3>كن من الأوائل</h3>
|
||||
<p>
|
||||
التطبيق قادم قريباً لهواتف iPhone. سجّل الآن — نرسل لك رسالة
|
||||
<strong>واحدة</strong> عندما يصبح التطبيق جاهزاً. بدون دعاية.
|
||||
</p>
|
||||
<p class="bonus">
|
||||
🎁 <strong>هديتك:</strong> حلّل <strong>٥ رسائل مجاناً</strong>
|
||||
بدلاً من ٣. تحصل على الرمز عبر البريد الإلكتروني.
|
||||
</p>
|
||||
<a class="cta" href="mailto:behoerdenbriefhelfer@gmail.com?subject=Anmeldung%20Warteliste%20(Arabisch)&body=Hallo%2C%20bitte%20informiert%20mich%2C%20wenn%20Beh%C3%B6rdenKlar%20verf%C3%BCgbar%20ist.%20%2F%20Please%20notify%20me%20when%20Beh%C3%B6rdenKlar%20is%20available.">
|
||||
📧 سجّل عبر البريد الإلكتروني
|
||||
</a>
|
||||
<p class="klein">
|
||||
نحفظ عنوان بريدك الإلكتروني فقط لهذه الرسالة الواحدة. يمكنك إلغاء
|
||||
التسجيل في أي وقت. <a href="datenschutz.html">سياسة الخصوصية (بالألمانية)</a>.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer>
|
||||
أسئلة؟ 📧 <a href="mailto:behoerdenbriefhelfer@gmail.com">behoerdenbriefhelfer@gmail.com</a>
|
||||
· <a href="impressum.html">Impressum (بالألمانية)</a>
|
||||
· <a href="datenschutz.html">سياسة الخصوصية (بالألمانية)</a>
|
||||
</footer>
|
||||
</div>
|
||||
<!-- Cloudflare Web Analytics: cookielose Reichweitenmessung, keine Nutzer-Profile -->
|
||||
<script type='module' src='https://static.cloudflareinsights.com/beacon.min.js' data-cf-beacon='{"token": "218273478b5845c6bc821588d7d823db"}'></script>
|
||||
</body>
|
||||
</html>
|
||||
180
webseite/datenschutz.html
Normal file
180
webseite/datenschutz.html
Normal file
@@ -0,0 +1,180 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Datenschutzerklärung – BehördenKlar</title>
|
||||
<meta name="robots" content="noindex">
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
color: #1a1a2e; background: #f4f8fb; line-height: 1.7;
|
||||
}
|
||||
.wrap { max-width: 640px; margin: 0 auto; padding: 32px 20px 64px; }
|
||||
h1 { font-size: 28px; color: #144d8c; margin: 24px 0 4px; }
|
||||
.stand { color: #4a5568; margin-bottom: 16px; }
|
||||
h2 { font-size: 20px; color: #144d8c; margin: 24px 0 8px; }
|
||||
h3 { font-size: 17px; color: #1a1a2e; margin: 16px 0 6px; }
|
||||
p, li { margin-bottom: 8px; }
|
||||
ul { padding-left: 24px; }
|
||||
.karte {
|
||||
background: #fff; border-radius: 16px; padding: 24px;
|
||||
box-shadow: 0 2px 8px rgba(20, 77, 140, 0.08);
|
||||
}
|
||||
a { color: #1a6bc4; }
|
||||
.zurueck { display: inline-block; margin-top: 24px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<h1>Datenschutzerklärung</h1>
|
||||
<p class="stand">für die App „BehördenKlar" — Stand: 14.07.2026</p>
|
||||
|
||||
<div class="karte">
|
||||
<h2>1. Verantwortlicher</h2>
|
||||
<p>
|
||||
Justin Klein<br>
|
||||
Am Schwimmbad 10<br>
|
||||
67722 Winnweiler<br>
|
||||
E-Mail: <a href="mailto:behoerdenbriefhelfer@gmail.com">behoerdenbriefhelfer@gmail.com</a>
|
||||
</p>
|
||||
|
||||
<h2>2. Was macht die App?</h2>
|
||||
<p>
|
||||
BehördenKlar hilft Ihnen, Behördenbriefe zu verstehen: Sie fotografieren
|
||||
einen Brief, und eine Künstliche Intelligenz (KI) erklärt den Inhalt in
|
||||
einfacher Sprache, findet Fristen und Termine und hilft beim Antworten.
|
||||
</p>
|
||||
|
||||
<h2>3. Welche Daten werden verarbeitet?</h2>
|
||||
|
||||
<h3>a) Brieffotos und PDF-Dateien (nur mit Ihrer Einwilligung)</h3>
|
||||
<p>Wenn Sie einen Brief scannen, wird das Foto bzw. die PDF-Datei zur Analyse übertragen an:</p>
|
||||
<ul>
|
||||
<li>
|
||||
<strong>Unseren Server</strong> (Cloudflare Workers, Anbieter:
|
||||
Cloudflare, Inc., USA) — er leitet die Anfrage nur weiter und
|
||||
speichert den Briefinhalt nicht.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Anthropic PBC (USA)</strong> — 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.
|
||||
</li>
|
||||
</ul>
|
||||
<p>
|
||||
<strong>Wichtig:</strong> Behördenbriefe können sehr persönliche und
|
||||
besondere Kategorien von Daten enthalten (z. B. Angaben zu
|
||||
Sozialleistungen, Gesundheit oder finanzieller Situation). Die Analyse
|
||||
erfolgt deshalb nur nach Ihrer ausdrücklichen Einwilligung, die Sie vor
|
||||
dem ersten Scan erteilen (Art. 6 Abs. 1 lit. a, Art. 9 Abs. 2 lit. a
|
||||
DSGVO). Sie können die Einwilligung jederzeit widerrufen, indem Sie in
|
||||
den Einstellungen „Alle Daten löschen" wählen.
|
||||
</p>
|
||||
|
||||
<h3>b) Analyse-Ergebnisse, Übersetzungen, Antwort-Entwürfe</h3>
|
||||
<p>
|
||||
Alle Ergebnisse werden ausschließlich lokal auf Ihrem Gerät gespeichert —
|
||||
zusätzlich verschlüsselt (AES-256-GCM; der Schlüssel liegt im
|
||||
Sicherheitsspeicher Ihres Geräts). Wir betreiben keine Nutzerkonten und
|
||||
keine Cloud-Speicherung Ihrer Briefe. Optional können Sie in den
|
||||
Einstellungen eine App-Sperre (Face ID / Geräte-Code) aktivieren und
|
||||
Briefe nach 30 oder 90 Tagen automatisch löschen lassen.
|
||||
</p>
|
||||
|
||||
<h3>c) Anonyme Geräte-Kennung</h3>
|
||||
<p>
|
||||
Die App erzeugt eine zufällige, anonyme Kennung. Sie enthält keine
|
||||
Angaben über Sie oder Ihr Gerät und dient ausschließlich dazu, ein
|
||||
tägliches Nutzungslimit pro Installation durchzusetzen (Schutz vor
|
||||
Missbrauch, Art. 6 Abs. 1 lit. f DSGVO). Sie wird zusammen mit einem
|
||||
Tageszähler für maximal 48 Stunden auf unserem Server gespeichert.
|
||||
</p>
|
||||
|
||||
<h3>d) Kalender, Erinnerungen, Vorlesen</h3>
|
||||
<p>
|
||||
Kalendereinträge und Frist-Erinnerungen werden nur auf Ihrem Gerät
|
||||
angelegt (mit Ihrer Erlaubnis über die iOS-/Android-Berechtigungen).
|
||||
Die Vorlese-Funktion nutzt die Sprachausgabe Ihres Geräts. Dabei werden
|
||||
keine Daten an uns übertragen.
|
||||
</p>
|
||||
|
||||
<h2>4. Übermittlung in die USA</h2>
|
||||
<p>
|
||||
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).
|
||||
</p>
|
||||
|
||||
<h2>5. Speicherdauer</h2>
|
||||
<ul>
|
||||
<li>Brieffotos bei Anthropic: maximal 30 Tage, danach automatische Löschung</li>
|
||||
<li>Anonyme Geräte-Kennung + Tageszähler auf unserem Server: maximal 48 Stunden</li>
|
||||
<li>Daten auf Ihrem Gerät: bis Sie sie löschen („Alle Daten löschen" in den Einstellungen oder Deinstallation der App)</li>
|
||||
</ul>
|
||||
|
||||
<h2>6. Ihre Rechte</h2>
|
||||
<p>
|
||||
Sie haben nach der DSGVO das Recht auf Auskunft (Art. 15), Berichtigung
|
||||
(Art. 16), Löschung (Art. 17), Einschränkung der Verarbeitung (Art. 18),
|
||||
Datenübertragbarkeit (Art. 20) und Widerspruch (Art. 21). Außerdem
|
||||
können Sie sich bei einer Datenschutz-Aufsichtsbehörde beschweren
|
||||
(Art. 77 DSGVO), z. B. beim Landesbeauftragten für den Datenschutz und
|
||||
die Informationsfreiheit Rheinland-Pfalz.
|
||||
</p>
|
||||
<p>
|
||||
Da wir keine Nutzerkonten führen, liegen die meisten Daten nur auf
|
||||
Ihrem Gerät — dort können Sie sie selbst einsehen und löschen. Für
|
||||
Anfragen zu den serverseitig verarbeiteten Daten wenden Sie sich an:
|
||||
<a href="mailto:behoerdenbriefhelfer@gmail.com">behoerdenbriefhelfer@gmail.com</a>
|
||||
</p>
|
||||
|
||||
<h2>7. Warteliste / Launch-Benachrichtigung (Webseite)</h2>
|
||||
<p>
|
||||
Auf unserer Webseite können Sie sich für die Warteliste anmelden, um
|
||||
benachrichtigt zu werden, wenn die App verfügbar ist. Dabei
|
||||
verarbeiten wir Ihre E-Mail-Adresse auf Grundlage Ihrer Einwilligung
|
||||
(Art. 6 Abs. 1 lit. a DSGVO). Wir verwenden die Adresse ausschließlich
|
||||
für die Launch-Benachrichtigung und Informationen zur App — keine
|
||||
Werbung Dritter, keine Weitergabe. Sie können sich jederzeit abmelden
|
||||
(Link in jeder E-Mail oder formlos an
|
||||
<a href="mailto:behoerdenbriefhelfer@gmail.com">behoerdenbriefhelfer@gmail.com</a>);
|
||||
Ihre Adresse wird dann gelöscht.
|
||||
</p>
|
||||
|
||||
<h2>8. Reichweitenmessung (Webseite)</h2>
|
||||
<p>
|
||||
Auf dieser Webseite nutzen wir Cloudflare Web Analytics (Cloudflare,
|
||||
Inc., USA) — eine datenschutzfreundliche Besucherstatistik
|
||||
<strong>ohne Cookies</strong> und ohne Verfolgung einzelner Personen.
|
||||
Erfasst werden nur zusammengefasste Werte (z. B. Seitenaufrufe,
|
||||
Herkunftsland, verwendeter Browser), aus denen sich keine Person
|
||||
identifizieren lässt. Rechtsgrundlage ist unser berechtigtes Interesse
|
||||
an einer sicheren und nachgefragten Webseite (Art. 6 Abs. 1 lit. f
|
||||
DSGVO).
|
||||
</p>
|
||||
|
||||
<h2>9. Keine Rechtsberatung</h2>
|
||||
<p>
|
||||
BehördenKlar erklärt Briefe verständlich, ersetzt aber keine
|
||||
Rechtsberatung. Bei rechtlich wichtigen Entscheidungen wenden Sie sich
|
||||
an eine Beratungsstelle oder eine Anwältin/einen Anwalt.
|
||||
</p>
|
||||
|
||||
<h2>10. Änderungen</h2>
|
||||
<p>
|
||||
Wir passen diese Datenschutzerklärung an, wenn sich die App oder die
|
||||
Rechtslage ändert. Die aktuelle Fassung finden Sie immer auf dieser
|
||||
Seite.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<a class="zurueck" href="index.html">← Zurück zur Startseite</a>
|
||||
</div>
|
||||
<!-- Cloudflare Web Analytics: cookielose Reichweitenmessung, keine Nutzer-Profile -->
|
||||
<script type='module' src='https://static.cloudflareinsights.com/beacon.min.js' data-cf-beacon='{"token": "218273478b5845c6bc821588d7d823db"}'></script>
|
||||
</body>
|
||||
</html>
|
||||
231
webseite/en.html
Normal file
231
webseite/en.html
Normal file
@@ -0,0 +1,231 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>BehördenKlar – Understand German official letters</title>
|
||||
<meta name="description" content="BehördenKlar explains German official letters in simple words — in English. Take a photo, understand the letter, respond the right way. Coming soon for iPhone.">
|
||||
<link rel="alternate" hreflang="de" href="https://behoerdenklar.pages.dev/">
|
||||
<link rel="alternate" hreflang="tr" href="https://behoerdenklar.pages.dev/tr">
|
||||
<link rel="alternate" hreflang="ar" href="https://behoerdenklar.pages.dev/ar">
|
||||
<link rel="alternate" hreflang="en" href="https://behoerdenklar.pages.dev/en">
|
||||
<link rel="alternate" hreflang="uk" href="https://behoerdenklar.pages.dev/uk">
|
||||
<link rel="alternate" hreflang="ru" href="https://behoerdenklar.pages.dev/ru">
|
||||
<link rel="alternate" hreflang="pl" href="https://behoerdenklar.pages.dev/pl">
|
||||
<style>
|
||||
:root {
|
||||
--papier: #faf7f0; --papier-dunkel: #f1ecdf; --tinte: #21211d;
|
||||
--grau: #6b675e; --amtsblau: #1d4e89; --stempelrot: #b3372b;
|
||||
--marker: #ffe66b; --gruen: #2e7d4f; color-scheme: light;
|
||||
}
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
html { scroll-behavior: smooth; }
|
||||
body {
|
||||
font-family: Georgia, 'Times New Roman', serif;
|
||||
background:
|
||||
repeating-linear-gradient(0deg, transparent 0 34px, rgba(33,33,29,0.025) 34px 35px),
|
||||
var(--papier);
|
||||
color: var(--tinte); line-height: 1.7; font-size: 17px;
|
||||
}
|
||||
.mono { font-family: 'Courier New', ui-monospace, monospace; letter-spacing: 0.02em; }
|
||||
.wrap { max-width: 720px; margin: 0 auto; padding: 0 20px 72px; }
|
||||
.briefkopf {
|
||||
display: flex; justify-content: space-between; align-items: baseline;
|
||||
padding: 20px 0 12px; border-bottom: 2px solid var(--tinte); font-size: 14px;
|
||||
}
|
||||
.briefkopf .absender { font-weight: 700; font-size: 16px; }
|
||||
.briefkopf .az { color: var(--grau); font-size: 13px; }
|
||||
.sprachen {
|
||||
padding: 12px 0 0; font-size: 15px; color: var(--grau);
|
||||
display: flex; flex-wrap: wrap; gap: 6px 14px; align-items: center;
|
||||
}
|
||||
.sprachen a { color: var(--amtsblau); text-decoration: none; border-bottom: 1px dotted var(--amtsblau); }
|
||||
.sprachen .aktiv { font-weight: 700; color: var(--tinte); border-bottom: none; }
|
||||
header { padding: 44px 0 8px; }
|
||||
h1 { font-size: clamp(32px, 6.5vw, 48px); line-height: 1.14; font-weight: 700; }
|
||||
.markiert {
|
||||
background: linear-gradient(104deg, transparent 2%, var(--marker) 4%, var(--marker) 96%, transparent 98%);
|
||||
padding: 0 6px; box-decoration-break: clone; -webkit-box-decoration-break: clone;
|
||||
}
|
||||
.intro { margin-top: 24px; font-size: 19px; max-width: 580px; }
|
||||
.hero-fuss { display: flex; align-items: center; gap: 22px; flex-wrap: wrap; margin-top: 28px; }
|
||||
.cta {
|
||||
display: inline-block; padding: 17px 30px; background: var(--amtsblau);
|
||||
color: #fff; text-decoration: none; font-size: 18px; font-weight: 700;
|
||||
border: 2px solid var(--tinte); box-shadow: 5px 5px 0 var(--tinte);
|
||||
transition: transform 0.12s ease, box-shadow 0.12s ease;
|
||||
}
|
||||
.cta:hover { transform: translate(2px, 2px); box-shadow: 3px 3px 0 var(--tinte); }
|
||||
.stempel {
|
||||
display: inline-block; padding: 7px 14px; border: 2.5px solid var(--stempelrot);
|
||||
color: var(--stempelrot); font-family: 'Courier New', ui-monospace, monospace;
|
||||
font-weight: 700; font-size: 14px; letter-spacing: 0.08em;
|
||||
text-transform: uppercase; transform: rotate(-4deg); opacity: 0.9;
|
||||
}
|
||||
section { margin-top: 60px; }
|
||||
h2 {
|
||||
font-size: 15px; text-transform: uppercase; letter-spacing: 0.14em;
|
||||
color: var(--grau); font-weight: 400; margin-bottom: 20px;
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
}
|
||||
h2::after { content: ''; flex: 1; border-top: 1px solid rgba(33,33,29,0.25); }
|
||||
.verwandlung { display: grid; gap: 14px; }
|
||||
.brief-karte {
|
||||
background: #fff; border: 1px solid rgba(33,33,29,0.28);
|
||||
padding: 22px 24px; position: relative;
|
||||
box-shadow: 4px 5px 0 rgba(33,33,29,0.08);
|
||||
}
|
||||
.brief-karte.amtsdeutsch { transform: rotate(-0.7deg); background: var(--papier-dunkel); }
|
||||
.brief-karte.klartext { transform: rotate(0.5deg); }
|
||||
.karten-label {
|
||||
position: absolute; top: -12px; left: 16px;
|
||||
font-family: 'Courier New', ui-monospace, monospace; font-size: 12px;
|
||||
text-transform: uppercase; letter-spacing: 0.1em;
|
||||
background: var(--tinte); color: var(--papier); padding: 3px 10px;
|
||||
}
|
||||
[dir="rtl"] .karten-label { left: auto; right: 16px; }
|
||||
.brief-karte.klartext .karten-label { background: var(--gruen); }
|
||||
.amtsdeutsch p {
|
||||
font-family: 'Courier New', ui-monospace, monospace;
|
||||
font-size: 14.5px; color: #45423b; line-height: 1.75;
|
||||
}
|
||||
.klartext p { font-size: 18px; }
|
||||
.pfeil { text-align: center; font-size: 15px; color: var(--grau); font-family: 'Courier New', ui-monospace, monospace; }
|
||||
.hinweis { margin-top: 10px; font-size: 14.5px; color: var(--grau); }
|
||||
.schritte { counter-reset: schritt; }
|
||||
.schritt {
|
||||
counter-increment: schritt; display: flex; gap: 20px; padding: 20px 4px;
|
||||
border-bottom: 1px solid rgba(33,33,29,0.15); align-items: baseline;
|
||||
}
|
||||
.schritt:last-child { border-bottom: none; }
|
||||
.schritt::before {
|
||||
content: counter(schritt, decimal-leading-zero);
|
||||
font-family: 'Courier New', ui-monospace, monospace;
|
||||
font-size: 30px; font-weight: 700; color: var(--amtsblau);
|
||||
flex-shrink: 0; width: 52px;
|
||||
}
|
||||
.schritt strong { font-size: 19px; display: block; margin-bottom: 2px; }
|
||||
.schritt span { color: var(--grau); font-size: 16px; }
|
||||
.antrag {
|
||||
background: #fff; border: 2px solid var(--tinte);
|
||||
box-shadow: 7px 8px 0 rgba(33,33,29,0.9);
|
||||
padding: 30px 28px; position: relative; margin-top: 8px;
|
||||
}
|
||||
.antrag .formular-kopf {
|
||||
font-family: 'Courier New', ui-monospace, monospace; font-size: 13px;
|
||||
text-transform: uppercase; letter-spacing: 0.1em; color: var(--grau); margin-bottom: 6px;
|
||||
}
|
||||
.antrag h3 { font-size: 26px; line-height: 1.25; }
|
||||
.antrag p { margin-top: 12px; max-width: 540px; }
|
||||
.bonus {
|
||||
margin: 18px 0; padding: 14px 16px; font-size: 17px;
|
||||
background: linear-gradient(104deg, transparent 1%, var(--marker) 3%, var(--marker) 97%, transparent 99%);
|
||||
}
|
||||
.antrag .klein { font-size: 13.5px; color: var(--grau); margin-top: 14px; }
|
||||
.antrag .klein a { color: var(--grau); }
|
||||
a { color: var(--amtsblau); }
|
||||
footer {
|
||||
margin-top: 56px; padding-top: 14px; border-top: 2px solid var(--tinte);
|
||||
font-size: 14.5px; color: var(--grau);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<div class="briefkopf">
|
||||
<span class="absender">📬 BehördenKlar</span>
|
||||
<span class="az mono">Az: KLAR-01/2026</span>
|
||||
</div>
|
||||
<nav class="sprachen" aria-label="Choose language">
|
||||
🌐
|
||||
<a href="index.html" lang="de">Deutsch</a>
|
||||
<a href="tr.html" lang="tr">Türkçe</a>
|
||||
<a href="ar.html" lang="ar">العربية</a>
|
||||
<span class="aktiv">English</span>
|
||||
<a href="uk.html" lang="uk">Українська</a>
|
||||
<a href="ru.html" lang="ru">Русский</a>
|
||||
<a href="pl.html" lang="pl">Polski</a>
|
||||
</nav>
|
||||
|
||||
<header>
|
||||
<h1>A letter from a German office?<br><span class="markiert">Now easy to understand.</span></h1>
|
||||
<p class="intro">
|
||||
Take a photo of the letter. BehördenKlar explains it in simple German —
|
||||
<strong>and in English</strong>. With all deadlines and a list of what
|
||||
to do.
|
||||
</p>
|
||||
<div class="hero-fuss">
|
||||
<a class="cta" href="#anmelden">Join the waiting list →</a>
|
||||
<span class="stempel">Coming soon for iPhone</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section>
|
||||
<h2>§ 1 The transformation</h2>
|
||||
<div class="verwandlung">
|
||||
<div class="brief-karte amtsdeutsch">
|
||||
<span class="karten-label">Before · Original letter (German)</span>
|
||||
<p>
|
||||
„Gemäß § 32 SGB II wird bei Nichterscheinen ohne wichtigen Grund
|
||||
eine Minderung des maßgebenden Regelbedarfs in Höhe von 10 vom
|
||||
Hundert festgestellt."
|
||||
</p>
|
||||
</div>
|
||||
<p class="pfeil">— 📷 one photo later —</p>
|
||||
<div class="brief-karte klartext">
|
||||
<span class="karten-label">After · BehördenKlar in English</span>
|
||||
<p>
|
||||
If you do not go to the appointment without a good reason, you will
|
||||
get 10% less money for one month. Go to the appointment — or cancel
|
||||
it in time.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="hinweis">
|
||||
The app explains letters in simple German and translates into English,
|
||||
Turkish, Arabic, Russian, Ukrainian, French, Farsi, Romanian and Polish.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>§ 2 How it works</h2>
|
||||
<div class="schritte">
|
||||
<div class="schritt"><div><strong>Take a photo</strong> <span>of the letter — or upload a PDF.</span></div></div>
|
||||
<div class="schritt"><div><strong>Understand it</strong> <span>Simple words, in your language. A traffic light shows how urgent it is.</span></div></div>
|
||||
<div class="schritt"><div><strong>Respond the right way</strong> <span>To-do list, deadline reminder, and a ready-made reply.</span></div></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="anmelden">
|
||||
<h2>§ 3 Early access</h2>
|
||||
<div class="antrag">
|
||||
<p class="formular-kopf">Form F-01 · Waiting list</p>
|
||||
<h3>Be one of the first</h3>
|
||||
<p>
|
||||
The app is coming soon for iPhone. Sign up now — we will send you
|
||||
<strong>one</strong> email when it is ready. No spam.
|
||||
</p>
|
||||
<p class="bonus">
|
||||
🎁 <strong>Your gift:</strong> analyse <strong>5 letters for free</strong>
|
||||
instead of 3. You get the code by email.
|
||||
</p>
|
||||
<a class="cta" href="mailto:behoerdenbriefhelfer@gmail.com?subject=Anmeldung%20Warteliste%20(English)&body=Hallo%2C%20bitte%20informiert%20mich%2C%20wenn%20Beh%C3%B6rdenKlar%20verf%C3%BCgbar%20ist.%20%2F%20Please%20notify%20me%20when%20Beh%C3%B6rdenKlar%20is%20available.">
|
||||
📧 Sign up by email
|
||||
</a>
|
||||
<p class="klein">
|
||||
We only store your email address for this one message. You can
|
||||
unsubscribe at any time. <a href="datenschutz.html">Privacy policy (in German)</a>.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer>
|
||||
Questions? 📧 <a href="mailto:behoerdenbriefhelfer@gmail.com">behoerdenbriefhelfer@gmail.com</a>
|
||||
· <a href="impressum.html">Impressum (German)</a>
|
||||
· <a href="datenschutz.html">Privacy policy (German)</a>
|
||||
</footer>
|
||||
</div>
|
||||
<!-- Cloudflare Web Analytics: cookielose Reichweitenmessung, keine Nutzer-Profile -->
|
||||
<script type='module' src='https://static.cloudflareinsights.com/beacon.min.js' data-cf-beacon='{"token": "218273478b5845c6bc821588d7d823db"}'></script>
|
||||
</body>
|
||||
</html>
|
||||
48
webseite/impressum.html
Normal file
48
webseite/impressum.html
Normal file
@@ -0,0 +1,48 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Impressum – BehördenKlar</title>
|
||||
<meta name="robots" content="noindex">
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
color: #1a1a2e; background: #f4f8fb; line-height: 1.7;
|
||||
}
|
||||
.wrap { max-width: 640px; margin: 0 auto; padding: 32px 20px 64px; }
|
||||
h1 { font-size: 28px; color: #144d8c; margin: 24px 0 16px; }
|
||||
.karte {
|
||||
background: #fff; border-radius: 16px; padding: 24px;
|
||||
box-shadow: 0 2px 8px rgba(20, 77, 140, 0.08);
|
||||
}
|
||||
a { color: #1a6bc4; }
|
||||
.zurueck { display: inline-block; margin-top: 24px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<h1>Impressum</h1>
|
||||
<div class="karte">
|
||||
<p><strong>Angaben gemäß § 5 DDG:</strong></p>
|
||||
<p style="margin-top: 12px;">
|
||||
Justin Klein<br>
|
||||
Am Schwimmbad 10<br>
|
||||
67722 Winnweiler<br>
|
||||
Deutschland
|
||||
</p>
|
||||
<p style="margin-top: 12px;">
|
||||
E-Mail: <a href="mailto:behoerdenbriefhelfer@gmail.com">behoerdenbriefhelfer@gmail.com</a>
|
||||
</p>
|
||||
<p style="margin-top: 16px;">
|
||||
<strong>Verantwortlich für den Inhalt:</strong><br>
|
||||
Justin Klein (Anschrift wie oben)
|
||||
</p>
|
||||
</div>
|
||||
<a class="zurueck" href="index.html">← Zurück zur Startseite</a>
|
||||
</div>
|
||||
<!-- Cloudflare Web Analytics: cookielose Reichweitenmessung, keine Nutzer-Profile -->
|
||||
<script type='module' src='https://static.cloudflareinsights.com/beacon.min.js' data-cf-beacon='{"token": "218273478b5845c6bc821588d7d823db"}'></script>
|
||||
</body>
|
||||
</html>
|
||||
403
webseite/index.html
Normal file
403
webseite/index.html
Normal file
@@ -0,0 +1,403 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>BehördenKlar – Brief rein. Klartext raus.</title>
|
||||
<meta name="description" content="BehördenKlar übersetzt Behördenbriefe in einfache Sprache. Foto machen, verstehen, richtig reagieren. Bald für iPhone – Warteliste sichert 5 Gratis-Analysen.">
|
||||
<link rel="alternate" hreflang="de" href="https://behoerdenklar.pages.dev/">
|
||||
<link rel="alternate" hreflang="tr" href="https://behoerdenklar.pages.dev/tr">
|
||||
<link rel="alternate" hreflang="ar" href="https://behoerdenklar.pages.dev/ar">
|
||||
<link rel="alternate" hreflang="en" href="https://behoerdenklar.pages.dev/en">
|
||||
<link rel="alternate" hreflang="uk" href="https://behoerdenklar.pages.dev/uk">
|
||||
<link rel="alternate" hreflang="ru" href="https://behoerdenklar.pages.dev/ru">
|
||||
<link rel="alternate" hreflang="pl" href="https://behoerdenklar.pages.dev/pl">
|
||||
<style>
|
||||
:root {
|
||||
--papier: #faf7f0;
|
||||
--papier-dunkel: #f1ecdf;
|
||||
--tinte: #21211d;
|
||||
--grau: #6b675e;
|
||||
--amtsblau: #1d4e89;
|
||||
--stempelrot: #b3372b;
|
||||
--marker: #ffe66b;
|
||||
--gruen: #2e7d4f;
|
||||
color-scheme: light;
|
||||
}
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
html { scroll-behavior: smooth; }
|
||||
body {
|
||||
font-family: Georgia, 'Times New Roman', serif;
|
||||
background:
|
||||
repeating-linear-gradient(0deg, transparent 0 34px, rgba(33, 33, 29, 0.025) 34px 35px),
|
||||
var(--papier);
|
||||
color: var(--tinte);
|
||||
line-height: 1.7;
|
||||
font-size: 17px;
|
||||
}
|
||||
.mono {
|
||||
font-family: 'Courier New', ui-monospace, monospace;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.wrap { max-width: 720px; margin: 0 auto; padding: 0 20px 72px; }
|
||||
|
||||
/* ---------- Briefkopf ---------- */
|
||||
.briefkopf {
|
||||
display: flex; justify-content: space-between; align-items: baseline;
|
||||
padding: 20px 0 12px; border-bottom: 2px solid var(--tinte);
|
||||
font-size: 14px;
|
||||
}
|
||||
.briefkopf .absender { font-weight: 700; font-size: 16px; }
|
||||
.briefkopf .az { color: var(--grau); font-size: 13px; }
|
||||
|
||||
/* Sprachleiste */
|
||||
.sprachen {
|
||||
padding: 12px 0 0; font-size: 15px; color: var(--grau);
|
||||
display: flex; flex-wrap: wrap; gap: 6px 14px; align-items: center;
|
||||
}
|
||||
.sprachen a { color: var(--amtsblau); text-decoration: none; border-bottom: 1px dotted var(--amtsblau); }
|
||||
.sprachen .aktiv { font-weight: 700; color: var(--tinte); border-bottom: none; }
|
||||
|
||||
/* ---------- Hero ---------- */
|
||||
header { padding: 44px 0 8px; }
|
||||
.betreff-label {
|
||||
font-size: 13px; color: var(--grau); text-transform: uppercase;
|
||||
letter-spacing: 0.14em; margin-bottom: 10px;
|
||||
}
|
||||
h1 {
|
||||
font-size: clamp(34px, 7vw, 52px);
|
||||
line-height: 1.12; font-weight: 700; letter-spacing: -0.01em;
|
||||
}
|
||||
.markiert {
|
||||
background: linear-gradient(104deg, transparent 2%, var(--marker) 4%, var(--marker) 96%, transparent 98%);
|
||||
padding: 0 6px; box-decoration-break: clone; -webkit-box-decoration-break: clone;
|
||||
}
|
||||
.anrede { margin-top: 26px; font-size: 19px; max-width: 560px; }
|
||||
.anrede strong { font-weight: 700; }
|
||||
|
||||
.hero-fuss { display: flex; align-items: center; gap: 22px; flex-wrap: wrap; margin-top: 30px; }
|
||||
.cta {
|
||||
display: inline-block; padding: 17px 30px;
|
||||
background: var(--amtsblau); color: #fff; text-decoration: none;
|
||||
font-family: inherit; font-size: 18px; font-weight: 700;
|
||||
border: 2px solid var(--tinte);
|
||||
box-shadow: 5px 5px 0 var(--tinte);
|
||||
transition: transform 0.12s ease, box-shadow 0.12s ease;
|
||||
}
|
||||
.cta:hover { transform: translate(2px, 2px); box-shadow: 3px 3px 0 var(--tinte); }
|
||||
.stempel {
|
||||
display: inline-block; padding: 7px 14px;
|
||||
border: 2.5px solid var(--stempelrot); color: var(--stempelrot);
|
||||
font-family: 'Courier New', ui-monospace, monospace; font-weight: 700;
|
||||
font-size: 14px; letter-spacing: 0.1em; text-transform: uppercase;
|
||||
transform: rotate(-4deg); opacity: 0.9;
|
||||
mask-image: radial-gradient(circle at 30% 60%, black 92%, transparent 99%);
|
||||
}
|
||||
|
||||
/* ---------- Abschnitte ---------- */
|
||||
section { margin-top: 64px; }
|
||||
h2 {
|
||||
font-size: 15px; text-transform: uppercase; letter-spacing: 0.14em;
|
||||
color: var(--grau); font-weight: 400; margin-bottom: 20px;
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
}
|
||||
h2::after { content: ''; flex: 1; border-top: 1px solid rgba(33,33,29,0.25); }
|
||||
h2 .nr { font-family: 'Courier New', ui-monospace, monospace; }
|
||||
|
||||
/* ---------- Verwandlung (Kernstück) ---------- */
|
||||
.verwandlung { display: grid; gap: 14px; }
|
||||
.brief-karte {
|
||||
background: #fff; border: 1px solid rgba(33, 33, 29, 0.28);
|
||||
padding: 22px 24px; position: relative;
|
||||
box-shadow: 4px 5px 0 rgba(33, 33, 29, 0.08);
|
||||
}
|
||||
.brief-karte.amtsdeutsch {
|
||||
transform: rotate(-0.7deg);
|
||||
background: var(--papier-dunkel);
|
||||
}
|
||||
.brief-karte.klartext { transform: rotate(0.5deg); }
|
||||
.karten-label {
|
||||
position: absolute; top: -12px; left: 16px;
|
||||
font-family: 'Courier New', ui-monospace, monospace; font-size: 12px;
|
||||
text-transform: uppercase; letter-spacing: 0.1em;
|
||||
background: var(--tinte); color: var(--papier); padding: 3px 10px;
|
||||
}
|
||||
.brief-karte.klartext .karten-label { background: var(--gruen); }
|
||||
.amtsdeutsch p {
|
||||
font-family: 'Courier New', ui-monospace, monospace;
|
||||
font-size: 14.5px; color: #45423b; line-height: 1.75;
|
||||
}
|
||||
.klartext p { font-size: 18px; }
|
||||
.klartext .ampelzeile {
|
||||
margin-top: 14px; padding-top: 12px;
|
||||
border-top: 1px dashed rgba(33, 33, 29, 0.3);
|
||||
font-size: 16px; font-weight: 700;
|
||||
}
|
||||
.pfeil {
|
||||
text-align: center; font-size: 15px; color: var(--grau);
|
||||
font-family: 'Courier New', ui-monospace, monospace;
|
||||
}
|
||||
.quelle-hinweis { margin-top: 10px; font-size: 13.5px; color: var(--grau); }
|
||||
|
||||
/* ---------- Zahlen ---------- */
|
||||
.zahlen { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
|
||||
@media (max-width: 560px) { .zahlen { grid-template-columns: 1fr; } }
|
||||
.zahl-karte {
|
||||
border: 1px dashed rgba(33, 33, 29, 0.4); padding: 20px 22px;
|
||||
background: rgba(255, 255, 255, 0.55);
|
||||
}
|
||||
.zahl-karte .zahl { font-size: 38px; font-weight: 700; line-height: 1.1; color: var(--amtsblau); }
|
||||
.zahl-karte .erkl { font-size: 15px; color: var(--grau); margin-top: 6px; }
|
||||
.zahl-karte .quelle { font-size: 12px; color: var(--grau); margin-top: 8px; font-family: 'Courier New', ui-monospace, monospace; }
|
||||
|
||||
/* ---------- Schritte ---------- */
|
||||
.schritte { counter-reset: schritt; display: grid; gap: 0; }
|
||||
.schritt {
|
||||
counter-increment: schritt;
|
||||
display: flex; gap: 20px; padding: 20px 4px;
|
||||
border-bottom: 1px solid rgba(33, 33, 29, 0.15);
|
||||
align-items: baseline;
|
||||
}
|
||||
.schritt:last-child { border-bottom: none; }
|
||||
.schritt::before {
|
||||
content: counter(schritt, decimal-leading-zero);
|
||||
font-family: 'Courier New', ui-monospace, monospace;
|
||||
font-size: 30px; font-weight: 700; color: var(--amtsblau);
|
||||
flex-shrink: 0; width: 52px;
|
||||
}
|
||||
.schritt strong { font-size: 19px; display: block; margin-bottom: 2px; }
|
||||
.schritt span { color: var(--grau); font-size: 16px; }
|
||||
|
||||
/* ---------- Können ---------- */
|
||||
.koennen { display: grid; grid-template-columns: 1fr 1fr; gap: 0 32px; }
|
||||
@media (max-width: 560px) { .koennen { grid-template-columns: 1fr; } }
|
||||
.kann {
|
||||
padding: 13px 0; border-bottom: 1px dotted rgba(33, 33, 29, 0.3);
|
||||
font-size: 16.5px;
|
||||
}
|
||||
.kann::before { content: '☑'; color: var(--gruen); font-size: 18px; margin-right: 10px; }
|
||||
|
||||
/* ---------- Antrag / Warteliste ---------- */
|
||||
.antrag {
|
||||
background: #fff; border: 2px solid var(--tinte);
|
||||
box-shadow: 7px 8px 0 rgba(33, 33, 29, 0.9);
|
||||
padding: 30px 28px; position: relative; margin-top: 8px;
|
||||
}
|
||||
.antrag .formular-kopf {
|
||||
font-family: 'Courier New', ui-monospace, monospace;
|
||||
font-size: 13px; text-transform: uppercase; letter-spacing: 0.1em;
|
||||
color: var(--grau); margin-bottom: 6px;
|
||||
}
|
||||
.antrag h3 { font-size: 27px; line-height: 1.25; }
|
||||
.antrag .genehmigt {
|
||||
position: absolute; top: -16px; right: 14px;
|
||||
transform: rotate(6deg);
|
||||
}
|
||||
.antrag p { margin-top: 12px; max-width: 520px; }
|
||||
.bonus-zeile {
|
||||
margin: 18px 0; padding: 14px 16px;
|
||||
background: linear-gradient(104deg, transparent 1%, var(--marker) 3%, var(--marker) 97%, transparent 99%);
|
||||
font-size: 17px;
|
||||
}
|
||||
.antrag .klein { font-size: 13.5px; color: var(--grau); margin-top: 14px; }
|
||||
.antrag .klein a { color: var(--grau); }
|
||||
|
||||
/* ---------- FAQ ---------- */
|
||||
details {
|
||||
border-bottom: 1px solid rgba(33, 33, 29, 0.2);
|
||||
padding: 14px 4px;
|
||||
}
|
||||
summary {
|
||||
font-weight: 700; cursor: pointer; font-size: 17.5px;
|
||||
list-style: none; display: flex; justify-content: space-between; gap: 12px;
|
||||
}
|
||||
summary::-webkit-details-marker { display: none; }
|
||||
summary::after { content: '+'; font-family: 'Courier New', monospace; font-size: 22px; color: var(--amtsblau); }
|
||||
details[open] summary::after { content: '–'; }
|
||||
details p { margin-top: 10px; color: var(--grau); font-size: 16px; max-width: 580px; }
|
||||
details a { color: var(--amtsblau); }
|
||||
|
||||
/* ---------- Fuß ---------- */
|
||||
.grussformel { margin-top: 64px; font-size: 18px; }
|
||||
.grussformel .unterschrift {
|
||||
font-size: 30px; margin: 10px 0 2px;
|
||||
font-style: italic;
|
||||
}
|
||||
.anlage {
|
||||
margin-top: 26px; padding-top: 14px; border-top: 2px solid var(--tinte);
|
||||
font-family: 'Courier New', ui-monospace, monospace; font-size: 13.5px; color: var(--grau);
|
||||
}
|
||||
footer { margin-top: 20px; font-size: 14.5px; color: var(--grau); }
|
||||
footer a { color: var(--grau); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
|
||||
<div class="briefkopf">
|
||||
<span class="absender">📬 BehördenKlar</span>
|
||||
<span class="az mono">Az: KLAR-01/2026 · Anlage: 1 App</span>
|
||||
</div>
|
||||
<nav class="sprachen" aria-label="Sprache wählen">
|
||||
🌐
|
||||
<span class="aktiv">Deutsch</span>
|
||||
<a href="tr.html" lang="tr">Türkçe</a>
|
||||
<a href="ar.html" lang="ar">العربية</a>
|
||||
<a href="en.html" lang="en">English</a>
|
||||
<a href="uk.html" lang="uk">Українська</a>
|
||||
<a href="ru.html" lang="ru">Русский</a>
|
||||
<a href="pl.html" lang="pl">Polski</a>
|
||||
</nav>
|
||||
|
||||
<header>
|
||||
<p class="betreff-label">Betreff</p>
|
||||
<h1>Brief vom Amt.<br><span class="markiert">Endlich verstanden.</span></h1>
|
||||
<p class="anrede">
|
||||
Sehr geehrte Damen und Herren, <strong>ein Brief vom Amt muss nicht
|
||||
schwer sein.</strong> Machen Sie ein Foto. BehördenKlar erklärt den
|
||||
Brief in einfachen Worten. <em>Brief rein. Klartext raus.</em>
|
||||
</p>
|
||||
<div class="hero-fuss">
|
||||
<a class="cta" href="#anmelden">Jetzt anmelden →</a>
|
||||
<span class="stempel">Bald im App Store</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section>
|
||||
<h2><span class="nr">§ 1</span> Vorher und Nachher</h2>
|
||||
<div class="verwandlung">
|
||||
<div class="brief-karte amtsdeutsch">
|
||||
<span class="karten-label">Vorher · Original vom Amt</span>
|
||||
<p>
|
||||
„Gemäß § 32 SGB II wird bei Nichterscheinen ohne wichtigen Grund
|
||||
eine Minderung des maßgebenden Regelbedarfs in Höhe von 10 vom
|
||||
Hundert für die Dauer eines Monats festgestellt."
|
||||
</p>
|
||||
</div>
|
||||
<p class="pfeil">— 📷 ein Foto später —</p>
|
||||
<div class="brief-karte klartext">
|
||||
<span class="karten-label">Nachher · BehördenKlar</span>
|
||||
<p>
|
||||
Wenn Sie ohne guten Grund nicht zum Termin kommen, bekommen Sie
|
||||
einen Monat lang 10 % weniger Geld. Gehen Sie hin — oder sagen
|
||||
Sie rechtzeitig ab.
|
||||
</p>
|
||||
<p class="ampelzeile">🔴 Termin in 5 Tagen · ⏰ Erinnerung ist gestellt</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="quelle-hinweis">
|
||||
Dazu bekommen Sie: eine Liste, was Sie tun müssen — und wenn Sie
|
||||
wollen, eine fertige Antwort.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2><span class="nr">§ 2</span> Sie sind nicht allein</h2>
|
||||
<div class="zahlen">
|
||||
<div class="zahl-karte">
|
||||
<div class="zahl">6,2 Mio.</div>
|
||||
<div class="erkl">Erwachsene in Deutschland haben Probleme mit schweren Texten. Behördenbriefe sind für sie besonders schwer.</div>
|
||||
<div class="quelle">Quelle: LEO-Studie 2018, Uni Hamburg</div>
|
||||
</div>
|
||||
<div class="zahl-karte">
|
||||
<div class="zahl">~ 60 Sek.</div>
|
||||
<div class="erkl">braucht BehördenKlar, um einen Brief zu erklären — in Deutsch oder 9 weiteren Sprachen.</div>
|
||||
<div class="quelle">Türkçe · العربية · English · Українська …</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2><span class="nr">§ 3</span> So funktioniert es</h2>
|
||||
<div class="schritte">
|
||||
<div class="schritt">
|
||||
<div><strong>Brief fotografieren</strong> <span>Mit dem Handy, auch als PDF. Gutes Licht reicht.</span></div>
|
||||
</div>
|
||||
<div class="schritt">
|
||||
<div><strong>Klartext lesen</strong> <span>„Was will das Amt von mir?" — kurz, einfach, ohne schwere Wörter. Die Ampel zeigt: Wie viel Zeit habe ich noch?</span></div>
|
||||
</div>
|
||||
<div class="schritt">
|
||||
<div><strong>Richtig reagieren</strong> <span>Liste abhaken, Erinnerung stellen, fertige Antwort anpassen und abschicken.</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2><span class="nr">§ 4</span> Was die App kann</h2>
|
||||
<div class="koennen">
|
||||
<div class="kann">Ampel: Wie dringend? 🔴🟡🟢</div>
|
||||
<div class="kann">Übersetzung in 9 Sprachen</div>
|
||||
<div class="kann">Fertige Antworten</div>
|
||||
<div class="kann">Erinnerung vor jeder Frist</div>
|
||||
<div class="kann">Termine in den Kalender</div>
|
||||
<div class="kann">Amts-Wörterbuch — auch ohne Internet</div>
|
||||
<div class="kann">Die App kann vorlesen</div>
|
||||
<div class="kann">Kein Konto, keine Anmeldung</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="anmelden">
|
||||
<h2><span class="nr">§ 5</span> Warteliste</h2>
|
||||
<div class="antrag">
|
||||
<span class="stempel genehmigt">Vorab genehmigt ✓</span>
|
||||
<p class="formular-kopf">Formular F-01 · Warteliste</p>
|
||||
<h3>Seien Sie früh dabei</h3>
|
||||
<p>
|
||||
Tragen Sie sich jetzt ein. Wir schicken Ihnen <strong>eine</strong>
|
||||
E-Mail, wenn die App fertig ist. Keine Werbung.
|
||||
</p>
|
||||
<p class="bonus-zeile">
|
||||
🎁 <strong>Ihr Dankeschön:</strong> Sie können <strong>5 Briefe
|
||||
gratis</strong> erklären lassen — statt 3. Den Code bekommen Sie
|
||||
per E-Mail.
|
||||
</p>
|
||||
<a class="cta" href="mailto:behoerdenbriefhelfer@gmail.com?subject=Anmeldung%20Warteliste&body=Hallo%2C%20bitte%20informiert%20mich%2C%20wenn%20Beh%C3%B6rdenKlar%20verf%C3%BCgbar%20ist.">
|
||||
📧 Per E-Mail eintragen
|
||||
</a>
|
||||
<p class="klein">
|
||||
Wir speichern Ihre E-Mail-Adresse nur für diese eine Nachricht.
|
||||
Sie können sich jederzeit abmelden — <a href="datenschutz.html">Datenschutzerklärung</a>.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2><span class="nr">§ 6</span> Häufige Fragen</h2>
|
||||
<details>
|
||||
<summary>Was kostet die App?</summary>
|
||||
<p>Die App ist gratis. Die ersten 3 Briefe sind auch gratis (mit Code: 5). Danach gibt es ein günstiges Abo. Sie testen zuerst — dann entscheiden Sie.</p>
|
||||
</details>
|
||||
<details>
|
||||
<summary>Was passiert mit meinen Briefen?</summary>
|
||||
<p>Ihre Briefe bleiben auf Ihrem Handy — sicher verschlüsselt. Für die Analyse wird das Foto einmal sicher an die KI geschickt. Nach spätestens 30 Tagen wird es dort gelöscht. Sie brauchen kein Konto. Mehr dazu in der <a href="datenschutz.html">Datenschutzerklärung</a>.</p>
|
||||
</details>
|
||||
<details>
|
||||
<summary>Ersetzt die App einen Anwalt?</summary>
|
||||
<p>Nein. Die App erklärt Briefe — sie ist keine Rechtsberatung. Bei wichtigen Entscheidungen (z. B. Widerspruch) hilft eine Beratungsstelle oder ein Anwalt.</p>
|
||||
</details>
|
||||
<details>
|
||||
<summary>Kommt die App auch für Android?</summary>
|
||||
<p>Ja, Android ist geplant. Das iPhone kommt zuerst. Auf der Warteliste erfahren Sie es als Erste.</p>
|
||||
</details>
|
||||
</section>
|
||||
|
||||
<div class="grussformel">
|
||||
<p>Mit freundlichen und verständlichen Grüßen</p>
|
||||
<p class="unterschrift">Ihr BehördenKlar</p>
|
||||
|
||||
<div class="anlage">
|
||||
Anlagen: <a href="impressum.html" style="color: inherit;">Impressum</a> ·
|
||||
<a href="datenschutz.html" style="color: inherit;">Datenschutzerklärung</a> ·
|
||||
Kontakt: <a href="mailto:behoerdenbriefhelfer@gmail.com" style="color: inherit;">behoerdenbriefhelfer@gmail.com</a>
|
||||
</div>
|
||||
<footer>
|
||||
Keine Sorge: Diese Seite ist kein echter Amtsbrief. Hier gibt es
|
||||
keine Frist und kein Kleingedrucktes — nur eine App, die hilft.
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<!-- Cloudflare Web Analytics: cookielose Reichweitenmessung, keine Nutzer-Profile -->
|
||||
<script type='module' src='https://static.cloudflareinsights.com/beacon.min.js' data-cf-beacon='{"token": "218273478b5845c6bc821588d7d823db"}'></script>
|
||||
</body>
|
||||
</html>
|
||||
233
webseite/pl.html
Normal file
233
webseite/pl.html
Normal file
@@ -0,0 +1,233 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="pl">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>BehördenKlar – Zrozum niemieckie pisma urzędowe</title>
|
||||
<meta name="description" content="BehördenKlar wyjaśnia niemieckie pisma urzędowe prostymi słowami — po polsku. Zrób zdjęcie, zrozum list, zareaguj właściwie. Wkrótce na iPhone'a.">
|
||||
<link rel="alternate" hreflang="de" href="https://behoerdenklar.pages.dev/">
|
||||
<link rel="alternate" hreflang="tr" href="https://behoerdenklar.pages.dev/tr">
|
||||
<link rel="alternate" hreflang="ar" href="https://behoerdenklar.pages.dev/ar">
|
||||
<link rel="alternate" hreflang="en" href="https://behoerdenklar.pages.dev/en">
|
||||
<link rel="alternate" hreflang="uk" href="https://behoerdenklar.pages.dev/uk">
|
||||
<link rel="alternate" hreflang="ru" href="https://behoerdenklar.pages.dev/ru">
|
||||
<link rel="alternate" hreflang="pl" href="https://behoerdenklar.pages.dev/pl">
|
||||
<style>
|
||||
:root {
|
||||
--papier: #faf7f0; --papier-dunkel: #f1ecdf; --tinte: #21211d;
|
||||
--grau: #6b675e; --amtsblau: #1d4e89; --stempelrot: #b3372b;
|
||||
--marker: #ffe66b; --gruen: #2e7d4f; color-scheme: light;
|
||||
}
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
html { scroll-behavior: smooth; }
|
||||
body {
|
||||
font-family: Georgia, 'Times New Roman', serif;
|
||||
background:
|
||||
repeating-linear-gradient(0deg, transparent 0 34px, rgba(33,33,29,0.025) 34px 35px),
|
||||
var(--papier);
|
||||
color: var(--tinte); line-height: 1.7; font-size: 17px;
|
||||
}
|
||||
.mono { font-family: 'Courier New', ui-monospace, monospace; letter-spacing: 0.02em; }
|
||||
.wrap { max-width: 720px; margin: 0 auto; padding: 0 20px 72px; }
|
||||
.briefkopf {
|
||||
display: flex; justify-content: space-between; align-items: baseline;
|
||||
padding: 20px 0 12px; border-bottom: 2px solid var(--tinte); font-size: 14px;
|
||||
}
|
||||
.briefkopf .absender { font-weight: 700; font-size: 16px; }
|
||||
.briefkopf .az { color: var(--grau); font-size: 13px; }
|
||||
.sprachen {
|
||||
padding: 12px 0 0; font-size: 15px; color: var(--grau);
|
||||
display: flex; flex-wrap: wrap; gap: 6px 14px; align-items: center;
|
||||
}
|
||||
.sprachen a { color: var(--amtsblau); text-decoration: none; border-bottom: 1px dotted var(--amtsblau); }
|
||||
.sprachen .aktiv { font-weight: 700; color: var(--tinte); border-bottom: none; }
|
||||
header { padding: 44px 0 8px; }
|
||||
h1 { font-size: clamp(32px, 6.5vw, 48px); line-height: 1.14; font-weight: 700; }
|
||||
.markiert {
|
||||
background: linear-gradient(104deg, transparent 2%, var(--marker) 4%, var(--marker) 96%, transparent 98%);
|
||||
padding: 0 6px; box-decoration-break: clone; -webkit-box-decoration-break: clone;
|
||||
}
|
||||
.intro { margin-top: 24px; font-size: 19px; max-width: 580px; }
|
||||
.hero-fuss { display: flex; align-items: center; gap: 22px; flex-wrap: wrap; margin-top: 28px; }
|
||||
.cta {
|
||||
display: inline-block; padding: 17px 30px; background: var(--amtsblau);
|
||||
color: #fff; text-decoration: none; font-size: 18px; font-weight: 700;
|
||||
border: 2px solid var(--tinte); box-shadow: 5px 5px 0 var(--tinte);
|
||||
transition: transform 0.12s ease, box-shadow 0.12s ease;
|
||||
}
|
||||
.cta:hover { transform: translate(2px, 2px); box-shadow: 3px 3px 0 var(--tinte); }
|
||||
.stempel {
|
||||
display: inline-block; padding: 7px 14px; border: 2.5px solid var(--stempelrot);
|
||||
color: var(--stempelrot); font-family: 'Courier New', ui-monospace, monospace;
|
||||
font-weight: 700; font-size: 14px; letter-spacing: 0.08em;
|
||||
text-transform: uppercase; transform: rotate(-4deg); opacity: 0.9;
|
||||
}
|
||||
section { margin-top: 60px; }
|
||||
h2 {
|
||||
font-size: 15px; text-transform: uppercase; letter-spacing: 0.14em;
|
||||
color: var(--grau); font-weight: 400; margin-bottom: 20px;
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
}
|
||||
h2::after { content: ''; flex: 1; border-top: 1px solid rgba(33,33,29,0.25); }
|
||||
.verwandlung { display: grid; gap: 14px; }
|
||||
.brief-karte {
|
||||
background: #fff; border: 1px solid rgba(33,33,29,0.28);
|
||||
padding: 22px 24px; position: relative;
|
||||
box-shadow: 4px 5px 0 rgba(33,33,29,0.08);
|
||||
}
|
||||
.brief-karte.amtsdeutsch { transform: rotate(-0.7deg); background: var(--papier-dunkel); }
|
||||
.brief-karte.klartext { transform: rotate(0.5deg); }
|
||||
.karten-label {
|
||||
position: absolute; top: -12px; left: 16px;
|
||||
font-family: 'Courier New', ui-monospace, monospace; font-size: 12px;
|
||||
text-transform: uppercase; letter-spacing: 0.1em;
|
||||
background: var(--tinte); color: var(--papier); padding: 3px 10px;
|
||||
}
|
||||
[dir="rtl"] .karten-label { left: auto; right: 16px; }
|
||||
.brief-karte.klartext .karten-label { background: var(--gruen); }
|
||||
.amtsdeutsch p {
|
||||
font-family: 'Courier New', ui-monospace, monospace;
|
||||
font-size: 14.5px; color: #45423b; line-height: 1.75;
|
||||
}
|
||||
.klartext p { font-size: 18px; }
|
||||
.pfeil { text-align: center; font-size: 15px; color: var(--grau); font-family: 'Courier New', ui-monospace, monospace; }
|
||||
.hinweis { margin-top: 10px; font-size: 14.5px; color: var(--grau); }
|
||||
.schritte { counter-reset: schritt; }
|
||||
.schritt {
|
||||
counter-increment: schritt; display: flex; gap: 20px; padding: 20px 4px;
|
||||
border-bottom: 1px solid rgba(33,33,29,0.15); align-items: baseline;
|
||||
}
|
||||
.schritt:last-child { border-bottom: none; }
|
||||
.schritt::before {
|
||||
content: counter(schritt, decimal-leading-zero);
|
||||
font-family: 'Courier New', ui-monospace, monospace;
|
||||
font-size: 30px; font-weight: 700; color: var(--amtsblau);
|
||||
flex-shrink: 0; width: 52px;
|
||||
}
|
||||
.schritt strong { font-size: 19px; display: block; margin-bottom: 2px; }
|
||||
.schritt span { color: var(--grau); font-size: 16px; }
|
||||
.antrag {
|
||||
background: #fff; border: 2px solid var(--tinte);
|
||||
box-shadow: 7px 8px 0 rgba(33,33,29,0.9);
|
||||
padding: 30px 28px; position: relative; margin-top: 8px;
|
||||
}
|
||||
.antrag .formular-kopf {
|
||||
font-family: 'Courier New', ui-monospace, monospace; font-size: 13px;
|
||||
text-transform: uppercase; letter-spacing: 0.1em; color: var(--grau); margin-bottom: 6px;
|
||||
}
|
||||
.antrag h3 { font-size: 26px; line-height: 1.25; }
|
||||
.antrag p { margin-top: 12px; max-width: 540px; }
|
||||
.bonus {
|
||||
margin: 18px 0; padding: 14px 16px; font-size: 17px;
|
||||
background: linear-gradient(104deg, transparent 1%, var(--marker) 3%, var(--marker) 97%, transparent 99%);
|
||||
}
|
||||
.antrag .klein { font-size: 13.5px; color: var(--grau); margin-top: 14px; }
|
||||
.antrag .klein a { color: var(--grau); }
|
||||
a { color: var(--amtsblau); }
|
||||
footer {
|
||||
margin-top: 56px; padding-top: 14px; border-top: 2px solid var(--tinte);
|
||||
font-size: 14.5px; color: var(--grau);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<div class="briefkopf">
|
||||
<span class="absender">📬 BehördenKlar</span>
|
||||
<span class="az mono">Az: KLAR-01/2026</span>
|
||||
</div>
|
||||
<nav class="sprachen" aria-label="Wybierz język">
|
||||
🌐
|
||||
<a href="index.html" lang="de">Deutsch</a>
|
||||
<a href="tr.html" lang="tr">Türkçe</a>
|
||||
<a href="ar.html" lang="ar">العربية</a>
|
||||
<a href="en.html" lang="en">English</a>
|
||||
<a href="uk.html" lang="uk">Українська</a>
|
||||
<a href="ru.html" lang="ru">Русский</a>
|
||||
<span class="aktiv">Polski</span>
|
||||
</nav>
|
||||
|
||||
<header>
|
||||
<h1>List z niemieckiego urzędu?<br><span class="markiert">Teraz łatwo go zrozumieć.</span></h1>
|
||||
<p class="intro">
|
||||
Zrób zdjęcie listu. BehördenKlar wyjaśni go prostym niemieckim —
|
||||
<strong>i po polsku</strong>. Ze wszystkimi terminami i listą, co
|
||||
trzeba zrobić.
|
||||
</p>
|
||||
<div class="hero-fuss">
|
||||
<a class="cta" href="#anmelden">Zapisz się na listę oczekujących →</a>
|
||||
<span class="stempel">Wkrótce na iPhone'a</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section>
|
||||
<h2>§ 1 Przemiana</h2>
|
||||
<div class="verwandlung">
|
||||
<div class="brief-karte amtsdeutsch">
|
||||
<span class="karten-label">Przed · Oryginalny list (po niemiecku)</span>
|
||||
<p>
|
||||
„Gemäß § 32 SGB II wird bei Nichterscheinen ohne wichtigen Grund
|
||||
eine Minderung des maßgebenden Regelbedarfs in Höhe von 10 vom
|
||||
Hundert festgestellt."
|
||||
</p>
|
||||
</div>
|
||||
<p class="pfeil">— 📷 jedno zdjęcie później —</p>
|
||||
<div class="brief-karte klartext">
|
||||
<span class="karten-label">Po · BehördenKlar po polsku</span>
|
||||
<p>
|
||||
Jeśli nie przyjdziesz na spotkanie bez ważnego powodu, przez
|
||||
miesiąc będziesz dostawać o 10% mniej pieniędzy. Idź na spotkanie
|
||||
— albo odwołaj je na czas.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="hinweis">
|
||||
Aplikacja wyjaśnia listy prostym niemieckim i tłumaczy na polski,
|
||||
turecki, arabski, angielski, rosyjski, ukraiński, francuski, farsi
|
||||
i rumuński.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>§ 2 Jak to działa</h2>
|
||||
<div class="schritte">
|
||||
<div class="schritt"><div><strong>Zrób zdjęcie listu</strong> <span>albo wgraj PDF.</span></div></div>
|
||||
<div class="schritt"><div><strong>Zrozum go</strong> <span>Proste słowa, w Twoim języku. Sygnalizacja pokazuje, jak pilna jest sprawa.</span></div></div>
|
||||
<div class="schritt"><div><strong>Zareaguj właściwie</strong> <span>Lista zadań, przypomnienie o terminie i gotowa odpowiedź.</span></div></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="anmelden">
|
||||
<h2>§ 3 Wcześniejszy dostęp</h2>
|
||||
<div class="antrag">
|
||||
<p class="formular-kopf">Formularz F-01 · Lista oczekujących</p>
|
||||
<h3>Bądź wśród pierwszych</h3>
|
||||
<p>
|
||||
Aplikacja wkrótce pojawi się na iPhone'a. Zapisz się teraz —
|
||||
wyślemy Ci <strong>jedną</strong> wiadomość, gdy wszystko będzie
|
||||
gotowe. Bez reklam.
|
||||
</p>
|
||||
<p class="bonus">
|
||||
🎁 <strong>Twój prezent:</strong> analizuj <strong>5 listów za
|
||||
darmo</strong> zamiast 3. Kod dostaniesz e-mailem.
|
||||
</p>
|
||||
<a class="cta" href="mailto:behoerdenbriefhelfer@gmail.com?subject=Anmeldung%20Warteliste%20(Polnisch)&body=Hallo%2C%20bitte%20informiert%20mich%2C%20wenn%20Beh%C3%B6rdenKlar%20verf%C3%BCgbar%20ist.%20%2F%20Please%20notify%20me%20when%20Beh%C3%B6rdenKlar%20is%20available.">
|
||||
📧 Zapisz się e-mailem
|
||||
</a>
|
||||
<p class="klein">
|
||||
Zapisujemy Twój adres tylko dla tej jednej wiadomości. Możesz
|
||||
wypisać się w każdej chwili. <a href="datenschutz.html">Polityka prywatności (po niemiecku)</a>.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer>
|
||||
Pytania? 📧 <a href="mailto:behoerdenbriefhelfer@gmail.com">behoerdenbriefhelfer@gmail.com</a>
|
||||
· <a href="impressum.html">Impressum (po niemiecku)</a>
|
||||
· <a href="datenschutz.html">Polityka prywatności (po niemiecku)</a>
|
||||
</footer>
|
||||
</div>
|
||||
<!-- Cloudflare Web Analytics: cookielose Reichweitenmessung, keine Nutzer-Profile -->
|
||||
<script type='module' src='https://static.cloudflareinsights.com/beacon.min.js' data-cf-beacon='{"token": "218273478b5845c6bc821588d7d823db"}'></script>
|
||||
</body>
|
||||
</html>
|
||||
232
webseite/ru.html
Normal file
232
webseite/ru.html
Normal file
@@ -0,0 +1,232 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>BehördenKlar – Поймите немецкие официальные письма</title>
|
||||
<meta name="description" content="BehördenKlar объясняет немецкие официальные письма простыми словами — по-русски. Сфотографируйте письмо, поймите его, правильно отреагируйте. Скоро для iPhone.">
|
||||
<link rel="alternate" hreflang="de" href="https://behoerdenklar.pages.dev/">
|
||||
<link rel="alternate" hreflang="tr" href="https://behoerdenklar.pages.dev/tr">
|
||||
<link rel="alternate" hreflang="ar" href="https://behoerdenklar.pages.dev/ar">
|
||||
<link rel="alternate" hreflang="en" href="https://behoerdenklar.pages.dev/en">
|
||||
<link rel="alternate" hreflang="uk" href="https://behoerdenklar.pages.dev/uk">
|
||||
<link rel="alternate" hreflang="ru" href="https://behoerdenklar.pages.dev/ru">
|
||||
<link rel="alternate" hreflang="pl" href="https://behoerdenklar.pages.dev/pl">
|
||||
<style>
|
||||
:root {
|
||||
--papier: #faf7f0; --papier-dunkel: #f1ecdf; --tinte: #21211d;
|
||||
--grau: #6b675e; --amtsblau: #1d4e89; --stempelrot: #b3372b;
|
||||
--marker: #ffe66b; --gruen: #2e7d4f; color-scheme: light;
|
||||
}
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
html { scroll-behavior: smooth; }
|
||||
body {
|
||||
font-family: Georgia, 'Times New Roman', serif;
|
||||
background:
|
||||
repeating-linear-gradient(0deg, transparent 0 34px, rgba(33,33,29,0.025) 34px 35px),
|
||||
var(--papier);
|
||||
color: var(--tinte); line-height: 1.7; font-size: 17px;
|
||||
}
|
||||
.mono { font-family: 'Courier New', ui-monospace, monospace; letter-spacing: 0.02em; }
|
||||
.wrap { max-width: 720px; margin: 0 auto; padding: 0 20px 72px; }
|
||||
.briefkopf {
|
||||
display: flex; justify-content: space-between; align-items: baseline;
|
||||
padding: 20px 0 12px; border-bottom: 2px solid var(--tinte); font-size: 14px;
|
||||
}
|
||||
.briefkopf .absender { font-weight: 700; font-size: 16px; }
|
||||
.briefkopf .az { color: var(--grau); font-size: 13px; }
|
||||
.sprachen {
|
||||
padding: 12px 0 0; font-size: 15px; color: var(--grau);
|
||||
display: flex; flex-wrap: wrap; gap: 6px 14px; align-items: center;
|
||||
}
|
||||
.sprachen a { color: var(--amtsblau); text-decoration: none; border-bottom: 1px dotted var(--amtsblau); }
|
||||
.sprachen .aktiv { font-weight: 700; color: var(--tinte); border-bottom: none; }
|
||||
header { padding: 44px 0 8px; }
|
||||
h1 { font-size: clamp(32px, 6.5vw, 48px); line-height: 1.14; font-weight: 700; }
|
||||
.markiert {
|
||||
background: linear-gradient(104deg, transparent 2%, var(--marker) 4%, var(--marker) 96%, transparent 98%);
|
||||
padding: 0 6px; box-decoration-break: clone; -webkit-box-decoration-break: clone;
|
||||
}
|
||||
.intro { margin-top: 24px; font-size: 19px; max-width: 580px; }
|
||||
.hero-fuss { display: flex; align-items: center; gap: 22px; flex-wrap: wrap; margin-top: 28px; }
|
||||
.cta {
|
||||
display: inline-block; padding: 17px 30px; background: var(--amtsblau);
|
||||
color: #fff; text-decoration: none; font-size: 18px; font-weight: 700;
|
||||
border: 2px solid var(--tinte); box-shadow: 5px 5px 0 var(--tinte);
|
||||
transition: transform 0.12s ease, box-shadow 0.12s ease;
|
||||
}
|
||||
.cta:hover { transform: translate(2px, 2px); box-shadow: 3px 3px 0 var(--tinte); }
|
||||
.stempel {
|
||||
display: inline-block; padding: 7px 14px; border: 2.5px solid var(--stempelrot);
|
||||
color: var(--stempelrot); font-family: 'Courier New', ui-monospace, monospace;
|
||||
font-weight: 700; font-size: 14px; letter-spacing: 0.08em;
|
||||
text-transform: uppercase; transform: rotate(-4deg); opacity: 0.9;
|
||||
}
|
||||
section { margin-top: 60px; }
|
||||
h2 {
|
||||
font-size: 15px; text-transform: uppercase; letter-spacing: 0.14em;
|
||||
color: var(--grau); font-weight: 400; margin-bottom: 20px;
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
}
|
||||
h2::after { content: ''; flex: 1; border-top: 1px solid rgba(33,33,29,0.25); }
|
||||
.verwandlung { display: grid; gap: 14px; }
|
||||
.brief-karte {
|
||||
background: #fff; border: 1px solid rgba(33,33,29,0.28);
|
||||
padding: 22px 24px; position: relative;
|
||||
box-shadow: 4px 5px 0 rgba(33,33,29,0.08);
|
||||
}
|
||||
.brief-karte.amtsdeutsch { transform: rotate(-0.7deg); background: var(--papier-dunkel); }
|
||||
.brief-karte.klartext { transform: rotate(0.5deg); }
|
||||
.karten-label {
|
||||
position: absolute; top: -12px; left: 16px;
|
||||
font-family: 'Courier New', ui-monospace, monospace; font-size: 12px;
|
||||
text-transform: uppercase; letter-spacing: 0.1em;
|
||||
background: var(--tinte); color: var(--papier); padding: 3px 10px;
|
||||
}
|
||||
[dir="rtl"] .karten-label { left: auto; right: 16px; }
|
||||
.brief-karte.klartext .karten-label { background: var(--gruen); }
|
||||
.amtsdeutsch p {
|
||||
font-family: 'Courier New', ui-monospace, monospace;
|
||||
font-size: 14.5px; color: #45423b; line-height: 1.75;
|
||||
}
|
||||
.klartext p { font-size: 18px; }
|
||||
.pfeil { text-align: center; font-size: 15px; color: var(--grau); font-family: 'Courier New', ui-monospace, monospace; }
|
||||
.hinweis { margin-top: 10px; font-size: 14.5px; color: var(--grau); }
|
||||
.schritte { counter-reset: schritt; }
|
||||
.schritt {
|
||||
counter-increment: schritt; display: flex; gap: 20px; padding: 20px 4px;
|
||||
border-bottom: 1px solid rgba(33,33,29,0.15); align-items: baseline;
|
||||
}
|
||||
.schritt:last-child { border-bottom: none; }
|
||||
.schritt::before {
|
||||
content: counter(schritt, decimal-leading-zero);
|
||||
font-family: 'Courier New', ui-monospace, monospace;
|
||||
font-size: 30px; font-weight: 700; color: var(--amtsblau);
|
||||
flex-shrink: 0; width: 52px;
|
||||
}
|
||||
.schritt strong { font-size: 19px; display: block; margin-bottom: 2px; }
|
||||
.schritt span { color: var(--grau); font-size: 16px; }
|
||||
.antrag {
|
||||
background: #fff; border: 2px solid var(--tinte);
|
||||
box-shadow: 7px 8px 0 rgba(33,33,29,0.9);
|
||||
padding: 30px 28px; position: relative; margin-top: 8px;
|
||||
}
|
||||
.antrag .formular-kopf {
|
||||
font-family: 'Courier New', ui-monospace, monospace; font-size: 13px;
|
||||
text-transform: uppercase; letter-spacing: 0.1em; color: var(--grau); margin-bottom: 6px;
|
||||
}
|
||||
.antrag h3 { font-size: 26px; line-height: 1.25; }
|
||||
.antrag p { margin-top: 12px; max-width: 540px; }
|
||||
.bonus {
|
||||
margin: 18px 0; padding: 14px 16px; font-size: 17px;
|
||||
background: linear-gradient(104deg, transparent 1%, var(--marker) 3%, var(--marker) 97%, transparent 99%);
|
||||
}
|
||||
.antrag .klein { font-size: 13.5px; color: var(--grau); margin-top: 14px; }
|
||||
.antrag .klein a { color: var(--grau); }
|
||||
a { color: var(--amtsblau); }
|
||||
footer {
|
||||
margin-top: 56px; padding-top: 14px; border-top: 2px solid var(--tinte);
|
||||
font-size: 14.5px; color: var(--grau);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<div class="briefkopf">
|
||||
<span class="absender">📬 BehördenKlar</span>
|
||||
<span class="az mono">Az: KLAR-01/2026</span>
|
||||
</div>
|
||||
<nav class="sprachen" aria-label="Выберите язык">
|
||||
🌐
|
||||
<a href="index.html" lang="de">Deutsch</a>
|
||||
<a href="tr.html" lang="tr">Türkçe</a>
|
||||
<a href="ar.html" lang="ar">العربية</a>
|
||||
<a href="en.html" lang="en">English</a>
|
||||
<a href="uk.html" lang="uk">Українська</a>
|
||||
<span class="aktiv">Русский</span>
|
||||
<a href="pl.html" lang="pl">Polski</a>
|
||||
</nav>
|
||||
|
||||
<header>
|
||||
<h1>Письмо из немецкого ведомства?<br><span class="markiert">Теперь его легко понять.</span></h1>
|
||||
<p class="intro">
|
||||
Сфотографируйте письмо. BehördenKlar объяснит его простым немецким —
|
||||
<strong>и по-русски</strong>. Со всеми сроками и списком, что делать.
|
||||
</p>
|
||||
<div class="hero-fuss">
|
||||
<a class="cta" href="#anmelden">Записаться в список ожидания →</a>
|
||||
<span class="stempel">Скоро для iPhone</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section>
|
||||
<h2>§ 1 Превращение</h2>
|
||||
<div class="verwandlung">
|
||||
<div class="brief-karte amtsdeutsch">
|
||||
<span class="karten-label">До · Оригинал письма (на немецком)</span>
|
||||
<p>
|
||||
„Gemäß § 32 SGB II wird bei Nichterscheinen ohne wichtigen Grund
|
||||
eine Minderung des maßgebenden Regelbedarfs in Höhe von 10 vom
|
||||
Hundert festgestellt."
|
||||
</p>
|
||||
</div>
|
||||
<p class="pfeil">— 📷 одно фото — и готово —</p>
|
||||
<div class="brief-karte klartext">
|
||||
<span class="karten-label">После · BehördenKlar по-русски</span>
|
||||
<p>
|
||||
Если вы не придёте на приём без уважительной причины, вы будете
|
||||
получать на 10% меньше денег в течение одного месяца. Придите на
|
||||
приём — или вовремя отмените его.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="hinweis">
|
||||
Приложение объясняет письма простым немецким языком и переводит на
|
||||
русский, турецкий, арабский, английский, украинский, французский,
|
||||
фарси, румынский и польский.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>§ 2 Как это работает</h2>
|
||||
<div class="schritte">
|
||||
<div class="schritt"><div><strong>Сфотографируйте письмо</strong> <span>или загрузите PDF.</span></div></div>
|
||||
<div class="schritt"><div><strong>Поймите его</strong> <span>Простые слова, на вашем языке. Светофор показывает, насколько это срочно.</span></div></div>
|
||||
<div class="schritt"><div><strong>Правильно отреагируйте</strong> <span>Список дел, напоминание о сроке и готовый ответ.</span></div></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="anmelden">
|
||||
<h2>§ 3 Ранний доступ</h2>
|
||||
<div class="antrag">
|
||||
<p class="formular-kopf">Формуляр F-01 · Список ожидания</p>
|
||||
<h3>Будьте в числе первых</h3>
|
||||
<p>
|
||||
Приложение скоро выйдет для iPhone. Запишитесь сейчас — мы пришлём
|
||||
вам <strong>одно</strong> письмо, когда всё будет готово. Без
|
||||
рекламы.
|
||||
</p>
|
||||
<p class="bonus">
|
||||
🎁 <strong>Ваш подарок:</strong> анализируйте <strong>5 писем
|
||||
бесплатно</strong> вместо 3. Код придёт по электронной почте.
|
||||
</p>
|
||||
<a class="cta" href="mailto:behoerdenbriefhelfer@gmail.com?subject=Anmeldung%20Warteliste%20(Russisch)&body=Hallo%2C%20bitte%20informiert%20mich%2C%20wenn%20Beh%C3%B6rdenKlar%20verf%C3%BCgbar%20ist.%20%2F%20Please%20notify%20me%20when%20Beh%C3%B6rdenKlar%20is%20available.">
|
||||
📧 Записаться по электронной почте
|
||||
</a>
|
||||
<p class="klein">
|
||||
Мы храним ваш адрес только для этого одного сообщения. Вы можете
|
||||
отписаться в любой момент. <a href="datenschutz.html">Политика конфиденциальности (на немецком)</a>.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer>
|
||||
Вопросы? 📧 <a href="mailto:behoerdenbriefhelfer@gmail.com">behoerdenbriefhelfer@gmail.com</a>
|
||||
· <a href="impressum.html">Impressum (на немецком)</a>
|
||||
· <a href="datenschutz.html">Политика конфиденциальности (на немецком)</a>
|
||||
</footer>
|
||||
</div>
|
||||
<!-- Cloudflare Web Analytics: cookielose Reichweitenmessung, keine Nutzer-Profile -->
|
||||
<script type='module' src='https://static.cloudflareinsights.com/beacon.min.js' data-cf-beacon='{"token": "218273478b5845c6bc821588d7d823db"}'></script>
|
||||
</body>
|
||||
</html>
|
||||
232
webseite/tr.html
Normal file
232
webseite/tr.html
Normal file
@@ -0,0 +1,232 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="tr">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>BehördenKlar – Alman resmî mektuplarını anlayın</title>
|
||||
<meta name="description" content="BehördenKlar Alman resmî mektuplarını basit kelimelerle — Türkçe — açıklar. Fotoğraf çekin, mektubu anlayın, doğru cevap verin. Yakında iPhone için.">
|
||||
<link rel="alternate" hreflang="de" href="https://behoerdenklar.pages.dev/">
|
||||
<link rel="alternate" hreflang="tr" href="https://behoerdenklar.pages.dev/tr">
|
||||
<link rel="alternate" hreflang="ar" href="https://behoerdenklar.pages.dev/ar">
|
||||
<link rel="alternate" hreflang="en" href="https://behoerdenklar.pages.dev/en">
|
||||
<link rel="alternate" hreflang="uk" href="https://behoerdenklar.pages.dev/uk">
|
||||
<link rel="alternate" hreflang="ru" href="https://behoerdenklar.pages.dev/ru">
|
||||
<link rel="alternate" hreflang="pl" href="https://behoerdenklar.pages.dev/pl">
|
||||
<style>
|
||||
:root {
|
||||
--papier: #faf7f0; --papier-dunkel: #f1ecdf; --tinte: #21211d;
|
||||
--grau: #6b675e; --amtsblau: #1d4e89; --stempelrot: #b3372b;
|
||||
--marker: #ffe66b; --gruen: #2e7d4f; color-scheme: light;
|
||||
}
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
html { scroll-behavior: smooth; }
|
||||
body {
|
||||
font-family: Georgia, 'Times New Roman', serif;
|
||||
background:
|
||||
repeating-linear-gradient(0deg, transparent 0 34px, rgba(33,33,29,0.025) 34px 35px),
|
||||
var(--papier);
|
||||
color: var(--tinte); line-height: 1.7; font-size: 17px;
|
||||
}
|
||||
.mono { font-family: 'Courier New', ui-monospace, monospace; letter-spacing: 0.02em; }
|
||||
.wrap { max-width: 720px; margin: 0 auto; padding: 0 20px 72px; }
|
||||
.briefkopf {
|
||||
display: flex; justify-content: space-between; align-items: baseline;
|
||||
padding: 20px 0 12px; border-bottom: 2px solid var(--tinte); font-size: 14px;
|
||||
}
|
||||
.briefkopf .absender { font-weight: 700; font-size: 16px; }
|
||||
.briefkopf .az { color: var(--grau); font-size: 13px; }
|
||||
.sprachen {
|
||||
padding: 12px 0 0; font-size: 15px; color: var(--grau);
|
||||
display: flex; flex-wrap: wrap; gap: 6px 14px; align-items: center;
|
||||
}
|
||||
.sprachen a { color: var(--amtsblau); text-decoration: none; border-bottom: 1px dotted var(--amtsblau); }
|
||||
.sprachen .aktiv { font-weight: 700; color: var(--tinte); border-bottom: none; }
|
||||
header { padding: 44px 0 8px; }
|
||||
h1 { font-size: clamp(32px, 6.5vw, 48px); line-height: 1.14; font-weight: 700; }
|
||||
.markiert {
|
||||
background: linear-gradient(104deg, transparent 2%, var(--marker) 4%, var(--marker) 96%, transparent 98%);
|
||||
padding: 0 6px; box-decoration-break: clone; -webkit-box-decoration-break: clone;
|
||||
}
|
||||
.intro { margin-top: 24px; font-size: 19px; max-width: 580px; }
|
||||
.hero-fuss { display: flex; align-items: center; gap: 22px; flex-wrap: wrap; margin-top: 28px; }
|
||||
.cta {
|
||||
display: inline-block; padding: 17px 30px; background: var(--amtsblau);
|
||||
color: #fff; text-decoration: none; font-size: 18px; font-weight: 700;
|
||||
border: 2px solid var(--tinte); box-shadow: 5px 5px 0 var(--tinte);
|
||||
transition: transform 0.12s ease, box-shadow 0.12s ease;
|
||||
}
|
||||
.cta:hover { transform: translate(2px, 2px); box-shadow: 3px 3px 0 var(--tinte); }
|
||||
.stempel {
|
||||
display: inline-block; padding: 7px 14px; border: 2.5px solid var(--stempelrot);
|
||||
color: var(--stempelrot); font-family: 'Courier New', ui-monospace, monospace;
|
||||
font-weight: 700; font-size: 14px; letter-spacing: 0.08em;
|
||||
text-transform: uppercase; transform: rotate(-4deg); opacity: 0.9;
|
||||
}
|
||||
section { margin-top: 60px; }
|
||||
h2 {
|
||||
font-size: 15px; text-transform: uppercase; letter-spacing: 0.14em;
|
||||
color: var(--grau); font-weight: 400; margin-bottom: 20px;
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
}
|
||||
h2::after { content: ''; flex: 1; border-top: 1px solid rgba(33,33,29,0.25); }
|
||||
.verwandlung { display: grid; gap: 14px; }
|
||||
.brief-karte {
|
||||
background: #fff; border: 1px solid rgba(33,33,29,0.28);
|
||||
padding: 22px 24px; position: relative;
|
||||
box-shadow: 4px 5px 0 rgba(33,33,29,0.08);
|
||||
}
|
||||
.brief-karte.amtsdeutsch { transform: rotate(-0.7deg); background: var(--papier-dunkel); }
|
||||
.brief-karte.klartext { transform: rotate(0.5deg); }
|
||||
.karten-label {
|
||||
position: absolute; top: -12px; left: 16px;
|
||||
font-family: 'Courier New', ui-monospace, monospace; font-size: 12px;
|
||||
text-transform: uppercase; letter-spacing: 0.1em;
|
||||
background: var(--tinte); color: var(--papier); padding: 3px 10px;
|
||||
}
|
||||
[dir="rtl"] .karten-label { left: auto; right: 16px; }
|
||||
.brief-karte.klartext .karten-label { background: var(--gruen); }
|
||||
.amtsdeutsch p {
|
||||
font-family: 'Courier New', ui-monospace, monospace;
|
||||
font-size: 14.5px; color: #45423b; line-height: 1.75;
|
||||
}
|
||||
.klartext p { font-size: 18px; }
|
||||
.pfeil { text-align: center; font-size: 15px; color: var(--grau); font-family: 'Courier New', ui-monospace, monospace; }
|
||||
.hinweis { margin-top: 10px; font-size: 14.5px; color: var(--grau); }
|
||||
.schritte { counter-reset: schritt; }
|
||||
.schritt {
|
||||
counter-increment: schritt; display: flex; gap: 20px; padding: 20px 4px;
|
||||
border-bottom: 1px solid rgba(33,33,29,0.15); align-items: baseline;
|
||||
}
|
||||
.schritt:last-child { border-bottom: none; }
|
||||
.schritt::before {
|
||||
content: counter(schritt, decimal-leading-zero);
|
||||
font-family: 'Courier New', ui-monospace, monospace;
|
||||
font-size: 30px; font-weight: 700; color: var(--amtsblau);
|
||||
flex-shrink: 0; width: 52px;
|
||||
}
|
||||
.schritt strong { font-size: 19px; display: block; margin-bottom: 2px; }
|
||||
.schritt span { color: var(--grau); font-size: 16px; }
|
||||
.antrag {
|
||||
background: #fff; border: 2px solid var(--tinte);
|
||||
box-shadow: 7px 8px 0 rgba(33,33,29,0.9);
|
||||
padding: 30px 28px; position: relative; margin-top: 8px;
|
||||
}
|
||||
.antrag .formular-kopf {
|
||||
font-family: 'Courier New', ui-monospace, monospace; font-size: 13px;
|
||||
text-transform: uppercase; letter-spacing: 0.1em; color: var(--grau); margin-bottom: 6px;
|
||||
}
|
||||
.antrag h3 { font-size: 26px; line-height: 1.25; }
|
||||
.antrag p { margin-top: 12px; max-width: 540px; }
|
||||
.bonus {
|
||||
margin: 18px 0; padding: 14px 16px; font-size: 17px;
|
||||
background: linear-gradient(104deg, transparent 1%, var(--marker) 3%, var(--marker) 97%, transparent 99%);
|
||||
}
|
||||
.antrag .klein { font-size: 13.5px; color: var(--grau); margin-top: 14px; }
|
||||
.antrag .klein a { color: var(--grau); }
|
||||
a { color: var(--amtsblau); }
|
||||
footer {
|
||||
margin-top: 56px; padding-top: 14px; border-top: 2px solid var(--tinte);
|
||||
font-size: 14.5px; color: var(--grau);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<div class="briefkopf">
|
||||
<span class="absender">📬 BehördenKlar</span>
|
||||
<span class="az mono">Az: KLAR-01/2026</span>
|
||||
</div>
|
||||
<nav class="sprachen" aria-label="Dil seçin">
|
||||
🌐
|
||||
<a href="index.html" lang="de">Deutsch</a>
|
||||
<span class="aktiv">Türkçe</span>
|
||||
<a href="ar.html" lang="ar">العربية</a>
|
||||
<a href="en.html" lang="en">English</a>
|
||||
<a href="uk.html" lang="uk">Українська</a>
|
||||
<a href="ru.html" lang="ru">Русский</a>
|
||||
<a href="pl.html" lang="pl">Polski</a>
|
||||
</nav>
|
||||
|
||||
<header>
|
||||
<h1>Alman dairesinden mektup mu geldi?<br><span class="markiert">Artık anlamak kolay.</span></h1>
|
||||
<p class="intro">
|
||||
Mektubun fotoğrafını çekin. BehördenKlar mektubu basit Almanca ile —
|
||||
<strong>ve Türkçe olarak</strong> — açıklar. Bütün süreler ve
|
||||
yapılacaklar listesi ile birlikte.
|
||||
</p>
|
||||
<div class="hero-fuss">
|
||||
<a class="cta" href="#anmelden">Bekleme listesine katıl →</a>
|
||||
<span class="stempel">Yakında iPhone'da</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section>
|
||||
<h2>§ 1 Dönüşüm</h2>
|
||||
<div class="verwandlung">
|
||||
<div class="brief-karte amtsdeutsch">
|
||||
<span class="karten-label">Önce · Orijinal mektup (Almanca)</span>
|
||||
<p>
|
||||
„Gemäß § 32 SGB II wird bei Nichterscheinen ohne wichtigen Grund
|
||||
eine Minderung des maßgebenden Regelbedarfs in Höhe von 10 vom
|
||||
Hundert festgestellt."
|
||||
</p>
|
||||
</div>
|
||||
<p class="pfeil">— 📷 bir fotoğraf sonra —</p>
|
||||
<div class="brief-karte klartext">
|
||||
<span class="karten-label">Sonra · BehördenKlar Türkçe</span>
|
||||
<p>
|
||||
Geçerli bir neden olmadan randevuya gitmezseniz, bir ay boyunca
|
||||
%10 daha az para alırsınız. Randevuya gidin — veya zamanında
|
||||
iptal edin.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="hinweis">
|
||||
Uygulama mektupları basit Almanca ile açıklar ve şu dillere çevirir:
|
||||
Türkçe, Arapça, İngilizce, Rusça, Ukraynaca, Fransızca, Farsça,
|
||||
Romence ve Lehçe.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>§ 2 Nasıl çalışır</h2>
|
||||
<div class="schritte">
|
||||
<div class="schritt"><div><strong>Fotoğraf çekin</strong> <span>mektubun fotoğrafını çekin — veya PDF yükleyin.</span></div></div>
|
||||
<div class="schritt"><div><strong>Anlayın</strong> <span>Basit kelimeler, kendi dilinizde. Bir lamba işin ne kadar acil olduğunu gösterir.</span></div></div>
|
||||
<div class="schritt"><div><strong>Doğru cevap verin</strong> <span>Yapılacaklar listesi, süre hatırlatması ve hazır bir cevap.</span></div></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="anmelden">
|
||||
<h2>§ 3 Erken erişim</h2>
|
||||
<div class="antrag">
|
||||
<p class="formular-kopf">Form F-01 · Bekleme listesi</p>
|
||||
<h3>İlk kullananlardan olun</h3>
|
||||
<p>
|
||||
Uygulama yakında iPhone için geliyor. Şimdi kaydolun — uygulama
|
||||
hazır olunca size <strong>bir</strong> e-posta göndeririz. Reklam yok.
|
||||
</p>
|
||||
<p class="bonus">
|
||||
🎁 <strong>Hediyeniz:</strong> 3 yerine <strong>5 mektubu ücretsiz</strong>
|
||||
analiz edin. Kodu e-posta ile alırsınız.
|
||||
</p>
|
||||
<a class="cta" href="mailto:behoerdenbriefhelfer@gmail.com?subject=Anmeldung%20Warteliste%20(Tuerkisch)&body=Hallo%2C%20bitte%20informiert%20mich%2C%20wenn%20Beh%C3%B6rdenKlar%20verf%C3%BCgbar%20ist.%20%2F%20Please%20notify%20me%20when%20Beh%C3%B6rdenKlar%20is%20available.">
|
||||
📧 E-posta ile kaydol
|
||||
</a>
|
||||
<p class="klein">
|
||||
E-posta adresinizi sadece bu bir mesaj için saklarız. İstediğiniz
|
||||
zaman listeden çıkabilirsiniz. <a href="datenschutz.html">Gizlilik politikası (Almanca)</a>.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer>
|
||||
Sorunuz mu var? 📧 <a href="mailto:behoerdenbriefhelfer@gmail.com">behoerdenbriefhelfer@gmail.com</a>
|
||||
· <a href="impressum.html">Impressum (Almanca)</a>
|
||||
· <a href="datenschutz.html">Gizlilik politikası (Almanca)</a>
|
||||
</footer>
|
||||
</div>
|
||||
<!-- Cloudflare Web Analytics: cookielose Reichweitenmessung, keine Nutzer-Profile -->
|
||||
<script type='module' src='https://static.cloudflareinsights.com/beacon.min.js' data-cf-beacon='{"token": "218273478b5845c6bc821588d7d823db"}'></script>
|
||||
</body>
|
||||
</html>
|
||||
233
webseite/uk.html
Normal file
233
webseite/uk.html
Normal file
@@ -0,0 +1,233 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="uk">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>BehördenKlar – Зрозумійте німецькі офіційні листи</title>
|
||||
<meta name="description" content="BehördenKlar пояснює німецькі офіційні листи простими словами — українською. Сфотографуйте лист, зрозумійте його, правильно відреагуйте. Скоро для iPhone.">
|
||||
<link rel="alternate" hreflang="de" href="https://behoerdenklar.pages.dev/">
|
||||
<link rel="alternate" hreflang="tr" href="https://behoerdenklar.pages.dev/tr">
|
||||
<link rel="alternate" hreflang="ar" href="https://behoerdenklar.pages.dev/ar">
|
||||
<link rel="alternate" hreflang="en" href="https://behoerdenklar.pages.dev/en">
|
||||
<link rel="alternate" hreflang="uk" href="https://behoerdenklar.pages.dev/uk">
|
||||
<link rel="alternate" hreflang="ru" href="https://behoerdenklar.pages.dev/ru">
|
||||
<link rel="alternate" hreflang="pl" href="https://behoerdenklar.pages.dev/pl">
|
||||
<style>
|
||||
:root {
|
||||
--papier: #faf7f0; --papier-dunkel: #f1ecdf; --tinte: #21211d;
|
||||
--grau: #6b675e; --amtsblau: #1d4e89; --stempelrot: #b3372b;
|
||||
--marker: #ffe66b; --gruen: #2e7d4f; color-scheme: light;
|
||||
}
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
html { scroll-behavior: smooth; }
|
||||
body {
|
||||
font-family: Georgia, 'Times New Roman', serif;
|
||||
background:
|
||||
repeating-linear-gradient(0deg, transparent 0 34px, rgba(33,33,29,0.025) 34px 35px),
|
||||
var(--papier);
|
||||
color: var(--tinte); line-height: 1.7; font-size: 17px;
|
||||
}
|
||||
.mono { font-family: 'Courier New', ui-monospace, monospace; letter-spacing: 0.02em; }
|
||||
.wrap { max-width: 720px; margin: 0 auto; padding: 0 20px 72px; }
|
||||
.briefkopf {
|
||||
display: flex; justify-content: space-between; align-items: baseline;
|
||||
padding: 20px 0 12px; border-bottom: 2px solid var(--tinte); font-size: 14px;
|
||||
}
|
||||
.briefkopf .absender { font-weight: 700; font-size: 16px; }
|
||||
.briefkopf .az { color: var(--grau); font-size: 13px; }
|
||||
.sprachen {
|
||||
padding: 12px 0 0; font-size: 15px; color: var(--grau);
|
||||
display: flex; flex-wrap: wrap; gap: 6px 14px; align-items: center;
|
||||
}
|
||||
.sprachen a { color: var(--amtsblau); text-decoration: none; border-bottom: 1px dotted var(--amtsblau); }
|
||||
.sprachen .aktiv { font-weight: 700; color: var(--tinte); border-bottom: none; }
|
||||
header { padding: 44px 0 8px; }
|
||||
h1 { font-size: clamp(32px, 6.5vw, 48px); line-height: 1.14; font-weight: 700; }
|
||||
.markiert {
|
||||
background: linear-gradient(104deg, transparent 2%, var(--marker) 4%, var(--marker) 96%, transparent 98%);
|
||||
padding: 0 6px; box-decoration-break: clone; -webkit-box-decoration-break: clone;
|
||||
}
|
||||
.intro { margin-top: 24px; font-size: 19px; max-width: 580px; }
|
||||
.hero-fuss { display: flex; align-items: center; gap: 22px; flex-wrap: wrap; margin-top: 28px; }
|
||||
.cta {
|
||||
display: inline-block; padding: 17px 30px; background: var(--amtsblau);
|
||||
color: #fff; text-decoration: none; font-size: 18px; font-weight: 700;
|
||||
border: 2px solid var(--tinte); box-shadow: 5px 5px 0 var(--tinte);
|
||||
transition: transform 0.12s ease, box-shadow 0.12s ease;
|
||||
}
|
||||
.cta:hover { transform: translate(2px, 2px); box-shadow: 3px 3px 0 var(--tinte); }
|
||||
.stempel {
|
||||
display: inline-block; padding: 7px 14px; border: 2.5px solid var(--stempelrot);
|
||||
color: var(--stempelrot); font-family: 'Courier New', ui-monospace, monospace;
|
||||
font-weight: 700; font-size: 14px; letter-spacing: 0.08em;
|
||||
text-transform: uppercase; transform: rotate(-4deg); opacity: 0.9;
|
||||
}
|
||||
section { margin-top: 60px; }
|
||||
h2 {
|
||||
font-size: 15px; text-transform: uppercase; letter-spacing: 0.14em;
|
||||
color: var(--grau); font-weight: 400; margin-bottom: 20px;
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
}
|
||||
h2::after { content: ''; flex: 1; border-top: 1px solid rgba(33,33,29,0.25); }
|
||||
.verwandlung { display: grid; gap: 14px; }
|
||||
.brief-karte {
|
||||
background: #fff; border: 1px solid rgba(33,33,29,0.28);
|
||||
padding: 22px 24px; position: relative;
|
||||
box-shadow: 4px 5px 0 rgba(33,33,29,0.08);
|
||||
}
|
||||
.brief-karte.amtsdeutsch { transform: rotate(-0.7deg); background: var(--papier-dunkel); }
|
||||
.brief-karte.klartext { transform: rotate(0.5deg); }
|
||||
.karten-label {
|
||||
position: absolute; top: -12px; left: 16px;
|
||||
font-family: 'Courier New', ui-monospace, monospace; font-size: 12px;
|
||||
text-transform: uppercase; letter-spacing: 0.1em;
|
||||
background: var(--tinte); color: var(--papier); padding: 3px 10px;
|
||||
}
|
||||
[dir="rtl"] .karten-label { left: auto; right: 16px; }
|
||||
.brief-karte.klartext .karten-label { background: var(--gruen); }
|
||||
.amtsdeutsch p {
|
||||
font-family: 'Courier New', ui-monospace, monospace;
|
||||
font-size: 14.5px; color: #45423b; line-height: 1.75;
|
||||
}
|
||||
.klartext p { font-size: 18px; }
|
||||
.pfeil { text-align: center; font-size: 15px; color: var(--grau); font-family: 'Courier New', ui-monospace, monospace; }
|
||||
.hinweis { margin-top: 10px; font-size: 14.5px; color: var(--grau); }
|
||||
.schritte { counter-reset: schritt; }
|
||||
.schritt {
|
||||
counter-increment: schritt; display: flex; gap: 20px; padding: 20px 4px;
|
||||
border-bottom: 1px solid rgba(33,33,29,0.15); align-items: baseline;
|
||||
}
|
||||
.schritt:last-child { border-bottom: none; }
|
||||
.schritt::before {
|
||||
content: counter(schritt, decimal-leading-zero);
|
||||
font-family: 'Courier New', ui-monospace, monospace;
|
||||
font-size: 30px; font-weight: 700; color: var(--amtsblau);
|
||||
flex-shrink: 0; width: 52px;
|
||||
}
|
||||
.schritt strong { font-size: 19px; display: block; margin-bottom: 2px; }
|
||||
.schritt span { color: var(--grau); font-size: 16px; }
|
||||
.antrag {
|
||||
background: #fff; border: 2px solid var(--tinte);
|
||||
box-shadow: 7px 8px 0 rgba(33,33,29,0.9);
|
||||
padding: 30px 28px; position: relative; margin-top: 8px;
|
||||
}
|
||||
.antrag .formular-kopf {
|
||||
font-family: 'Courier New', ui-monospace, monospace; font-size: 13px;
|
||||
text-transform: uppercase; letter-spacing: 0.1em; color: var(--grau); margin-bottom: 6px;
|
||||
}
|
||||
.antrag h3 { font-size: 26px; line-height: 1.25; }
|
||||
.antrag p { margin-top: 12px; max-width: 540px; }
|
||||
.bonus {
|
||||
margin: 18px 0; padding: 14px 16px; font-size: 17px;
|
||||
background: linear-gradient(104deg, transparent 1%, var(--marker) 3%, var(--marker) 97%, transparent 99%);
|
||||
}
|
||||
.antrag .klein { font-size: 13.5px; color: var(--grau); margin-top: 14px; }
|
||||
.antrag .klein a { color: var(--grau); }
|
||||
a { color: var(--amtsblau); }
|
||||
footer {
|
||||
margin-top: 56px; padding-top: 14px; border-top: 2px solid var(--tinte);
|
||||
font-size: 14.5px; color: var(--grau);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<div class="briefkopf">
|
||||
<span class="absender">📬 BehördenKlar</span>
|
||||
<span class="az mono">Az: KLAR-01/2026</span>
|
||||
</div>
|
||||
<nav class="sprachen" aria-label="Оберіть мову">
|
||||
🌐
|
||||
<a href="index.html" lang="de">Deutsch</a>
|
||||
<a href="tr.html" lang="tr">Türkçe</a>
|
||||
<a href="ar.html" lang="ar">العربية</a>
|
||||
<a href="en.html" lang="en">English</a>
|
||||
<span class="aktiv">Українська</span>
|
||||
<a href="ru.html" lang="ru">Русский</a>
|
||||
<a href="pl.html" lang="pl">Polski</a>
|
||||
</nav>
|
||||
|
||||
<header>
|
||||
<h1>Лист від німецької установи?<br><span class="markiert">Тепер його легко зрозуміти.</span></h1>
|
||||
<p class="intro">
|
||||
Сфотографуйте лист. BehördenKlar пояснить його простою німецькою —
|
||||
<strong>і українською</strong>. З усіма термінами та списком, що
|
||||
робити.
|
||||
</p>
|
||||
<div class="hero-fuss">
|
||||
<a class="cta" href="#anmelden">Записатися в список очікування →</a>
|
||||
<span class="stempel">Скоро для iPhone</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section>
|
||||
<h2>§ 1 Перетворення</h2>
|
||||
<div class="verwandlung">
|
||||
<div class="brief-karte amtsdeutsch">
|
||||
<span class="karten-label">До · Оригінальний лист (німецькою)</span>
|
||||
<p>
|
||||
„Gemäß § 32 SGB II wird bei Nichterscheinen ohne wichtigen Grund
|
||||
eine Minderung des maßgebenden Regelbedarfs in Höhe von 10 vom
|
||||
Hundert festgestellt."
|
||||
</p>
|
||||
</div>
|
||||
<p class="pfeil">— 📷 одне фото — і готово —</p>
|
||||
<div class="brief-karte klartext">
|
||||
<span class="karten-label">Після · BehördenKlar українською</span>
|
||||
<p>
|
||||
Якщо ви не прийдете на зустріч без поважної причини, ви будете
|
||||
отримувати на 10% менше грошей протягом одного місяця. Прийдіть
|
||||
на зустріч — або вчасно скасуйте її.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="hinweis">
|
||||
Застосунок пояснює листи простою німецькою та перекладає українською,
|
||||
турецькою, арабською, англійською, російською, французькою, фарсі,
|
||||
румунською і польською.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>§ 2 Як це працює</h2>
|
||||
<div class="schritte">
|
||||
<div class="schritt"><div><strong>Сфотографуйте лист</strong> <span>або завантажте PDF.</span></div></div>
|
||||
<div class="schritt"><div><strong>Зрозумійте його</strong> <span>Прості слова, вашою мовою. Світлофор показує, наскільки це терміново.</span></div></div>
|
||||
<div class="schritt"><div><strong>Правильно відреагуйте</strong> <span>Список справ, нагадування про термін і готова відповідь.</span></div></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="anmelden">
|
||||
<h2>§ 3 Ранній доступ</h2>
|
||||
<div class="antrag">
|
||||
<p class="formular-kopf">Формуляр F-01 · Список очікування</p>
|
||||
<h3>Будьте серед перших</h3>
|
||||
<p>
|
||||
Застосунок скоро з'явиться для iPhone. Запишіться зараз — ми
|
||||
надішлемо вам <strong>один</strong> лист, коли все буде готово.
|
||||
Без реклами.
|
||||
</p>
|
||||
<p class="bonus">
|
||||
🎁 <strong>Ваш подарунок:</strong> аналізуйте <strong>5 листів
|
||||
безкоштовно</strong> замість 3. Код отримаєте електронною поштою.
|
||||
</p>
|
||||
<a class="cta" href="mailto:behoerdenbriefhelfer@gmail.com?subject=Anmeldung%20Warteliste%20(Ukrainisch)&body=Hallo%2C%20bitte%20informiert%20mich%2C%20wenn%20Beh%C3%B6rdenKlar%20verf%C3%BCgbar%20ist.%20%2F%20Please%20notify%20me%20when%20Beh%C3%B6rdenKlar%20is%20available.">
|
||||
📧 Записатися електронною поштою
|
||||
</a>
|
||||
<p class="klein">
|
||||
Ми зберігаємо вашу адресу лише для цього одного повідомлення. Ви
|
||||
можете відписатися будь-коли. <a href="datenschutz.html">Політика конфіденційності (німецькою)</a>.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer>
|
||||
Питання? 📧 <a href="mailto:behoerdenbriefhelfer@gmail.com">behoerdenbriefhelfer@gmail.com</a>
|
||||
· <a href="impressum.html">Impressum (німецькою)</a>
|
||||
· <a href="datenschutz.html">Політика конфіденційності (німецькою)</a>
|
||||
</footer>
|
||||
</div>
|
||||
<!-- Cloudflare Web Analytics: cookielose Reichweitenmessung, keine Nutzer-Profile -->
|
||||
<script type='module' src='https://static.cloudflareinsights.com/beacon.min.js' data-cf-beacon='{"token": "218273478b5845c6bc821588d7d823db"}'></script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user