generated from knso/webklar-preview-template
Release-Vorbereitung: Proxy, Kostenoptimierung, Freemium, Store-Assets
- Backend-Proxy (Cloudflare Worker in proxy/): API-Key serverseitig, Modell-Whitelist, max_tokens-Deckel, Tageslimits pro Gerät und IP - Modell-Split: Analyse auf Sonnet 5, Übersetzung/Antwort auf Haiku 4.5 - Bild-Downscaling auf 2000px vor dem Upload (ScanScreen) - Expo SDK 56 -> 57 Upgrade - Freemium: 3 Gratis-Analysen mit lokalem Zähler und Paywall-Hinweis - Einstellungen: API-Key-Feld nur noch im Dev-Modus (NUTZT_PROXY) - Build-Setup: Bundle-IDs, eas.json, Splash-Screen-Plugin - Tests: jest-expo + 17 Tests für die Ampel-/Fristlogik - Rechtliches: Datenschutzerklärung (Entwurf), Webseite (webseite/, live auf behoerdenklar.pages.dev), Store-Texte (store/) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -33,6 +33,10 @@ yarn-error.*
|
|||||||
# local env files
|
# local env files
|
||||||
.env*.local
|
.env*.local
|
||||||
|
|
||||||
|
# Cloudflare Worker (Proxy)
|
||||||
|
.wrangler/
|
||||||
|
.dev.vars
|
||||||
|
|
||||||
# typescript
|
# typescript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
|||||||
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
|
## Produktion: Backend-Proxy statt API-Key in der App
|
||||||
|
|
||||||
Für Endkunden darf kein API-Key in der App stecken. Vorbereitet in
|
Für Endkunden darf kein API-Key in der App stecken. Der fertige Proxy
|
||||||
`src/services/claudeClient.ts`:
|
(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
|
```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:
|
Abrechnung der Endkunden dann per Abo/In-App-Kauf (Abo-Prüfung im Proxy ergänzen).
|
||||||
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.
|
|
||||||
|
|
||||||
## Architektur
|
## Architektur
|
||||||
|
|
||||||
|
|||||||
14
app.json
14
app.json
@@ -7,9 +7,11 @@
|
|||||||
"icon": "./assets/icon.png",
|
"icon": "./assets/icon.png",
|
||||||
"userInterfaceStyle": "light",
|
"userInterfaceStyle": "light",
|
||||||
"ios": {
|
"ios": {
|
||||||
"supportsTablet": true
|
"supportsTablet": true,
|
||||||
|
"bundleIdentifier": "de.behoerdenklar.app"
|
||||||
},
|
},
|
||||||
"android": {
|
"android": {
|
||||||
|
"package": "de.behoerdenklar.app",
|
||||||
"adaptiveIcon": {
|
"adaptiveIcon": {
|
||||||
"backgroundColor": "#E6F4FE",
|
"backgroundColor": "#E6F4FE",
|
||||||
"foregroundImage": "./assets/android-icon-foreground.png",
|
"foregroundImage": "./assets/android-icon-foreground.png",
|
||||||
@@ -38,7 +40,15 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"expo-notifications",
|
"expo-notifications",
|
||||||
"expo-status-bar"
|
"expo-status-bar",
|
||||||
|
[
|
||||||
|
"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": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
4225
package-lock.json
generated
4225
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
39
package.json
39
package.json
@@ -6,34 +6,43 @@
|
|||||||
"@react-native-async-storage/async-storage": "2.2.0",
|
"@react-native-async-storage/async-storage": "2.2.0",
|
||||||
"@react-navigation/native": "^7.3.8",
|
"@react-navigation/native": "^7.3.8",
|
||||||
"@react-navigation/native-stack": "^7.17.10",
|
"@react-navigation/native-stack": "^7.17.10",
|
||||||
"expo": "^56.0.15",
|
"expo": "^57.0.0",
|
||||||
"expo-calendar": "~56.0.9",
|
"expo-calendar": "~57.0.0",
|
||||||
"expo-camera": "~56.0.8",
|
"expo-camera": "~57.0.1",
|
||||||
"expo-document-picker": "~56.0.4",
|
"expo-document-picker": "~57.0.0",
|
||||||
"expo-file-system": "~56.0.8",
|
"expo-file-system": "~57.0.0",
|
||||||
"expo-image-picker": "~56.0.20",
|
"expo-image-manipulator": "~57.0.2",
|
||||||
"expo-network": "~56.0.5",
|
"expo-image-picker": "~57.0.2",
|
||||||
"expo-notifications": "~56.0.20",
|
"expo-network": "~57.0.0",
|
||||||
"expo-print": "~56.0.4",
|
"expo-notifications": "~57.0.3",
|
||||||
"expo-secure-store": "~56.0.4",
|
"expo-print": "~57.0.0",
|
||||||
"expo-sharing": "~56.0.21",
|
"expo-secure-store": "~57.0.0",
|
||||||
"expo-speech": "~56.0.3",
|
"expo-sharing": "~57.0.3",
|
||||||
"expo-status-bar": "~56.0.4",
|
"expo-speech": "~57.0.0",
|
||||||
|
"expo-splash-screen": "~57.0.2",
|
||||||
|
"expo-status-bar": "~57.0.0",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
"react-native": "0.85.3",
|
"react-native": "0.86.0",
|
||||||
"react-native-safe-area-context": "~5.7.0",
|
"react-native-safe-area-context": "~5.7.0",
|
||||||
"react-native-screens": "4.25.2",
|
"react-native-screens": "4.25.2",
|
||||||
"zustand": "^5.0.14"
|
"zustand": "^5.0.14"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/jest": "~29.5.14",
|
||||||
"@types/react": "~19.2.2",
|
"@types/react": "~19.2.2",
|
||||||
|
"jest": "~29.7.0",
|
||||||
|
"jest-expo": "~57.0.1",
|
||||||
"typescript": "~6.0.3"
|
"typescript": "~6.0.3"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "expo start",
|
"start": "expo start",
|
||||||
"android": "expo start --android",
|
"android": "expo start --android",
|
||||||
"ios": "expo start --ios",
|
"ios": "expo start --ios",
|
||||||
"web": "expo start --web"
|
"web": "expo start --web",
|
||||||
|
"test": "jest"
|
||||||
|
},
|
||||||
|
"jest": {
|
||||||
|
"preset": "jest-expo"
|
||||||
},
|
},
|
||||||
"private": true
|
"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"
|
||||||
104
rechtliches/datenschutzerklaerung.md
Normal file
104
rechtliches/datenschutzerklaerung.md
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
# 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.
|
||||||
|
Wir betreiben keine Nutzerkonten und keine Cloud-Speicherung Ihrer Briefe.
|
||||||
|
|
||||||
|
### 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. 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.
|
||||||
|
|
||||||
|
## 8. Ä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,6 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* Einstellungen: API-Schlüssel (Dev-Modus), Datenschutz-Info,
|
* Einstellungen: Datenschutz-Info und "Alle Daten löschen".
|
||||||
* "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 React, { useEffect, useState } from 'react';
|
||||||
import { Alert, ScrollView, StyleSheet, Text, TextInput, View } from 'react-native';
|
import { Alert, ScrollView, StyleSheet, Text, TextInput, View } from 'react-native';
|
||||||
@@ -8,6 +9,7 @@ import * as Notifications from 'expo-notifications';
|
|||||||
import { NativeStackScreenProps } from '@react-navigation/native-stack';
|
import { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||||
import { RootStackParamList } from '../types';
|
import { RootStackParamList } from '../types';
|
||||||
import { holeApiKey, speichereApiKey, loescheAlleDaten } from '../services/storage';
|
import { holeApiKey, speichereApiKey, loescheAlleDaten } from '../services/storage';
|
||||||
|
import { NUTZT_PROXY } from '../services/claudeClient';
|
||||||
import { useAppStore } from '../store/useAppStore';
|
import { useAppStore } from '../store/useAppStore';
|
||||||
import { GrossButton } from '../components/GrossButton';
|
import { GrossButton } from '../components/GrossButton';
|
||||||
import { farben, schrift, abstand } from '../theme';
|
import { farben, schrift, abstand } from '../theme';
|
||||||
@@ -35,7 +37,7 @@ export function EinstellungenScreen({ navigation }: Props) {
|
|||||||
const allesLoeschen = () => {
|
const allesLoeschen = () => {
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
'Wirklich ALLE Daten löschen?',
|
'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' },
|
{ text: 'Abbrechen', style: 'cancel' },
|
||||||
{
|
{
|
||||||
@@ -57,6 +59,8 @@ export function EinstellungenScreen({ navigation }: Props) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollView style={styles.container} contentContainerStyle={{ padding: abstand.m }}>
|
<ScrollView style={styles.container} contentContainerStyle={{ padding: abstand.m }}>
|
||||||
|
{!NUTZT_PROXY && (
|
||||||
|
<>
|
||||||
<Text style={styles.abschnittTitel}>KI-Zugang (API-Schlüssel)</Text>
|
<Text style={styles.abschnittTitel}>KI-Zugang (API-Schlüssel)</Text>
|
||||||
<Text style={styles.text}>
|
<Text style={styles.text}>
|
||||||
Status:{' '}
|
Status:{' '}
|
||||||
@@ -87,12 +91,15 @@ export function EinstellungenScreen({ navigation }: Props) {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<View style={styles.trenner} />
|
<View style={styles.trenner} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<Text style={styles.abschnittTitel}>Datenschutz</Text>
|
<Text style={styles.abschnittTitel}>Datenschutz</Text>
|
||||||
<View style={styles.karte}>
|
<View style={styles.karte}>
|
||||||
<Text style={styles.text}>
|
<Text style={styles.text}>
|
||||||
• Brieffotos werden nur zur Analyse an Anthropic gesendet und dort
|
• Brieffotos werden nur zur Analyse an den KI-Dienst Anthropic (USA)
|
||||||
nicht dauerhaft gespeichert.{'\n\n'}
|
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.{'\n\n'}
|
• Alle Ergebnisse liegen ausschließlich auf Ihrem Gerät.{'\n\n'}
|
||||||
• Diese App ersetzt keine Rechtsberatung.
|
• Diese App ersetzt keine Rechtsberatung.
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
@@ -2,15 +2,18 @@
|
|||||||
* Brief erfassen: Foto aufnehmen, aus Galerie wählen oder PDF hochladen.
|
* Brief erfassen: Foto aufnehmen, aus Galerie wählen oder PDF hochladen.
|
||||||
* Danach läuft die KI-Analyse; bei Erfolg geht es direkt zum Ergebnis.
|
* 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 { ActivityIndicator, Alert, StyleSheet, Text, View } from 'react-native';
|
||||||
|
import { useFocusEffect } from '@react-navigation/native';
|
||||||
import * as ImagePicker from 'expo-image-picker';
|
import * as ImagePicker from 'expo-image-picker';
|
||||||
import * as DocumentPicker from 'expo-document-picker';
|
import * as DocumentPicker from 'expo-document-picker';
|
||||||
|
import { ImageManipulator, SaveFormat } from 'expo-image-manipulator';
|
||||||
import { File } from 'expo-file-system';
|
import { File } from 'expo-file-system';
|
||||||
import { NativeStackScreenProps } from '@react-navigation/native-stack';
|
import { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||||
import { RootStackParamList, BriefEintrag } from '../types';
|
import { RootStackParamList, BriefEintrag } from '../types';
|
||||||
import { analysiereBrief } from '../services/analyse';
|
import { analysiereBrief } from '../services/analyse';
|
||||||
import { ClaudeFehler } from '../services/claudeClient';
|
import { ClaudeFehler, NUTZT_PROXY } from '../services/claudeClient';
|
||||||
|
import { GRATIS_ANALYSEN, holeAnzahlAnalysen, zaehleAnalyse } from '../services/storage';
|
||||||
import { planeErinnerungen } from '../services/erinnerungen';
|
import { planeErinnerungen } from '../services/erinnerungen';
|
||||||
import { useAppStore, neueId } from '../store/useAppStore';
|
import { useAppStore, neueId } from '../store/useAppStore';
|
||||||
import { GrossButton } from '../components/GrossButton';
|
import { GrossButton } from '../components/GrossButton';
|
||||||
@@ -18,15 +21,68 @@ import { farben, schrift, abstand } from '../theme';
|
|||||||
|
|
||||||
type Props = NativeStackScreenProps<RootStackParamList, 'Scan'>;
|
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) {
|
export function ScanScreen({ navigation }: Props) {
|
||||||
const addBrief = useAppStore((s) => s.addBrief);
|
const addBrief = useAppStore((s) => s.addBrief);
|
||||||
const [laedt, setLaedt] = useState(false);
|
const [laedt, setLaedt] = useState(false);
|
||||||
|
const [verbraucht, setVerbraucht] = useState(0);
|
||||||
|
|
||||||
|
// Zähler bei jedem Screen-Besuch frisch laden
|
||||||
|
useFocusEffect(
|
||||||
|
useCallback(() => {
|
||||||
|
holeAnzahlAnalysen().then(setVerbraucht);
|
||||||
|
}, [])
|
||||||
|
);
|
||||||
|
|
||||||
|
// Freemium gilt nur im Produktionsmodus (Proxy). Im Dev-Modus mit
|
||||||
|
// eigenem API-Key zahlt der Entwickler selbst — kein Limit nötig.
|
||||||
|
const uebrig = GRATIS_ANALYSEN - 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 ${GRATIS_ANALYSEN} 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. */
|
/** Gemeinsamer Abschluss: analysieren, speichern, Erinnerungen, weiter. */
|
||||||
const verarbeite = async (base64: string, mimeType: string) => {
|
const verarbeite = async (base64: string, mimeType: string) => {
|
||||||
setLaedt(true);
|
setLaedt(true);
|
||||||
try {
|
try {
|
||||||
const analyse = await analysiereBrief(base64, mimeType);
|
const analyse = await analysiereBrief(base64, mimeType);
|
||||||
|
await zaehleAnalyse();
|
||||||
const brief: BriefEintrag = {
|
const brief: BriefEintrag = {
|
||||||
id: neueId(),
|
id: neueId(),
|
||||||
erstelltAm: new Date().toISOString(),
|
erstelltAm: new Date().toISOString(),
|
||||||
@@ -49,6 +105,7 @@ export function ScanScreen({ navigation }: Props) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const fotoAufnehmen = async () => {
|
const fotoAufnehmen = async () => {
|
||||||
|
if (!pruefeKontingent()) return;
|
||||||
const { granted } = await ImagePicker.requestCameraPermissionsAsync();
|
const { granted } = await ImagePicker.requestCameraPermissionsAsync();
|
||||||
if (!granted) {
|
if (!granted) {
|
||||||
Alert.alert('Kamera-Zugriff nötig', 'Bitte erlauben Sie den Kamera-Zugriff in den Geräte-Einstellungen.');
|
Alert.alert('Kamera-Zugriff nötig', 'Bitte erlauben Sie den Kamera-Zugriff in den Geräte-Einstellungen.');
|
||||||
@@ -56,16 +113,17 @@ export function ScanScreen({ navigation }: Props) {
|
|||||||
}
|
}
|
||||||
const ergebnis = await ImagePicker.launchCameraAsync({
|
const ergebnis = await ImagePicker.launchCameraAsync({
|
||||||
mediaTypes: 'images',
|
mediaTypes: 'images',
|
||||||
quality: 0.6, // Kompromiss: lesbar für die KI, aber kleine Datenmenge
|
// Volle Qualität vom Picker — verkleinert & komprimiert wird einmal
|
||||||
base64: true,
|
// zentral in bildFuerAnalyse (doppelte JPEG-Kompression vermeiden)
|
||||||
|
quality: 1,
|
||||||
});
|
});
|
||||||
if (!ergebnis.canceled && ergebnis.assets[0]?.base64) {
|
if (!ergebnis.canceled && ergebnis.assets[0]) {
|
||||||
// base64 der ImagePicker-Assets ist laut Doku immer JPEG
|
await verarbeite(await bildFuerAnalyse(ergebnis.assets[0]), 'image/jpeg');
|
||||||
await verarbeite(ergebnis.assets[0].base64, 'image/jpeg');
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const ausGalerie = async () => {
|
const ausGalerie = async () => {
|
||||||
|
if (!pruefeKontingent()) return;
|
||||||
const { granted } = await ImagePicker.requestMediaLibraryPermissionsAsync();
|
const { granted } = await ImagePicker.requestMediaLibraryPermissionsAsync();
|
||||||
if (!granted) {
|
if (!granted) {
|
||||||
Alert.alert('Foto-Zugriff nötig', 'Bitte erlauben Sie den Zugriff auf Ihre Fotos in den Geräte-Einstellungen.');
|
Alert.alert('Foto-Zugriff nötig', 'Bitte erlauben Sie den Zugriff auf Ihre Fotos in den Geräte-Einstellungen.');
|
||||||
@@ -73,15 +131,15 @@ export function ScanScreen({ navigation }: Props) {
|
|||||||
}
|
}
|
||||||
const ergebnis = await ImagePicker.launchImageLibraryAsync({
|
const ergebnis = await ImagePicker.launchImageLibraryAsync({
|
||||||
mediaTypes: 'images',
|
mediaTypes: 'images',
|
||||||
quality: 0.6,
|
quality: 1,
|
||||||
base64: true,
|
|
||||||
});
|
});
|
||||||
if (!ergebnis.canceled && ergebnis.assets[0]?.base64) {
|
if (!ergebnis.canceled && ergebnis.assets[0]) {
|
||||||
await verarbeite(ergebnis.assets[0].base64, 'image/jpeg');
|
await verarbeite(await bildFuerAnalyse(ergebnis.assets[0]), 'image/jpeg');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const pdfWaehlen = async () => {
|
const pdfWaehlen = async () => {
|
||||||
|
if (!pruefeKontingent()) return;
|
||||||
const ergebnis = await DocumentPicker.getDocumentAsync({
|
const ergebnis = await DocumentPicker.getDocumentAsync({
|
||||||
type: 'application/pdf',
|
type: 'application/pdf',
|
||||||
copyToCacheDirectory: true,
|
copyToCacheDirectory: true,
|
||||||
@@ -118,6 +176,13 @@ export function ScanScreen({ navigation }: Props) {
|
|||||||
<GrossButton titel="Foto aufnehmen" symbol="📷" onPress={fotoAufnehmen} />
|
<GrossButton titel="Foto aufnehmen" symbol="📷" onPress={fotoAufnehmen} />
|
||||||
<GrossButton titel="Aus Galerie wählen" symbol="🖼️" variante="sekundaer" onPress={ausGalerie} />
|
<GrossButton titel="Aus Galerie wählen" symbol="🖼️" variante="sekundaer" onPress={ausGalerie} />
|
||||||
<GrossButton titel="PDF hochladen" symbol="📄" variante="sekundaer" onPress={pdfWaehlen} />
|
<GrossButton titel="PDF hochladen" symbol="📄" variante="sekundaer" onPress={pdfWaehlen} />
|
||||||
|
{NUTZT_PROXY && (
|
||||||
|
<Text style={styles.kontingent}>
|
||||||
|
{kontingentLeer
|
||||||
|
? 'Gratis-Analysen aufgebraucht — Abo folgt in Kürze'
|
||||||
|
: `Noch ${uebrig} von ${GRATIS_ANALYSEN} kostenlosen Analysen`}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -145,6 +210,12 @@ const styles = StyleSheet.create({
|
|||||||
padding: abstand.xl,
|
padding: abstand.xl,
|
||||||
gap: abstand.m,
|
gap: abstand.m,
|
||||||
},
|
},
|
||||||
|
kontingent: {
|
||||||
|
fontSize: schrift.klein,
|
||||||
|
color: farben.textSekundaer,
|
||||||
|
textAlign: 'center',
|
||||||
|
marginTop: abstand.m,
|
||||||
|
},
|
||||||
ladeText: { fontSize: schrift.gross, fontWeight: '700', color: farben.text },
|
ladeText: { fontSize: schrift.gross, fontWeight: '700', color: farben.text },
|
||||||
ladeHinweis: {
|
ladeHinweis: {
|
||||||
fontSize: schrift.basis,
|
fontSize: schrift.basis,
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
* Sprache, Fachbegriffe, Fristen, Termine, Checkliste und Antwort-Optionen.
|
* Sprache, Fachbegriffe, Fristen, Termine, Checkliste und Antwort-Optionen.
|
||||||
*/
|
*/
|
||||||
import { BriefAnalyse } from '../types';
|
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
|
* JSON-Schema für die Analyse. Die Claude API erzwingt dieses Schema
|
||||||
@@ -159,6 +159,7 @@ export async function analysiereBrief(
|
|||||||
};
|
};
|
||||||
|
|
||||||
return claudeJsonAufruf<BriefAnalyse>({
|
return claudeJsonAufruf<BriefAnalyse>({
|
||||||
|
modell: MODELL_ANALYSE,
|
||||||
system: SYSTEM_PROMPT,
|
system: SYSTEM_PROMPT,
|
||||||
schema: ANALYSE_SCHEMA,
|
schema: ANALYSE_SCHEMA,
|
||||||
messages: [
|
messages: [
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
* Entwurf danach frei bearbeiten und als PDF exportieren.
|
* Entwurf danach frei bearbeiten und als PDF exportieren.
|
||||||
*/
|
*/
|
||||||
import { AntwortTyp, ANTWORT_TYP_LABEL, BriefAnalyse } from '../types';
|
import { AntwortTyp, ANTWORT_TYP_LABEL, BriefAnalyse } from '../types';
|
||||||
import { claudeJsonAufruf } from './claudeClient';
|
import { claudeJsonAufruf, MODELL_EINFACH } from './claudeClient';
|
||||||
|
|
||||||
const ANTWORT_SCHEMA = {
|
const ANTWORT_SCHEMA = {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
@@ -36,6 +36,8 @@ export async function generiereAntwort(
|
|||||||
hinweise: string
|
hinweise: string
|
||||||
): Promise<AntwortEntwurf> {
|
): Promise<AntwortEntwurf> {
|
||||||
return claudeJsonAufruf<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:
|
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).
|
- 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.
|
- Kurz und präzise, keine Floskeln über das Nötige hinaus.
|
||||||
|
|||||||
@@ -13,16 +13,32 @@
|
|||||||
* deshalb hier bewusst rohes fetch gegen die Messages API.
|
* deshalb hier bewusst rohes fetch gegen die Messages API.
|
||||||
*/
|
*/
|
||||||
import * as Network from 'expo-network';
|
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_URL = 'https://api.anthropic.com/v1/messages';
|
||||||
const ANTHROPIC_VERSION = '2023-06-01';
|
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) ----
|
// ---- Typen für den Request-Body (Teilmenge der Messages API) ----
|
||||||
|
|
||||||
@@ -44,6 +60,8 @@ export interface DocumentBlock {
|
|||||||
export type ContentBlock = TextBlock | ImageBlock | DocumentBlock;
|
export type ContentBlock = TextBlock | ImageBlock | DocumentBlock;
|
||||||
|
|
||||||
export interface ClaudeRequest {
|
export interface ClaudeRequest {
|
||||||
|
/** Welches Modell: MODELL_ANALYSE (Vision) oder MODELL_EINFACH (Text). */
|
||||||
|
modell: string;
|
||||||
system?: string;
|
system?: string;
|
||||||
messages: { role: 'user' | 'assistant'; content: string | ContentBlock[] }[];
|
messages: { role: 'user' | 'assistant'; content: string | ContentBlock[] }[];
|
||||||
/** JSON-Schema für Structured Output — garantiert valides JSON in der Antwort. */
|
/** 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
|
// 2. Request-Body bauen
|
||||||
const body: Record<string, unknown> = {
|
const body: Record<string, unknown> = {
|
||||||
model: MODELL,
|
model: anfrage.modell,
|
||||||
max_tokens: anfrage.maxTokens ?? 16000,
|
max_tokens: anfrage.maxTokens ?? 16000,
|
||||||
// Adaptives Denken verbessert die Extraktions-Genauigkeit deutlich
|
// Kein explizites thinking-Feld: Sonnet 5 denkt adaptiv von selbst
|
||||||
thinking: { type: 'adaptive' },
|
// (gut für die Extraktions-Genauigkeit), Haiku 4.5 unterstützt den
|
||||||
|
// adaptiven Modus nicht und würde die Anfrage ablehnen.
|
||||||
messages: anfrage.messages,
|
messages: anfrage.messages,
|
||||||
};
|
};
|
||||||
if (anfrage.system) body.system = anfrage.system;
|
if (anfrage.system) body.system = anfrage.system;
|
||||||
@@ -96,6 +115,8 @@ export async function claudeJsonAufruf<T>(anfrage: ClaudeRequest): Promise<T> {
|
|||||||
};
|
};
|
||||||
if (PROXY_URL) {
|
if (PROXY_URL) {
|
||||||
url = PROXY_URL; // Der Proxy hält den API-Key serverseitig
|
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 {
|
} else {
|
||||||
const apiKey = await holeApiKey();
|
const apiKey = await holeApiKey();
|
||||||
if (!apiKey) {
|
if (!apiKey) {
|
||||||
@@ -137,8 +158,11 @@ export async function claudeJsonAufruf<T>(anfrage: ClaudeRequest): Promise<T> {
|
|||||||
technisch
|
technisch
|
||||||
);
|
);
|
||||||
case 429:
|
case 429:
|
||||||
|
// Direkt-Modus: API-Rate-Limit. Proxy-Modus: Tageslimit pro Gerät.
|
||||||
throw new ClaudeFehler(
|
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
|
technisch
|
||||||
);
|
);
|
||||||
case 413:
|
case 413:
|
||||||
|
|||||||
@@ -14,6 +14,16 @@ import { BriefEintrag } from '../types';
|
|||||||
const KEY_API = 'anthropic_api_key';
|
const KEY_API = 'anthropic_api_key';
|
||||||
const KEY_BRIEFE = 'behoerdenklar_briefe';
|
const KEY_BRIEFE = 'behoerdenklar_briefe';
|
||||||
const KEY_CONSENT = 'behoerdenklar_consent';
|
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;
|
||||||
|
|
||||||
// ---- API-Key (sensibel -> SecureStore) ----
|
// ---- API-Key (sensibel -> SecureStore) ----
|
||||||
|
|
||||||
@@ -29,6 +39,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) ----
|
// ---- Einwilligung (Datenschutz-Consent) ----
|
||||||
|
|
||||||
export async function holeConsent(): Promise<boolean> {
|
export async function holeConsent(): Promise<boolean> {
|
||||||
@@ -59,6 +105,14 @@ export async function speichereBriefe(briefe: BriefEintrag[]): Promise<void> {
|
|||||||
// ---- Alles löschen (Datenschutz-Anforderung) ----
|
// ---- Alles löschen (Datenschutz-Anforderung) ----
|
||||||
|
|
||||||
export async function loescheAlleDaten(): Promise<void> {
|
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,
|
||||||
|
]);
|
||||||
await SecureStore.deleteItemAsync(KEY_API);
|
await SecureStore.deleteItemAsync(KEY_API);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
* Archiv gecacht (siehe store), damit kein Aufruf doppelt läuft.
|
* Archiv gecacht (siehe store), damit kein Aufruf doppelt läuft.
|
||||||
*/
|
*/
|
||||||
import { BriefAnalyse, Sprache, Uebersetzung } from '../types';
|
import { BriefAnalyse, Sprache, Uebersetzung } from '../types';
|
||||||
import { claudeJsonAufruf } from './claudeClient';
|
import { claudeJsonAufruf, MODELL_EINFACH } from './claudeClient';
|
||||||
|
|
||||||
const UEBERSETZUNG_SCHEMA = {
|
const UEBERSETZUNG_SCHEMA = {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
@@ -36,6 +36,8 @@ export async function uebersetzeAnalyse(
|
|||||||
sprache: Sprache
|
sprache: Sprache
|
||||||
): Promise<Uebersetzung> {
|
): Promise<Uebersetzung> {
|
||||||
return claudeJsonAufruf<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.`,
|
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,
|
schema: UEBERSETZUNG_SCHEMA,
|
||||||
messages: [
|
messages: [
|
||||||
|
|||||||
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)');
|
||||||
|
});
|
||||||
|
});
|
||||||
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",
|
"extends": "expo/tsconfig.base",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"strict": true
|
"strict": true,
|
||||||
}
|
"types": ["jest"]
|
||||||
|
},
|
||||||
|
"exclude": ["proxy", "node_modules"]
|
||||||
}
|
}
|
||||||
|
|||||||
149
webseite/datenschutz.html
Normal file
149
webseite/datenschutz.html
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
<!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.
|
||||||
|
Wir betreiben keine Nutzerkonten und keine Cloud-Speicherung Ihrer Briefe.
|
||||||
|
</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. 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>8. Ä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>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
46
webseite/impressum.html
Normal file
46
webseite/impressum.html
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
<!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>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
75
webseite/index.html
Normal file
75
webseite/index.html
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>BehördenKlar – Behördenbriefe einfach verstehen</title>
|
||||||
|
<meta name="description" content="BehördenKlar erklärt Behördenbriefe in einfacher Sprache. Support und Kontakt.">
|
||||||
|
<style>
|
||||||
|
:root { color-scheme: light; }
|
||||||
|
* { 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; }
|
||||||
|
header { text-align: center; padding: 48px 0 32px; }
|
||||||
|
.logo { font-size: 56px; }
|
||||||
|
h1 { font-size: 32px; margin-top: 12px; color: #144d8c; }
|
||||||
|
.slogan { font-size: 18px; color: #4a5568; margin-top: 8px; }
|
||||||
|
.karte {
|
||||||
|
background: #fff; border-radius: 16px; padding: 24px;
|
||||||
|
margin-top: 24px; box-shadow: 0 2px 8px rgba(20, 77, 140, 0.08);
|
||||||
|
}
|
||||||
|
h2 { font-size: 20px; color: #144d8c; margin-bottom: 12px; }
|
||||||
|
a { color: #1a6bc4; }
|
||||||
|
.kontakt { font-size: 18px; font-weight: 600; }
|
||||||
|
footer { text-align: center; margin-top: 40px; font-size: 15px; color: #4a5568; }
|
||||||
|
footer a { margin: 0 10px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="wrap">
|
||||||
|
<header>
|
||||||
|
<div class="logo">📬</div>
|
||||||
|
<h1>BehördenKlar</h1>
|
||||||
|
<p class="slogan">Behördenbriefe fotografieren – und einfach verstehen.</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="karte">
|
||||||
|
<h2>Was ist BehördenKlar?</h2>
|
||||||
|
<p>
|
||||||
|
BehördenKlar ist eine App für iPhone: Sie fotografieren einen Brief vom
|
||||||
|
Amt, und die App erklärt ihn in einfacher Sprache — mit allen Fristen,
|
||||||
|
Terminen und einer Checkliste, was zu tun ist. Auf Wunsch übersetzt in
|
||||||
|
9 weitere Sprachen.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="karte">
|
||||||
|
<h2>Support & Kontakt</h2>
|
||||||
|
<p>
|
||||||
|
Sie haben eine Frage, ein Problem mit der App oder einen Verbesserungsvorschlag?
|
||||||
|
Schreiben Sie uns — wir antworten so schnell wie möglich:
|
||||||
|
</p>
|
||||||
|
<p class="kontakt" style="margin-top: 12px;">
|
||||||
|
📧 <a href="mailto:behoerdenbriefhelfer@gmail.com">behoerdenbriefhelfer@gmail.com</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="karte">
|
||||||
|
<h2>Wichtiger Hinweis</h2>
|
||||||
|
<p>
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
<a href="impressum.html">Impressum</a> ·
|
||||||
|
<a href="datenschutz.html">Datenschutzerklärung</a>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user