# Blut24 — Marketing- und Registrierungsseite

Öffentliche Website für **Blut24**, die Plattform für Blutkonserven und Blutprodukte.
Sie erklärt das Produkt und nimmt die Registrierung von **Kliniken** und
**medizinischen Kurierdiensten** entgegen — inklusive
Klinik-Autovervollständigung, E-Mail-Domain-Prüfung, Double-Opt-in und einer
abschaltbaren Übergabe an das Blut24/LogShip-ERP.

Eigenständiges Repository. Es hat **keine Laufzeit-Abhängigkeit** zum ERP-Repo
(`../erp-nuxt-frontend`); der ERP wird — falls aktiviert — ausschließlich über HTTP
angesprochen.

---

## Stack

| Bereich | Technologie |
|---|---|
| Framework | Nuxt 4 (SSR) + Nitro |
| Styling | Tailwind CSS v4 (Vite-Plugin, kein Nuxt-Modul) + Design-Tokens in `app/assets/css/main.css` |
| Datenbank | SQLite via `better-sqlite3` (Klinikverzeichnis mit FTS5, Registrierungen) |
| E-Mail | `nodemailer` gegen einen lokalen MTA (localhost:25, ohne Auth) |
| Icons/Bilder | ausschließlich handgezeichnetes Inline-SVG und CSS-Verläufe, keine externen Assets |
| Schriften | System-Schriftstack (bewusst **kein** Google-Fonts-CDN → DSGVO, keine Fremdanfragen) |
| Sprachen | Deutsch (Vorgabe) und Englisch — eigenes Katalog-Composable, **kein** `@nuxtjs/i18n` (siehe *Sprachen*) |

---

## Schnellstart

```bash
npm install --legacy-peer-deps   # siehe Hinweis unten
npm run import:clinics           # data/clinics.db aus dem Klinik-Atlas-XML bauen
npm run dev                      # http://localhost:3000
```

Produktion:

```bash
npm run import:clinics
npm run build
node .output/server/index.mjs    # PORT=3000 HOST=0.0.0.0
```

> **`--legacy-peer-deps`:** npm 10.9.8 bricht bei diesem Abhängigkeitsbaum mit
> `Cannot read properties of null (reading 'edgesOut')` ab (npm-Bug in der
> Peer-Auflösung). Mit `--legacy-peer-deps` installiert es sauber durch; die
> Anwendung selbst ist davon nicht betroffen.

---

## Datenpflege: Klinikverzeichnis

Grundlage ist der offene Datensatz des **Bundes-Klinik-Atlas** (Transparenzverzeichnis,
`data/2026-06-30_TVERZ_Export.xml`, 1.581 Standorte).

```bash
npm run import:clinics
# optional mit anderer Quelle / anderem Ziel:
node scripts/import-clinics.mjs --source data/2026-12-31_TVERZ_Export.xml --out data/clinics.db
```

Der Import liest `Standort > StandortKontaktDaten` (`STOID`, `Land`, `Name`, `Strasse`,
`PLZ`, `Ort`, `URL`, `Telefon`, `EMail`, `TraegerArt`, `Breitengrad`, `Laengengrad`) und
`StandortStrukturDaten/@AnzahlBetten`, leitet je Standort die **registrierbare
E-Mail-Domain** ab (aus `EMail`, ersatzweise `URL`; `www.` entfernt, Subdomains auf die
eTLD+1 reduziert, Freemail-Adressen verworfen) und schreibt `data/clinics.db` neu.

Ergebnis des aktuellen Datenstands:

```
Standorte importiert : 1581
mit E-Mail-Domain    : 1556 (98,4 %)
ohne Domain          : 25   (1,6 %)  → manuelle Freigabe
eindeutige Domains   : 1206
Domains mit mehreren Standorten (Klinikkonzerne): 280
    helios-gesundheit.de   78 Standorte
    asklepios.com          45 Standorte
    sana.de                41 Standorte
    helios-kliniken.de     24 Standorte
    ameos.de               21 Standorte
```

Schema von `data/clinics.db`:

| Tabelle | Inhalt |
|---|---|
| `clinics` | ein Datensatz je Standort (`stoid` eindeutig), inkl. `domain` (primär) und `domains` (alle) |
| `clinic_domains` | `(domain, stoid, source)` — Domain → Standorte, der Schlüssel für die Verbund-Auflösung |
| `clinics_fts` | FTS5 (contentless, `unicode61 remove_diacritics 2`) über Name, Ort, Straße, PLZ, STOID inkl. Umlautvarianten (`München`/`Muenchen`/`Munchen`) |
| `meta` | Quelldatei, Importzeitpunkt, Zählwerte |

`data/clinics.db` ist ein **Build-Artefakt** und in `.gitignore` — nach jedem Deployment
neu erzeugen.

---

## Sprachen

Die Seite ist vollständig zweisprachig: **Deutsch ist die Vorgabe**, Englisch wird über den
Schalter `DE | EN` in der Kopfzeile erreicht (zwei echte `<button>`, tastaturbedienbar,
`aria-pressed`, sichtbarer Fokusring).

### Warum kein `@nuxtjs/i18n`

Bei 12 Komponenten, vier Seiten und einem Formular kostet `@nuxtjs/i18n` mehr, als es
bringt: es zieht `vue-i18n` samt Build-Transform ein und seine Routing-Strategien wollen
den URL-Raum besitzen (`/en/...`), was jeden bestehenden In-Page-Anker (`/#vorteil`) und
jeden Link brechen würde. Stattdessen: ein typisierter Katalog plus ein Composable von
rund 40 Zeilen, ohne zusätzliche Abhängigkeit — und beide Sprachen bleiben unter **einer**
URL, ein geteilter Link funktioniert also immer.

| Datei | Inhalt |
|---|---|
| `app/locales/de.ts` | deutscher Katalog — **Leitbild der Struktur** (`export type Messages = typeof de`) |
| `app/locales/en.ts` | englischer Katalog, als `Messages` typisiert → fehlender Schlüssel = Typfehler |
| `app/locales/detect.ts` | `Locale`, `isLocale`, `localeFromAcceptLanguage` (abhängigkeitsfrei, direkt testbar) |
| `app/composables/useLocale.ts` | aktive Sprache, `t`, `setLocale` |
| `server/utils/i18n.ts` | serverseitige Texte: Validierung, API-Antworten, Typbezeichnungen, **Mails** |

### Auflösung und Persistenz

Reihenfolge: **`?lang=` → Cookie `b24_lang` → `Accept-Language` → Deutsch.**
Deutsch gewinnt immer, wenn der Header fehlt, unlesbar oder mehrdeutig ist — Englisch nur
bei echt höherer Gewichtung (`en;q=0.9,de;q=0.9` → Deutsch). Die aufgelöste Sprache wird im
Cookie festgehalten (ein Jahr, `SameSite=Lax`, keine Kennung; im Datenschutztext genannt).

Die Sprache liegt in `useState`, wird also **einmal auf dem Server** aufgelöst, in die
Payload serialisiert und bei der Hydration unverändert übernommen — es gibt keine zweite,
clientseitige Auflösung, die abweichen könnte. `<html lang>` folgt der aktiven Sprache
(`app.vue`); `nuxt.config.ts` liefert nur noch die Vorgabe `de` für den Moment davor.

### E-Mails

Die Sprache reist mit dem Formular (`lang` im Submit-Body) und wird in der Spalte
`pending_registrations.lang` **gespeichert**. Bestätigungs-, Willkommens- und
Duplikatsmail richten sich nach dieser Spalte, nicht nach der Anfrage, die sie auslöst:
wer sich auf Englisch registriert, bekommt auch dann eine englische Willkommensmail, wenn
der Bestätigungslink später in einem deutschen Browser geöffnet wird. Die interne
Benachrichtigung an `NUXT_NOTIFY_EMAIL` bleibt bewusst deutsch — sie geht an den Betreiber.

> Bestehende Datenbanken kennen die Spalte `lang` nicht. `server/utils/registrationDb.ts`
> ergänzt sie beim Öffnen additiv (`COLUMN_MIGRATIONS`), nur wenn sie fehlt, und
> protokolliert einen Fehlschlag statt zu werfen. Altdatensätze gelten als `de`.

### Impressum und Datenschutz

Beide sind **deutsche Rechtstexte**. Die englische Fassung ist eine Verständnishilfe und
sagt das auch: `<LegalTranslationNotice />` erscheint ausschließlich auf der englischen
Ansicht, benennt die deutsche Fassung als allein verbindlich und verlinkt direkt dorthin.
Deutsche Rechtsbegriffe, die einen **regulierten Status** bezeichnen, werden nicht in eine
ausländische Kategorie übersetzt — „Freiberufler" bleibt stehen und wird erläutert
(*freelancer*, *sole trader* o. Ä. wären eine Statusbehauptung nach fremdem Recht).
Paragraphenverweise (§ 5 DDG, § 18 Abs. 2 MStV, § 38 BDSG, § 27a UStG) behalten ihre
deutschen Kurznamen; DSGVO wird als *GDPR* wiedergegeben, weil es dasselbe Instrument ist.

---

## Registrierung

### Ablauf

Das Formular liegt in einem **modalen Dialog** (`app/components/RegistrationDialog.vue`,
einmalig in `app.vue` eingehängt). Jeder Aufruf zur Registrierung — Kopfzeile, Hero,
Kurierdienst-Abschnitt und der Abschnitt `#registrierung` — öffnet dieselbe Instanz über
`useRegistrationDialog()`, das Formular behält also seinen Inhalt beim Schließen und
erneuten Öffnen. CTAs, die den Typ bereits benennen („Als Kurierdienst registrieren"),
übergeben eine Vorauswahl für Schritt 1.

> **Zurücksetzen beim Öffnen, nicht beim Schließen.** Genau weil der Zustand das Schließen
> überlebt, zeigte der Dialog nach einer erfolgreichen Absendung beim nächsten Öffnen
> weiterhin den Bestätigungsbildschirm. `openDialog()` zählt deshalb `resetToken` hoch;
> `RegistrationForm` setzt daraufhin **nur dann** komplett zurück, wenn `done` gesetzt ist,
> und leert sonst lediglich eine stehengebliebene Fehlermeldung. Wer den Dialog mitten im
> Tippen schließt, behält seine Eingaben. `openDialog()` schreibt außerdem `presetType`
> immer (auch leer), damit eine frühere Rollenvorauswahl nicht in ein „Registrierung
> starten" hineinleckt.
>
> Der Erfolgsbildschirm hat **keinen** „Weitere Einrichtung registrieren"-Knopf mehr; an
> seiner Stelle steht „Fenster schließen" (`dismissible`), neben ✕, ESC und Klick auf den
> Hintergrund.

1. **Typ wählen** — Klinik · Medizinischer Kurierdienst.

   > **Zwei Typen, nicht drei.** Kliniken wählten früher zwischen „abgebende" und
   > „beziehende Klinik". Diese Unterscheidung änderte keine einzige Validierungsregel und
   > war an dieser Stelle die falsche Frage — die meisten Universitätskliniken sind beides.
   > Sie wird jetzt **einmal bei der ersten Anmeldung in Blut24** gestellt (mindestens eine
   > Rolle ist Pflicht) und dort nativ über `C_BPartner.IsVendor` / `IsCustomer` geführt,
   > die beide gleichzeitig gesetzt sein dürfen. Die Partnergruppe bleibt die grobe Rolle
   > (Kliniken vs. Medizinische Kurierdienste).
   >
   > `abgebende_klinik` und `beziehende_klinik` werden **als Eingabe abgelehnt** (sie
   > stehen nirgends mehr zur Wahl), auf dem **Lesepfad** aber weiterhin akzeptiert:
   > Bestandszeilen und Bestätigungslinks in Postfächern tragen sie noch. `normaliseType()`
   > in `server/utils/registrationTypes.ts` bildet sie auf `klinik` ab — Etiketten,
   > Bestätigung per Token, Mails und ERP-Onboarding laufen unverändert durch.
   > **Keine Migration, keine Änderung an bestehenden Zeilen.**
2. **Einrichtung**
   * Kliniken: Autovervollständigung über `GET /api/clinics/search?q=…` (FTS, 220 ms
     Debounce, Tastaturbedienung als ARIA-Combobox). Adresse wird aus dem Atlas übernommen.
   * Kurierdienste: freies Firmenformular (kein Atlas-Abgleich).
3. **E-Mail-Prüfung**
   * Freemail-Anbieter (gmail, gmx, web.de, t-online, outlook, yahoo, hotmail …) werden
     abgelehnt — Liste in `lib/domains.mjs`.
   * Die Domain der Adresse muss zur gewählten Klinik gehören. Da Klinikkonzerne **eine**
     Domain über viele Häuser teilen (Helios: 78 Standorte), wird eine Domain immer auf die
     **Menge** ihrer Standorte aufgelöst (`GET /api/clinics/by-domain`) und der Nutzer wählt
     seinen Standort aus.
   * Standorte ohne hinterlegte Domain (25 Stück) und alle Kurierdienste laufen über
     **manuelle Freigabe** (`needs_manual_approval = 1`) — das sind die beiden Fälle, in
     denen der Server nichts prüfen *kann*.
   * **Alles andere wird automatisch freigeschaltet** (`needs_manual_approval = 0`): eine
     zur gewählten Klinik passende Domain ebenso wie eine Adresse aus
     `REGISTRATION_TEST_DOMAINS`. Nach der Bestätigung legt das ERP den Zugang an und
     verschickt die Zugangsdaten; niemand wartet auf eine Freigabe. Testregistrierungen
     bleiben trotzdem als solche erkennbar — `domain_verified = 0` und
     `[TESTREGISTRIERUNG via …]` in `note`.
4. **Doppelanmeldung** — je Standort (STOID) ist genau eine aktive Registrierung möglich
   (partieller UNIQUE-Index). Ein zweiter Versuch erhält **dieselbe neutrale Antwort** wie
   eine erfolgreiche Anmeldung (keine Auskunft darüber, wer bereits registriert ist);
   intern wird er als `status = 'duplicate'` protokolliert und optional per Mail gemeldet.
5. **Double-Opt-in** — Bestätigungsmail mit Token-Link auf `/bestaetigung?token=…`,
   14 Tage gültig. Die Bestätigung läuft **clientseitig** beim Laden der Seite, damit
   Link-Scanner in Mail-Gateways (kein JavaScript) keine Registrierung auslösen können.
6. **Bestätigt** → ERP-Onboarding (siehe unten), **danach** die Willkommensmail. Die
   Reihenfolge ist keine Kosmetik: die Mail formuliert je nach Ergebnis „Zugang ist
   eingerichtet, Zugangsdaten kommen separat“, „wir prüfen manuell“ oder „wir richten
   gerade ein“ — sie kann also erst geschrieben werden, wenn das ERP geantwortet hat.

### Endpunkte

| Methode | Pfad | Zweck |
|---|---|---|
| `GET` | `/api/clinics/search?q=&limit=` | Autovervollständigung (min. 2 Zeichen, max. 15 Treffer) |
| `GET` | `/api/clinics/by-domain?domain=` / `?email=` | alle Standorte hinter einer Domain |
| `POST` | `/api/registration/submit` | Registrierung speichern + Bestätigungsmail |
| `POST` | `/api/registration/confirm` | Token bestätigen, ERP-Onboarding auslösen |

Die Klinik-Endpunkte geben **nie** die im Atlas hinterlegte Klinik-E-Mail-Adresse aus.

### Speicher

`data/registrations.db`, Tabelle **`pending_registrations`** (Schema in
`server/utils/registrationDb.ts`): Typ, Status (`pending` · `confirmed` · `duplicate` ·
`cancelled`), STOID, Einrichtung + Anschrift, Kontaktdaten, `email_domain`,
`domain_verified`, `needs_manual_approval`, `lang`, ERP-Ergebnis, IP und User-Agent,
Zeitstempel.
Bewusst eine **eigene** Datei, weil `clinics.db` bei jedem Import gelöscht und neu
geschrieben wird.

> Alt-Datenbanken tragen noch die Spalte `contact_salutation` (Anrede). Das Feld wurde
> aus Formular, Validierung, Speicherung und Mails entfernt; die Spalte wird weder
> geschrieben noch gelesen. Da sie `NOT NULL DEFAULT ''` ist, bleiben `INSERT`s ohne sie
> gültig — **keine Migration nötig**, Altwerte bleiben lesbar.

Spam- und Missbrauchsschutz: verstecktes Honeypot-Feld (`website`) und eine einfache
In-Memory-Drossel von 6 Anfragen je IP und 10 Minuten. Echtes Rate-Limiting gehört in den
Reverse-Proxy.

---

## ERP-Onboarding (Feature-Flag, standardmäßig aus)

`server/utils/erpOnboarding.ts` ist das **einzige** Modul mit ERP-Bezug und klar als solches
markiert. Nach einer bestätigten Registrierung ruft es serverseitig auf:

1. `POST {ERP_ONBOARDING_URL}/api/admin/merchant-onboarding/process`
   → legt im iDempiere C_BPartner, C_Location, C_BPartner_Location, AD_Org, AD_User,
   M_Warehouse, M_Locator, AD_Role und AD_User_Roles an und liefert
   `{ status, report, ctx:{ bpartnerId, newOrgId, userId, … } }`.
2. `POST {ERP_ONBOARDING_URL}/api/admin/merchant-onboarding/send-credentials`
   mit `resetPassword: true` → erzeugt ein neues Passwort, schreibt es nach
   `AD_User.Password` und mailt die Zugangsdaten an die Kontaktadresse.

Der Benutzername (`shortName` = `AD_Org.Value` = `AD_User.Value`) wird aus Klinikname und
STOID gebildet, z. B. `dkd-helios-klinik-wiesba-260828`.

**Solange `ERP_ONBOARDING_URL` oder `ERP_SERVICE_TOKEN` fehlt, wird der Aufruf
übersprungen** — mit einer Log-Zeile, ohne Fehler; die Bestätigungsseite funktioniert
unverändert und die Registrierung wird als `erp_status = 'disabled'` markiert.

Registrierungen mit `needs_manual_approval` (alle Kurierdienste sowie Kliniken ohne
hinterlegte E-Mail-Domain) werden **nicht** automatisch angelegt: `confirm.post.ts` setzt
für sie `erp_status = 'manual-approval'` und überspringt das Onboarding, bis ein Operator
sie manuell anlegt.

Authentifizierung gegenüber dem ERP: der Token wird als Cookie
`logship_it=<ERP_SERVICE_TOKEN>` gesendet (die ERP-Routen lesen ihren Aufrufer über
`getTokenHelper()` ausschließlich aus diesem Cookie — ein reiner
`Authorization: Bearer`-Header wird ignoriert), zusammen mit
`logship_organization_id=<ERP_ORGANIZATION_ID>` und `X-Blut24-Service: 1`
(nur Log-Attribution, keine Autorität). Die ERP-Route `send-credentials` ist inzwischen
abgesichert und akzeptiert Maschinenaufrufe nur, wenn der gesendete Token **exakt dem
`IDEMPIERETOKEN` der ERP-Instanz entspricht** — `ERP_SERVICE_TOKEN` muss also genau dieser
Wert sein; `process` autorisiert sich implizit über die iDempiere-Berechtigungen des Tokens.

---

## Umgebungsvariablen

Vorlage: `.env.example`.

| Variable | Standard | Bedeutung |
|---|---|---|
| `NUXT_PUBLIC_SITE_URL` | `https://app.blut24.com` | Basis-URL für den Bestätigungslink |
| `NUXT_PUBLIC_ANDROID_APK_URL` | `#app-download` | Ziel des Android-Buttons (Platzhalter) |
| `NUXT_CLINIC_DB_PATH` | `data/clinics.db` | Pfad zum Klinikverzeichnis |
| `NUXT_REGISTRATION_DB_PATH` | `data/registrations.db` | Pfad zur Registrierungs-DB |
| `NUXT_SMTP_HOST` / `NUXT_SMTP_PORT` | `localhost` / `25` | lokaler MTA, ohne Auth |
| `NUXT_MAIL_FROM` | `Blut24 <no-reply@blut24.com>` | Absender |
| `NUXT_MAIL_BCC` | – | stille Kopie aller ausgehenden Mails |
| `NUXT_NOTIFY_EMAIL` | – | interne Benachrichtigung bei neuen/bestätigten Registrierungen |
| `ERP_ONBOARDING_URL` | – | **Feature-Flag**, Basis-URL des ERP |
| `ERP_SERVICE_TOKEN` | – | **Feature-Flag**, iDempiere-Token; als Cookie `logship_it` gesendet und muss dem `IDEMPIERETOKEN` der ERP-Instanz entsprechen |
| `ERP_ORGANIZATION_ID` | – | Betreiber-Organisation im ERP |
| `ERP_COUNTRY_ID` | `208` | `C_Country_ID` Deutschland |
| `ERP_PRICE_LIST_ID` | – | optionale Preisliste |
| `ERP_GROUP_CLINIC` / `ERP_GROUP_COURIER` | `1000002` / `1000003` | `C_BP_Group` „Kliniken" / „Medizinische Kurierdienste" |

---

## Tests

```bash
npm run build
npm run test:registration
```

`scripts/test-registration.mjs` startet den gebauten Nitro-Server gegen eine
Wegwerf-Datenbank (`data/test-registrations.db`) und einen unerreichbaren SMTP-Port und
prüft über echtes HTTP mit echten Datensätzen aus `clinics.db`: Autovervollständigung
(Name, PLZ, Umlautvarianten, Mindestlänge, Datensparsamkeit), Domain-Auflösung für
Klinikkonzerne, Annahme/Ablehnung von Domains und Freemail, manuelle Freigabe bei fehlender
Domain, Kurierdienst-Formular, Honeypot, stiller Duplikatsschutz und das komplette
Double-Opt-in inklusive deaktiviertem ERP-Gate. Abschnitt 8 prüft zusätzlich die
Sprachumschaltung: Antwortsprache der API, Rückfall auf Deutsch bei fehlendem oder
unbekanntem `lang`, Speicherung der Sprache auf dem Datensatz, übersetzte Typbezeichnung
in der Bestätigung sowie `<html lang>` aus `?lang=`, Cookie und `Accept-Language`
(Deutsch gewinnt bei Gleichstand) und die beiden Anker `#vorteil`/`#verfall`.
Aktueller Stand: **54 Prüfungen, 0 Fehler** (38 bestehende + 16 neue).

Nichts wird gemockt; jede Anfrage trägt eine eigene `X-Forwarded-For`, damit die IP-Drossel
die Testläufe nicht verfälscht.

---

## Gestaltung

Umgesetzt nach der Regel „Healthcare / Medical Clinic" der `ui-ux-pro-max`-Skill:
Muster **Trust & Authority + Conversion**, Stil **Accessible & Ethical + Minimalism**.

* Markenfarben: Rot `#C62828` `#B71C1C` `#EF5350`, Blau `#1565C0` `#0D47A1` `#64B5F6`,
  Fläche `#F8FAFC`. Kontrast auf Weiß: 5,6:1 / 6,6:1 / 5,7:1 / 8,6:1 — alle ≥ WCAG AA.
* Fließtext 17 px, Zeilenhöhe 1,65; Bedienelemente mindestens 44 px hoch.
* Sichtbarer 3-px-Fokusring, Skip-Link, semantische Überschriftenhierarchie, `role="alert"`
  an Feldfehlern, ARIA-Combobox mit Tastaturbedienung, `prefers-reduced-motion` respektiert.
* Registrierungsdialog auf Basis des nativen `<dialog>` + `showModal()`: Fokusfalle,
  Top-Layer und inerter Hintergrund kommen von der Plattform; ergänzt um Scroll-Sperre,
  Schließen per ESC und Klick auf den Hintergrund, Fokusrückgabe an den auslösenden
  Button, `aria-modal` + `aria-labelledby`. Unter 640 px als Vollbild-Sheet, darüber als
  zentrierte Karte mit eigenem Scrollbereich.
* Kein Neon, keine bewegungslastigen Effekte, keine violett/pinken KI-Verläufe, keine Emoji
  als Icons, keine externen Bilder oder Schriften.

Seiten: `/` (Landing), `/bestaetigung` (Double-Opt-in), `/impressum`, `/datenschutz`.

Der frühere Abschnitt „Verfall & Kosten" heißt jetzt **„Ihr Vorteil"**
(`SectionBenefit.vue`, vormals `SectionWaste.vue`) und führt mit dem Gewinn statt mit dem
Verlust. Die Anker-Id ist `#vorteil`; **`#verfall` bleibt als zweiter Anker auf demselben
Abschnitt bestehen**, weil die alte Adresse in Mails und Lesezeichen stehen kann.

Dass die Nutzung **kostenlos** ist, steht bewusst dreifach und weit oben: als
hervorgehobener Block direkt unter dem Hero-Text, als volle Zeile am Kopf des Proof-Bands
und noch einmal unmittelbar am Registrierungs-CTA sowie im Dialogkopf. Formuliert wird nur,
was tatsächlich kostenlos ist — Registrierung und Nutzung für Kliniken, Teilnahme für
medizinische Kurierdienste.

> **Impressum und Datenschutz sind Platzhalter.** Alle Angaben in eckigen Klammern müssen
> vor dem Livegang ersetzt und juristisch geprüft werden.

---

## Projektstruktur

```
app/
  assets/css/main.css          Design-Tokens (@theme) + Basis-/Komponentenklassen
  components/                  AppIcon, BrandMark, SiteHeader/Footer, Section*,
                               RegistrationDialog (modaler <dialog>) + RegistrationForm,
                               LegalTranslationNotice (nur auf der englischen Rechtsansicht)
  composables/                 useRegistrationDialog (offen/zu, Vorauswahl, Reset beim
                               Öffnen, Fokusrückgabe) · useLocale (Sprache, t, setLocale)
  locales/                     de.ts · en.ts · detect.ts · index.ts
  pages/                       index · bestaetigung · impressum · datenschutz
  app.vue                      Skip-Link + Layout + RegistrationDialog (einmalig)
server/
  api/clinics/                 search.get.ts · by-domain.get.ts
  api/registration/            submit.post.ts · confirm.post.ts
  utils/                       clinicDb · registrationDb · registrationValidation · mail ·
                               domains · i18n (Server- und Mailtexte) ·
                               erpOnboarding (Feature-Flag)
lib/domains.mjs                Domain-/Freemail-Logik, geteilt von Skript, Server und Browser
scripts/import-clinics.mjs     XML → data/clinics.db
scripts/test-registration.mjs  End-to-End-Prüfung
public/                        blut24-logo.svg · blut24-icon.svg
data/                          Klinik-Atlas-XML (eingecheckt), *.db (generiert)
```
