# Kundenportal

> Ein Paket kann einen Teil seiner Datensätze im Internet zeigen, für die Kunden der Firma, die es installiert hat. Wie der Abschnitt portal im Manifest funktioniert, was nexxus prüft und was ein Moduldienst antwortet.

Manche Pakete bedienen Menschen, die sich nie anmelden: Die Gäste einer Ferienhausagentur suchen ein Haus, prüfen die Daten und schicken eine Buchungsanfrage. Das **Kundenportal** ist diese öffentliche Seite. Das Paket erklärt in seinem Manifest, welche Datensätze und Felder öffentlich sind und welche Seiten sie zeigen; eine eigene Portal-App zeichnet die Seiten unter der Adresse der Firma. Einen Webserver oder eigenen Code bringt das Paket dafür nicht mit. Code gibt es nur in seinem [Moduldienst](/de/modules/services/), für das, was gerechnet werden muss: freie Tage, ein Preis, eine Buchungsanfrage.

## Wie es zusammenhängt

<Flow
  steps={[
    { icon: 'globe', title: 'Portal-Domain', text: 'Die eigene Adresse der Firma, etwa stay.agentur.example, als Portal-Domain des Mandanten eingetragen.' },
    { icon: 'layout-template', title: 'Portal-App', text: 'Zeichnet die Seiten, die das Paket erklärt, auf dem Server, damit Suchmaschinen sie lesen.', tone: 'cyan' },
    { icon: 'shield-check', title: 'Öffentliche Schnittstelle von nexxus', text: 'Gibt nur die öffentlichen Felder der öffentlichen Datensätze heraus, für den Mandanten dieser Adresse.', tone: 'lavender' },
    { icon: 'server-cog', title: 'Moduldienst', text: 'Beantwortet, was gerechnet werden muss: Verfügbarkeit, Preis, Buchungsanfrage.' },
  ]}
  caption="nexxus steht zwischen Besucher und Paket: Jede Antwort wird auf dem Server gefiltert oder geprüft."
/>

### Portal-Domains

Eine Domain eines Mandanten hat eine Rolle. Seine eigenen Domains (<Ui>Eigene Domains</Ui>) tragen die App und ihre Anmeldung. Seine **Portal-Domains** (<Ui>Portal-Domains</Ui>) tragen nur das Portal: Unter so einer Adresse kann sich niemand anmelden, und Verwaltung, Schnittstelle und Live-Kanal antworten mit `404`, als gäbe es sie nicht. Beide setzt ein Superadmin unter <MenuPath to="Administration > Unternehmen > Mandanten" />. Links, die nexxus für Mitarbeitende schreibt, zeigen immer auf die eigenen Domains.

Jede Antwort der Portal-Schnittstelle gehört dem Mandanten der Adresse, unter der sie aufgerufen wurde. Eine Seite unter `stay.agentur.example` kann keine Datensätze eines anderen Mandanten lesen.

Ein Paket mit Portal je Mandant: Installiert ein Mandant zwei, ist das zuerst installierte die Website.

## Der Abschnitt portal im Manifest

Das Beispiel ist das Ferienhaus-Paket: Gäste blättern durch die Unterkünfte, öffnen eine und schicken eine Anfrage.

```json
"portal": {
  "name": { "en": "Stays", "de": "Unterkünfte" },
  "siteNameSetting": "company_name",
  "public": {
    "listing": {
      "filter": { "status": "active" },
      "fields": ["name", "kind", "region", "city", "description", "bedrooms", "max_guests",
                 "amenities", "base_price", "photos", "location"],
      "geo": { "field": "location", "precision": "approximate" }
    }
  },
  "pages": [
    { "key": "stays", "type": "catalog", "entity": "listing", "path": "",
      "title": { "en": "Holiday homes", "de": "Ferienhäuser" },
      "card": { "titleField": "name", "subtitleFields": ["city", "region"], "imageField": "photos",
                "facts": ["bedrooms", "max_guests"], "priceField": "base_price",
                "priceSuffix": { "en": "per night", "de": "pro Nacht" } },
      "filters": [ { "field": "region" }, { "field": "kind" },
                   { "field": "max_guests", "op": "gte", "label": { "en": "Guests", "de": "Gäste" } } ],
      "dates": { "quote": "quote" }, "map": true, "detail": "stay" },
    { "key": "stay", "type": "detail", "entity": "listing", "path": "stays/:slug", "slugField": "name",
      "title": { "en": "Stay", "de": "Unterkunft" },
      "sections": [
        { "type": "gallery", "field": "photos" },
        { "type": "facts", "fields": ["kind", "bedrooms", "max_guests"] },
        { "type": "text", "field": "description" },
        { "type": "chips", "field": "amenities" },
        { "type": "map", "field": "location" },
        { "type": "availability", "endpoint": "availability" },
        { "type": "booking", "quote": "quote", "request": "booking_request" } ] }
  ],
  "endpoints": [
    { "key": "availability", "method": "GET", "service": "concierge", "params": ["record", "from", "to"] },
    { "key": "quote", "method": "GET", "service": "concierge", "params": ["record", "check_in", "check_out", "guests"] },
    { "key": "booking_request", "method": "POST", "service": "concierge", "params": ["record"],
      "form": [ { "key": "name", "type": "string", "required": true },
                { "key": "email", "type": "email", "required": true },
                { "key": "guests", "type": "number", "required": true },
                { "key": "check_in", "type": "date", "required": true },
                { "key": "check_out", "type": "date", "required": true },
                { "key": "message", "type": "text" },
                { "key": "consent", "type": "consent", "required": true,
                  "label": { "en": "I agree that my details are used to answer my request.",
                             "de": "Ich bin einverstanden, dass meine Angaben zur Beantwortung meiner Anfrage verwendet werden." } } ],
      "rateLimit": { "perIpPerHour": 10 } }
  ]
}
```

Die Seiten rund um ein Objekt kommen aus demselben Paket. Die Objektseite bekommt drei weitere Abschnitte, und vier Seiten kommen dazu: eine für Nachrichten an den Gastgeber, eine Kontaktseite und zwei Rechtsseiten. Gekürzt aus `higgins.rentals` 1.2.1, dessen Objekte für die FAQ außerdem `house_rules`, `checkin_time`, `checkout_time`, `min_nights` und `cleaning_fee` veröffentlichen:

```jsonc
"portal": {
  "public": {
    // Einstellungen für Fußzeile, Kontaktkarte, Gastgeber und Rechtsseiten
    "settings": ["company_name", "company_address", "portal_phone", "portal_email", "portal_hours",
                 "portal_host_intro", "portal_hosting_since", "agency_languages",
                 "cancellation_policy", "privacy_text", "legal_text"],
    "listing": { /* wie oben */ },
    // Die Bewertungen: zweite öffentliche Entität, mit Anzeigenamen statt Gastnamen
    "review": { "fields": ["display_name", "rating", "received_at", "text"] }
  },
  "pages": [
    { "key": "stay", "type": "detail", /* wie oben */
      "sections": [
        /* die Abschnitte von oben, mit amenities statt chips */
        { "type": "amenities", "field": "amenities" },
        { "type": "reviews", "entity": "review", "via": "listing",
          "fields": ["display_name", "rating", "received_at", "text"],
          "nameField": "display_name", "dateField": "received_at",
          "ratingField": "rating", "textField": "text" },
        { "type": "host", "setting": "portal_host_intro",
          "fields": ["portal_hosting_since", "agency_languages"], "page": "inquiry" } ] },
    { "key": "inquiry", "type": "inquiry", "entity": "listing", "path": "stays/:slug/contact",
      "title": { "en": "Message the host", "de": "Gastgeber kontaktieren" },
      "sections": [
        { "type": "faq", "amenities": "amenities", "groups": [
          { "title": { "en": "Arrival and departure", "de": "Anreise und Abreise" },
            "items": [ { "field": "checkin_time" }, { "field": "checkout_time" },
                       { "amenity": "parking",
                         "text": { "en": "Parking at the house", "de": "Parkplatz am Haus" } } ] },
          { "title": { "en": "Price and availability", "de": "Preis und Verfügbarkeit" },
            "items": [ { "field": "min_nights" }, { "field": "cleaning_fee" },
                       { "setting": "cancellation_policy" } ] } ] },
        { "type": "form", "endpoint": "contact" } ] },
    { "key": "contact", "type": "contact", "path": "contact", "nav": true,
      "title": { "en": "Contact", "de": "Kontakt" },
      "sections": [
        { "type": "contact",
          "fields": ["company_address", "portal_phone", "portal_email", "portal_hours"] },
        { "type": "form", "endpoint": "contact" } ] },
    { "key": "privacy", "type": "info", "path": "privacy", "footer": true,
      "title": { "en": "Privacy notice", "de": "Datenschutz" }, "setting": "privacy_text" },
    { "key": "legal", "type": "info", "path": "legal", "footer": true,
      "title": { "en": "Legal notice", "de": "Impressum" }, "setting": "legal_text" }
  ],
  "endpoints": [
    // Für beide Formulare; nur die Anfrageseite schickt ein Objekt als record
    { "key": "contact", "method": "POST", "service": "concierge", "params": ["record"],
      "form": [ { "key": "name", "type": "string", "required": true },
                { "key": "email", "type": "email", "required": true },
                { "key": "message", "type": "text", "required": true, "maxLength": 2000 },
                { "key": "consent", "type": "consent", "required": true,
                  "label": { "en": "I agree that my details are used to answer my message.",
                             "de": "Ich bin einverstanden, dass meine Angaben zur Beantwortung meiner Nachricht verwendet werden." } } ],
      "rateLimit": { "perIpPerHour": 10 } }
  ]
}
```

### Was öffentlich ist

`public.<datenobjekt>` ist das Einzige, was das Portal je von diesem Datenobjekt bekommt.

| Schlüssel | Bedeutung |
|---|---|
| `filter` | Feld und Wert (oder eine Liste von Werten), den jeder öffentliche Datensatz hat, etwa nur aktive Unterkünfte. Die Felder müssen `indexed` sein. Ein Filter des Besuchers hebt ihn nie auf |
| `fields` | Die Felder, die das Portal bekommt, sonst nichts. Erlaubt sind `string`, `text`, `number`, `boolean`, `date`, `datetime`, `enum`, `geo` und ein `ref` auf `core:dmsDocument` (Bilder). Ein Verweis auf einen Kontakt oder einen Benutzer und `json` werden abgelehnt |
| `geo` | `field` und `precision`: `approximate` (der Standard für jedes öffentliche Geo-Feld) rundet die Koordinaten auf dem Server auf zwei Nachkommastellen, etwa 1 km; `exact` gibt sie unverändert weiter |

`public.settings` ist eine Liste von Einstellungen der Installation, die die Website zeigen darf, etwa Telefonnummer, Adresse oder Datenschutzhinweis. Erlaubt sind nur Einstellungen vom Typ `string`, `text` oder `enum`; alle anderen Einstellungen der Installation bleiben privat.

Ein `ref` auf `core:dmsDocument` in `fields` macht diese Dokumente zu öffentlichen Bildern. Das Portal bekommt sie als `{ "id", "url", "alt" }`, `alt` aus der Beschreibung des Dokuments. Die Adresse ist signiert und an Mandant, Datensatz und Dokument gebunden; nexxus liefert das Bild nur, solange ein öffentliches Feld eines öffentlichen Datensatzes es noch nennt, und nur als JPEG, PNG, WebP, GIF oder AVIF. Mit `?w=400`, `800`, `1200` oder `1600` liefert es das Bild auf diese Breite verkleinert, für `srcset` auf dem Handy.

### Seiten

| Schlüssel | Bedeutung |
|---|---|
| `key`, `title` | Name der Seite und ihre Überschrift |
| `type` | `catalog` (eine Liste mit Karten, Filtern und auf Wunsch einer Karte), `detail` (ein Datensatz), `inquiry` (eine Frage zu einem Datensatz, mit dem Slug der Detailseite), `contact` (die Kontaktseite der Agentur, ohne Datensatz) oder `info` (eine Textseite wie der Datenschutzhinweis, ohne Datensatz) |
| `entity` | Ein Datenobjekt aus `public`; nicht auf `contact`- und `info`-Seiten |
| `path` | Unterhalb der Startseite, eindeutig; `""` ist die Startseite. Eine `detail`- oder `inquiry`-Seite enthält genau einmal `:slug`, die anderen Typen keins |
| `nav`, `footer` | `true` setzt einen Link auf die Seite in die Kopfzeile oder in die Fußzeile |
| `setting` | `info`-Seite: eine öffentliche Einstellung vom Typ `string` oder `text`, als Absätze gezeigt (eine Leerzeile beginnt einen neuen) |
| `slugField` | Detailseite: ein öffentliches `string`-Feld, aus dem die Adresse entsteht (aus `Ocean View Villa` wird `ocean-view-villa`; ein zweiter Datensatz gleichen Namens bekommt seine Id angehängt) |
| `card` | Katalog: `titleField`, `subtitleFields`, `imageField` (Bilder; ohne ist die Karte reiner Text), `facts`, `priceField` (eine Zahl), `priceSuffix`. Ohne `priceField` und `dates.quote` hat die Karte keine Preiszeile |
| `filters` | Katalog: öffentliche Felder, die `indexed` sind; `op` `eq` (Standard), `gte` oder `lte` (Zahlen und Daten). Auswahlfelder werden Chips; Textfelder mit Werten zur Auswahl, eine `gte`-Zahl (Gäste) und `dates` kommen in die Suchleiste, die wegfällt, wenn sie nichts davon hat |
| `dates.quote` | Katalog: ein `GET`-Endpunkt, der einen Aufenthalt bepreist, sobald Daten gewählt sind |
| `map`, `detail` | Katalog: eine Karte zeigen (braucht ein öffentliches Geo-Feld); der Schlüssel der Detailseite. Eine Markierung trägt den Preis aus `card.priceField`, wenn dieses Feld eine `currency` hat, und den angebotenen Gesamtpreis, sobald Daten gesetzt sind und die Unterkunft frei ist. Eine Detailseite führt zurück zum Katalog ihrer eigenen `entity` |
| `sections` | `detail`- und `inquiry`-Seiten: `gallery` (Bilder), `facts`, `text`, `chips`, `amenities` (ein Auswahlfeld, mit Symbolen gezeigt), `map` (Geo), `availability` (ein `GET`-Endpunkt), `booking` (`quote`: `GET`, `request`: `POST`), `reviews`, `host`, `faq`. Nur auf `detail`-Seiten: `slots` (`endpoint`: `GET`, `request`: `POST`). Jede Seite: `contact` (öffentliche Einstellungen in `fields`) und `form` (das Formular eines `POST`-Endpunkts, direkt auf der Seite) |

Jedes Feld und jede Einstellung, die eine Seite nennt, muss öffentlich sein.

Die neueren Abschnitte im Einzelnen:

| Abschnitt | Schlüssel |
|---|---|
| `amenities` | `field`: ein öffentliches Auswahlfeld. Ein Wert davon kann `icon` tragen, einen kurzen Schlüssel wie `wifi` aus dem Symbolsatz des Portals; ohne zeigt das Portal ein neutrales Häkchen |
| `reviews` | `entity` (ein anderes öffentliches Datenobjekt), `via` (sein Verweis auf das Datenobjekt der Seite), `fields` (öffentliche Felder dieses Datenobjekts) und welches davon Name, Datum, Bewertung und Text ist: `nameField`, `dateField`, `ratingField`, `textField`. Das Portal zeigt die neuesten zuerst, mit der Durchschnittsbewertung. Veröffentlichen Sie einen Anzeigenamen wie „Anna K.“, nie den vollen Namen oder die E-Mail-Adresse eines Gastes |
| `host` | `setting` (ein Vorstellungstext), `fields` (weitere öffentliche Einstellungen, etwa „Gastgeber seit“), `page` (die `inquiry`-Seite, zu der „Nachricht an den Gastgeber“ führt; ohne sie fehlt der Knopf), `endpoint` (wird angenommen, die Portal-App nutzt es aber nicht: Die Anfrageseite bringt ihr eigenes `form` mit) |
| `faq` | `groups`, jede mit `title` und `items`. Ein Eintrag ist eines von `{"field": ...}` (ein öffentliches Feld des Datensatzes), `{"setting": ...}` (eine öffentliche Einstellung) oder `{"amenity": ..., "text": {...}}` (ein Text, der erscheint, wenn der Datensatz diesen Wert des Auswahlfelds hat, das der Abschnitt in `amenities` nennt); jeder Eintrag kann ein eigenes `label` tragen |
| `slots` | Freie Termine statt eines Zeitraums von Nächten. `endpoint`: ein `GET`-Endpunkt mit den Parametern `record`, `from` und `to`, der die Termine auflistet. `request`: ein `POST`-Endpunkt mit den Parametern `record` und dem `slotParam`, dessen Formular der Besucher für den gewählten Termin ausfüllt. `slotParam`: der Name, unter dem der gewählte Termin geschickt wird, nie `record`, `lang` oder `website`. `days`: wie weit ein Besucher vorausblättern kann, 7 bis 90 Tage, ohne Angabe 28. `title` ersetzt die Überschrift <Ui>Termin wählen</Ui> |

Eine Praxis, deren Patienten einen Termin anfragen, erklärt `slots` auf der Seite einer Behandlung. Das Paket gibt `"platform": ">=0.13.0 <1.0.0"` an, die erste Plattformversion mit diesem Abschnitt:

```jsonc
"pages": [
  { "key": "treatment", "type": "detail", "entity": "treatment", "path": "treatments/:slug", "slugField": "name",
    "title": { "en": "Treatment", "de": "Behandlung" },
    "sections": [
      { "type": "gallery", "field": "photos" },
      { "type": "text", "field": "description" },
      { "type": "slots", "endpoint": "slots", "request": "appointment", "slotParam": "slot", "days": 28,
        "title": { "en": "Choose a time", "de": "Termin wählen" } } ] }
],
"endpoints": [
  { "key": "slots", "method": "GET", "service": "practice", "params": ["record", "from", "to"] },
  { "key": "appointment", "method": "POST", "service": "practice", "params": ["record", "slot"],
    "form": [ { "key": "name", "type": "string", "required": true },
              { "key": "email", "type": "email", "required": true },
              { "key": "consent", "type": "consent", "required": true,
                "label": { "en": "I agree that my details are used to arrange the appointment.",
                           "de": "Ich bin einverstanden, dass meine Angaben zur Vereinbarung des Termins verwendet werden." } } ],
    "rateLimit": { "perIpPerHour": 10 } }
]
```

Eine `detail`-Seite mit `slots` und ohne `booking` hat keine Buchungskarte daneben.

### Endpunkte

Ein Endpunkt ist etwas, das das Portal den Dienst des Pakets fragen darf, über nexxus.

| Schlüssel | Bedeutung |
|---|---|
| `key` | Name, eindeutig im Portal |
| `method` | `GET` oder `POST` |
| `service` | Ein Dienst des Pakets (`services`) |
| `params` | Abfrageparameter, die weitergegeben werden. `record` muss einen öffentlichen Datensatz des Datenobjekts der Seiten nennen, die den Endpunkt nutzen; das prüft nexxus vor dem Aufruf. `lang` ist reserviert |
| `form` | Nur bei `POST`. Felder mit `type` `string` (bis 500 Zeichen), `text` (bis 5000), `email`, `number`, `date` (`JJJJ-MM-TT`), `consent` (muss angehakt sein, wenn `required`) oder `boolean` (ein Kästchen zum Anhaken, gesendet als `true` oder `false`); `required`, `label`, `maxLength`. `website` ist reserviert |
| `rateLimit.perIpPerHour` | Aufrufe je Besucheradresse und Stunde, höchstens 10000; ohne Angabe 2000 bei `GET` (ein Katalog mit Daten fragt je Karte einen Preis an) und 20 bei `POST` |

Bevor etwas beim Dienst ankommt, zählt nexxus den Aufruf gegen das Limit, lässt jeden nicht erklärten Parameter weg, prüft den Datensatz und prüft das Formular: unbekannte Felder, fehlende Pflichtfelder, falsche Typen, eine ungültige E-Mail-Adresse oder eine fehlende Einwilligung werden mit den Feldern abgelehnt. Die Portal-App zeichnet ein verstecktes Feld `website`, das Menschen nie sehen; eine Anfrage, die es ausfüllt, bekommt eine freundliche Antwort und geht nirgendwohin.

## Was Besucher sehen

Die Portal-App zeichnet jede Seite zuerst auf dem Server, damit Suchmaschinen den Inhalt lesen, und arbeitet dann im Browser weiter. Sie speichert nichts auf dem Gerät der Besucher: keine Cookies, keinen lokalen Speicher, keinen anderen Browserspeicher. Eine Website damit braucht deshalb keinen Cookie-Banner. Schreiben Sie das stattdessen in den Datenschutzhinweis, wie es das Ferienhauspaket tut.

Jedes Portal bietet Deutsch und Englisch an, die Standardsprache der Website zuerst. Polnisch kommt dazu, wenn die Sprache des Mandanten Polnisch ist oder das Paket für sein Portal Polnisch mitbringt (einen `pl`-Text in `portal.name`); eine Website, die Polnisch nie gewählt hat, behält ihre zwei Sprachen. Die Sprache des Besuchers kommt aus `?lang=`, sonst aus dem Browser (die erste angebotene Sprache seines Accept-Language-Headers), sonst ist es die Standardsprache der Website. Die eigenen Texte des Portals gibt es in allen drei Sprachen. Die Texte des Pakets erscheinen in der Sprache des Besuchers, sonst in der Standardsprache der Website, sonst auf Englisch; ein Paket ohne polnische Texte zeigt einem polnischen Besucher also die der Standardsprache.

### Katalog

Die Suchleiste enthält die Filter, die Reisedaten und die Zahl der Gäste; Auswahlfilter erscheinen als Chips über der Liste. Sind Daten gewählt, zeigt jede Karte den Preis für diesen Aufenthalt oder dass die Unterkunft dann nicht frei ist.

Mit `map: true` stehen Liste und Karte auf breiten Bildschirmen nebeneinander, und die Liste folgt der Karte: Verschiebt oder zoomt der Besucher sie, zeigt die Liste nur noch, was im sichtbaren Ausschnitt liegt, 400 ms nachdem die Karte zur Ruhe gekommen ist, mit der Zeile <Ui>8 Treffer in diesem Kartenausschnitt</Ui>. <Ui>Beim Verschieben der Karte suchen</Ui> schaltet das für den Besuch ab; dann bietet eine verschobene Karte <Ui>In diesem Gebiet suchen</Ui> an. <Ui>Alle zeigen</Ui> holt alle Treffer zurück und passt die Karte an sie an. Auf dem Handy ist die Karte eine eigene Ansicht (<Ui>Karte zeigen</Ui>); nach dem Verschieben übernimmt <Ui>In diesem Gebiet suchen</Ui> den Ausschnitt. Der Ausschnitt wird nie Teil der Adresse: Ein geteilter Link und die Seite, die Suchmaschinen sehen, zeigen immer alle Treffer.

nexxus filtert auf dem Server. Die Abfrage der Datensätze nimmt `bbox=W,S,E,N` (West, Süd, Ost, Nord) in Grad; ist der Westrand größer als der Ostrand, reicht der Ausschnitt über die Datumsgrenze. Verglichen wird mit derselben gerundeten Lage, die die Markierungen zeigen, damit Liste und Markierungen übereinstimmen.

### Objektseite

- <Ui>Teilen</Ui> neben dem Titel öffnet das Teilen-Menü des Handys; wo es keines gibt, kopiert es den Link und meldet <Ui>Link kopiert</Ui>.
- Die Reisedaten öffnen <Ui>Daten wählen</Ui>, mit Anreise und Abreise oben. Vergangene und belegte Tage sind durchgestrichen.
- `amenities`: unter <Ui>Das bietet diese Unterkunft</Ui> die Werte mit ihren Symbolen, ab Tablet-Breite in zwei Spalten. Ab dem elften öffnet ein Knopf wie <Ui>Alle 13 Ausstattungsmerkmale ansehen</Ui> die ganze Liste.
- `reviews`: Die Überschrift zeigt die Durchschnittsbewertung und die Zahl der Bewertungen. Darunter folgen bis zu sechs Bewertungen mit Name, Monat, Sternen und Text; ein langer Text endet nach vier Zeilen mit <Ui>Mehr anzeigen</Ui>. Ein Knopf wie <Ui>Alle 8 Bewertungen ansehen</Ui> öffnet alle. Eine Bewertung ohne Anzeigenamen erscheint als <Ui>Gast</Ui>; ein Objekt ohne Bewertungen hat keinen Bewertungsabschnitt.
- `host`: <Ui>Ihr Gastgeber</Ui> zeigt Symbol oder Logo der Firma (sonst ihren Anfangsbuchstaben), ihren Namen, die Einstellungen aus `fields` mit ihren Bezeichnungen, die Vorstellung und <Ui>Nachricht an den Gastgeber</Ui>. Der Knopf öffnet die `inquiry`-Seite und nimmt die gewählten Daten und Gäste mit.

<Screenshot src={reviewsHost} url="/de/stays/villa-salt-rock" alt="Drei Bewertungen mit Namen, Monat, Sternen und Text, darunter Ihr Gastgeber mit dem Logo der Agentur, ihren Angaben und der Vorstellung" caption="Bewertungen und Gastgeber auf einer Objektseite. Die Buchungskarte bleibt daneben stehen." />

### Freie Termine

Ein `slots`-Abschnitt zeigt die freien Termine des Datensatzes Woche für Woche, Montag bis Sonntag, beginnend mit der laufenden Woche und nie mit einem Termin, der schon vorbei ist. <Ui>Vorherige Woche</Ui> und <Ui>Nächste Woche</Ui> blättern bis `days` Tage voraus. Auf breiten Bildschirmen ist die Woche sieben Spalten breit, auf Handys eine Liste der Tage mit freien Terminen. Unter einer Uhrzeit steht ihr `label`, etwa die Ärztin oder der Arzt. Eine Woche ohne freie Termine meldet <Ui>Keine freien Termine in dieser Woche</Ui> und bietet die nächste an. Die Uhrzeiten gelten in der Zeitzone, die der Dienst nennt; ist das nicht die Zeitzone des Besuchers, sagt eine Zeile unter der Woche, welche es ist.

Ein gewählter Termin öffnet <Ui>Ihr Termin</Ui> mit Datum und Uhrzeit und dem Formular von `request`; <Ui>Anderen Termin wählen</Ui> führt zurück und behält, was schon eingetragen ist. <Ui>Termin anfragen</Ui> schickt es ab. Danach sieht der Besucher <Ui>Termin angefragt</Ui> mit der Referenz, die der Dienst zurückgibt. Hat jemand anderes den Termin inzwischen genommen, meldet das Portal <Ui>Dieser Termin ist gerade vergeben worden.</Ui> und lädt die Woche neu. Auf Handys führt die Leiste unten zu den Terminen.

### Anfrageseite

Die `inquiry`-Seite (im Ferienhauspaket `stays/:slug/contact`) trägt die Überschrift <Ui>Fynbos & Tide Holiday Homes kontaktieren</Ui> mit dem Namen der Website, zeigt die Angaben zum Gastgeber und die `faq`-Gruppen unter <Ui>Das fragen Gäste am häufigsten</Ui>, dann das Formular unter <Ui>Noch Fragen? Schreiben Sie dem Gastgeber</Ui>. Daneben zeigt eine Karte Foto und Namen des Objekts mit Daten und Gästen; <Ui>Verfügbarkeit prüfen</Ui> führt mit ihnen zurück zur Objektseite. Nach dem Absenden sieht der Besucher die Referenz, die der Dienst zurückgibt. Die Seite steht nicht in der Sitemap, und Suchmaschinen werden gebeten, sie nicht aufzunehmen.

<Screenshot src={inquiry} url="/de/stays/villa-salt-rock/contact" alt="Fynbos & Tide Holiday Homes kontaktieren, mit den Angaben zum Gastgeber und den häufigsten Fragen der Gäste, daneben eine Karte mit Foto und Reisedaten des Objekts" caption="Die Anfrageseite. Das Formular folgt unter den Fragen." />

### Kontaktseite, Infoseiten und Fußzeile

Seiten mit `nav: true` stehen in der Kopfzeile, auf dem Handy unter <Ui>Menü</Ui>. Die `contact`-Seite zeigt die Karte der Firma aus dem Abschnitt `contact` (Anschrift, eine Telefonnummer zum Anrufen, eine E-Mail-Adresse zum Schreiben, Erreichbarkeit) und das Formular des Abschnitts `form`; nach dem Absenden meldet sie <Ui>Nachricht gesendet</Ui> mit der Referenz. Eine `info`-Seite zeigt den Text ihrer Einstellung als Absätze; solange dieser Text leer ist, antwortet die Seite mit `404`, und ihr Link verschwindet aus der Fußzeile.

<Screenshot src={contact} url="/de/contact" alt="Die Kontaktseite mit der Karte der Agentur links und dem Nachrichtenformular rechts" caption="Die Kontaktseite: links der Abschnitt contact, rechts der Abschnitt form." />

Die Fußzeile steht auf jeder Seite: der Name der Website mit Anschrift, Telefonnummer und E-Mail-Adresse, die Links zu den Seiten mit `footer: true` und die Copyright-Zeile mit dem laufenden Jahr. Das Portal findet diese Angaben unter den öffentlichen Einstellungen an ihrem Schlüssel (er enthält `address`; `phone`, `tel` oder `mobile`; `email` oder `e-mail`) und prüft bei Telefon und E-Mail, ob der Wert danach aussieht.

### Symbole für Ausstattung

Ein Wert eines `amenities`-Felds bekommt im Manifest `"icon": "<Schlüssel>"`. Die Schlüssel unten bleiben stabil: Neue kommen dazu, keiner wird umbenannt oder entfernt. Ein unbekannter oder fehlender Schlüssel zeigt ein Häkchen.

`accessible`, `aircon`, `baby`, `balcony`, `bath`, `bbq`, `bed`, `bike`, `boat`, `car`, `check`, `coffee`, `dishwasher`, `dryer`, `fireplace`, `fridge`, `garden`, `gym`, `heating`, `hot-tub`, `kitchen`, `mountain-view`, `microwave`, `no-smoking`, `parking`, `pets`, `ev-charger`, `pool`, `safe`, `sauna`, `sea-view`, `self-checkin`, `shower`, `terrace`, `tv`, `beach`, `washer`, `wifi`, `winter`, `workspace`

## Was der Dienst bekommt und antwortet

nexxus ruft `POST <Dienst-URL>/portal/<Endpunkt>` mit einem Aufruf-Token, dessen `trigger` `portal:<Endpunkt>` ist. Dahinter steht keine Person: Ruft der Dienst zurück, handelt er als Dienstkonto der Installation, wie bei einem Ereignis. Der Rumpf:

```json
{
  "endpoint": "quote",
  "method": "GET",
  "params": { "record": 12, "check_in": "2026-12-20", "check_out": "2026-12-27", "guests": "4" },
  "form": null,
  "locale": "en",
  "installation": { "id": 7, "version": "1.2.0", "settings": { "company_name": "Higgins Stays" } }
}
```

`locale` ist die Sprache des Besuchers, `de`, `en` oder `pl`. `params` sind die erklärten Parameter, wie der Besucher sie geschickt hat, `record` als Zahl. Bei `POST` enthält `form` die geprüften Felder mit ihren Typen: Zahlen als Zahlen, `consent` als `true`.

Für einen `slots`-Abschnitt fragt das Portal den `endpoint` je eine Woche: `from` und `to` sind Daten (`YYYY-MM-DD`), beide eingeschlossen. Der Dienst antwortet mit den freien Terminen dieser Tage in der Zeitzone, in der sie gezeigt werden; ein Tag ohne freie Termine darf fehlen oder eine leere Liste haben, und ein Termin in der Vergangenheit darf nicht dabei sein. Bieten zwei Personen dieselbe Uhrzeit an, trägt jeder der beiden Termine eine eigene `id`, eine beliebige Zeichenfolge bis 200 Zeichen; das Portal liest sie nicht:

```json
{ "timeZone": "Europe/Berlin",
  "days": [ { "date": "2026-10-22",
              "slots": [ { "id": "beispiel-0830", "start": "2026-10-22T08:30:00+02:00",
                           "end": "2026-10-22T09:00:00+02:00", "label": "Dr. Beispiel" },
                         { "id": "muster-0830", "start": "2026-10-22T08:30:00+02:00",
                           "end": "2026-10-22T09:00:00+02:00", "label": "Dr. Muster" } ] } ] }
```

Der `request`-Endpunkt bekommt die `id` des gewählten Termins, ohne `id` seinen `start`, genau so, wie der Dienst sie geliefert hat, als den Parameter, den `slotParam` nennt. Ohne `id` kämen zwei Termine mit demselben `start` als derselbe Wert an; das Portal zeigt deshalb nur den ersten. Der Dienst prüft den Termin noch einmal: Ist er noch frei, antwortet er wie auf eine Buchungsanfrage (`{ "ok": true, "reference": "T-0042", "message": { "de", "en" } }`); ist er vergeben, antwortet er `409`, und das Portal zeigt seine eigene Meldung und lädt die Woche neu.

Der Dienst antwortet mit JSON, einem Objekt oder einer Liste, höchstens 64 KB. nexxus gibt es unverändert an das Portal. Lehnt der Dienst ab, antwortet er `400`, `404`, `409` oder `422` mit `{ "errorCode", "message", "messages": { "de", "en", "pl" }, "fieldErrors": { "<feld>": "..." } }`, `pl` optional. nexxus gibt diese Schlüssel als kurzen reinen Text weiter (eine Meldung als `{ "de", "en", "pl" }` in der Sprache des Besuchers, sonst auf Englisch, sonst auf Deutsch) und weitere Schlüssel nur als kurze Codes, Zahlen oder ja/nein, etwa `"reason": "booked"`; alles andere fällt weg. Ist der Dienst nicht erreichbar oder braucht er länger als 10 Sekunden, wird daraus `503 SERVICE_UNAVAILABLE`, jeder andere Fehler wird `502 SERVICE_ERROR`. Den eigenen Fehlertext des Dienstes sieht der Besucher nie.

## Kundenbereich

Das Portal hat auch eine angemeldete Seite: Die Kunden eines Maklers sehen ihre Verträge, die Eigentümer eines Ferienhauses ihre Buchungen. Sie sind **Mitglieder**, keine Benutzer der App. Jedes Mitglied gehört zu einem Datensatz des Pakets (seiner Firma, seinem Objekt), sieht nur, was das Paket für seine Zielgruppe freigibt, und handelt mit den Rechten der Person, die es eingeladen hat, seiner **verantwortlichen Person**. Die Historie des Datensatzes zeigt diese Person, das Modulprotokoll nennt das Mitglied.

Das Manifest beschreibt eine Zielgruppe unter `portal.members`: die Entität, zu der ein Mitglied gehört (`scope`), die Datensätze und Felder, die es liest, was es anlegen darf, seine Aktionen, Ansichten und Rollen:

```json
"members": {
  "kunde": {
    "label": { "de": "Kundenbereich", "en": "Client area" },
    "scope": "firma",
    "start": "konto.start",
    "records": {
      "firma":   { "fields": ["name", "ampel"] },
      "vertrag": { "via": "firma", "fields": ["titel", "sparte", "ablauf", "dokument"] }
    },
    "views": ["konto.start", "konto.vertraege"],
    "actions": ["nachweis-einreichen"],
    "uploads": { "folder": ["Kundenbereich", "{{scope.title}}"], "maxMb": 20 },
    "roles": {
      "leitung": { "label": { "de": "Geschäftsführung", "en": "Management" }, "admin": true, "secondFactor": true },
      "technik": { "label": { "de": "Technik", "en": "Facilities" }, "views": ["konto.start"] }
    }
  }
}
```

- **Anmeldung** auf der Portal-Domain unter `/konto`, mit einem Link per E-Mail, ohne Passwort. Eine Rolle mit `secondFactor` verlangt zusätzlich einen Code aus einer Authenticator-App. Mitarbeitende laden jemanden auf der Seite des Datensatzes ein, Reiter <Ui>Kundenbereich</Ui>, oder bitten den Assistenten darum; ein `admin`-Mitglied verwaltet seine Kollegen selbst. Eine Einladung trägt eine Sprache, `de`, `en` oder `pl` (Standard: die des Mandanten), und die Mails zur Einladung und zur Anmeldung gibt es in allen drei.
- **Jede Seite nach der Anmeldung ist eine Ansicht des Pakets** mit dem Slot `member`, fensterfüllend. nexxus zeichnet nur die Anmeldeseiten darum, mit dem Logo der Firma, damit der Bereich nach dem Paket aussieht und nicht nach nexxus. Was so eine Ansicht aufrufen darf, steht unter [Ansichten und das View-SDK](/de/modules/views/).
- **Was ein Mitglied liest, prüft der Server** bei jedem Aufruf: den Weg `via` vom Datensatz zu seinem eigenen, die Filter der Zielgruppe, die Felder seiner Rolle. Ein Datensatz außerhalb antwortet mit `404`, als gäbe es ihn nicht.

## Zustimmung bei der Installation

Ein Paket mit Portal fragt nach der Zustimmung `portal:public`, eines mit Kundenbereich zusätzlich nach `portal:members` (wer, welche Daten, was die Mitglieder tun dürfen). Der Installationsdialog zeigt in einem Satz, den nexxus aus dem Manifest baut, was öffentlich wird: welche Datensätze, welche Felder, dass Bilder und eine gerundete Lage mitgehen und welchen Dienst Besucher aufrufen können. Ein Update, das etwas anderes veröffentlicht (ein weiteres Feld, einen anderen Filter, einen anderen Endpunkt), fragt erneut; ohne Zustimmung bleibt das Paket auf seiner Version.

## Was die Paketprüfung verlangt

Das Hochladen wird abgelehnt, mit einer Zeile je Fund, wenn ein Portal ein Datenobjekt, Feld, eine Seite, einen Endpunkt oder Dienst nennt, den es nicht gibt, ein Feld eines Typs veröffentlicht, der nicht öffentlich sein darf, nach einem Feld filtert, das nicht `indexed` ist, einen Seitenpfad doppelt vergibt, in einem Seitenabschnitt ein Feld des falschen Typs nennt oder einen Endpunkt mit der falschen Methode aufruft. Bei `slots` prüft sie außerdem, dass der Abschnitt auf einer `detail`-Seite steht, dass `endpoint` die Parameter `record`, `from` und `to` erklärt, `request` die Parameter `record` und den `slotParam`, und dass `days` zwischen 7 und 90 liegt.