Skip to content

Händlerbereich — Konzept ​

Ziel: Der Händler soll durchgängig das Gefühl haben, auf einer Website zu sein — Login/Logout und Warenkorb sind Teil der Marketing-Site — obwohl das Portal eine eigenständige Laravel/Inertia-App (b2b) neben dem Nuxt-Frontend (staging) ist.

Der Header in staging/components/SiteHeader.vue verlinkt bereits auf /haendlerportal; dieses Dokument beschreibt, was hinter diesem Link technisch entsteht.

Nahtlosigkeit ist kein UI-Problem, sondern ein Cookie-Scope-Problem. Wenn beide Apps unter derselben registrierbaren Domain liegen, teilen sie sich die Session ohne Token-Tanz:

AppDomain (prod)Domain (lokal)
Nuxt-Marketing (staging)www.matev.eustaging.matev.ddev.site
Händlerportal (b2b)haendler.matev.eub2b.matev.ddev.site
PIM (admin)admin.matev.euadmin.matev.ddev.site

Session-Cookie auf .matev.eu (lokal .matev.ddev.site) → beide Apps sehen dieselbe Session.

Konkrete Änderung: b2b/.env.example:35 steht auf SESSION_DOMAIN=null.

dotenv
SESSION_DOMAIN=.matev.eu
SESSION_COOKIE=matev_b2b_session

Falle 1 — Cookie-Kollision: admin und b2b sind beide Laravel. Setzen beide ihr Cookie mit dem Default-Namen auf .matev.eu, überschreiben sie sich gegenseitig die Session — mit sporadischen Logouts, die kaum zu diagnostizieren sind. Jede App braucht einen eigenen SESSION_COOKIE (matev_admin_session / matev_b2b_session).

Falle 2 — Cookie-Reichweite: Getrennte Namen verhindern, dass die Apps sich gegenseitig die Session zerschießen; sie verhindern nicht, dass ein auf .matev.eu gescoptes Cookie bei jedem Request an jede Subdomain mitgesendet wird. Das Händler-Session-Cookie liefe also auch an admin.matev.eu. Deshalb: nur b2b bekommt den Wildcard-Scope, admin bleibt host-scoped (SESSION_DOMAIN=null).

Alternative: Path-Mount statt Subdomain ​

Caddy ist ohnehin zentraler Reverse-Proxy — man könnte b2b unter www.matev.eu/haendler mounten. Dann ist alles same-origin: kein CORS, kein Cookie-Scope-Thema, maximal nahtlos in der URL-Leiste. Preis: Laravel unter einem Pfad-Präfix (Asset-URLs, Route-Prefix, Cookie-Path, Inertia-Bundles) ist erfahrungsgemäß eine dauerhafte Reibungsquelle. Empfehlung: Subdomain. Der URL-Wechsel auf haendler.matev.eu stört nicht, solange Header/Footer identisch aussehen.

Rollenverteilung ​

b2b ist die Auth-Autorität. Fortify liegt dort (b2b/routes/web.php, Login/2FA/Password-Reset komplett vorhanden). Nuxt implementiert kein eigenes Login-Formular und keinen Sanctum-SPA-Flow.

  • Login: Header-Button navigiert nach https://haendler.matev.eu/login?redirect=<aktuelle-url>.
  • Logout: Link auf die Logout-Route in b2b mit intended-Rücksprung.

Das kostet einen Seitenwechsel, spart aber die komplette CSRF-/Sanctum-Verdrahtung und hält Auth an genau einer Stelle. Sehen Header und Footer gleich aus, ist der Wechsel für den Nutzer nicht als "andere Anwendung" wahrnehmbar.

Session- und Warenkorb-Status im Nuxt-Header ​

b2b liefert einen schlanken JSON-Endpoint gegen dieselbe Session:

GET /api/session   (credentials: include, CORS supports_credentials)
→ { "user": { "name": "…", "dealer": "…" } | null,
    "cart": { "count": 3, "total": "1.240,00 €" } }

Der Nuxt-Header rendert daraus Login-Button oder Nutzer-Menü + Warenkorb-Badge.

SSR-Falle: staging/nuxt.config.ts hat kql: { server: { cache: true } }. Wird der Auth-Status im SSR gelesen (Cookie via useRequestHeaders weiterreichen), werden Marketing-Seiten pro Nutzer unterschiedlich und dürfen nicht mehr gecached werden — das ist ein Korrektheitsfehler, nicht nur ein Performance-Thema.

Lösung: Header serverseitig neutral rendern, Auth-Status client-only nachladen (Pinia ist bereits installiert), mit kleinem Skeleton gegen Layout-Shift.

Warenkorb ​

Entschieden: kein Gast-Warenkorb. B2B-Preise sind kundenspezifisch — Price ist im PIM BelongsToOrganization, gilt also pro Händlerbetrieb. Ein Gast sähe entweder keine oder irreführende Preise.

Daraus folgt konkret:

  • Es gibt einen Warenkorb, serverseitig in b2b, an die Session gebunden. Keine zweite Implementierung in Nuxt, keine Merge-Logik beim Login.
  • Bestellt wird im Portal, nicht auf der Marketing-Site. Nuxt-Produktseiten bekommen keinen "In den Warenkorb"-Button, sondern einen Link "Im Händlerportal bestellen" — mit redirect auf die passende Portal-Seite. Ein Add-to-Cart auf einer Seite ohne Preise wäre ohnehin eine Blackbox.
  • Der Warenkorb-Zähler ist damit im Portal-Header zuhause, nicht im Nuxt-Header. Das cart-Feld in GET /api/session bleibt optional (nützlich, falls der Nuxt-Header später doch einen Badge zeigen soll), ist aber nicht tragend.
  • Preise auf der Marketing-Site sind grundsätzlich nicht kundenspezifisch; alles Preisführende lebt hinter dem Login.

Wo lebt was — entkoppelt oder nicht? ​

Technisch entkoppelt, gefühlt durchgehend. Eigene App, eigene Subdomain, eigene session-gebundene Daten — aber gemeinsame Hülle (Header, Footer, Brand, Kirby-Navigation) und im Portal eine zweite Navigationsebene für die Händler-Funktionen. Der Nutzer wechselt nicht die Website, er wechselt den Modus.

InhaltOrtWarum
Marketing, Redaktion, LandingpagesNuxt (Kirby)öffentlich, cachebar
Produkte/Anbaugeräte als PräsentationNuxt (Kirby-Content + PIM-Stammdaten, ohne Preise)Interessenten und SEO
Ersatzteil-Shop mit HändlerpreisenPortalPrice ist BelongsToOrganization — pro Betrieb verschieden
Warenkorb, Bestellungen, BestellhistoriePortalsession-gebunden
Konto, 2FA, Nutzer des BetriebsPortalFortify liegt dort
Händler-Downloads (Preislisten, Montageanleitungen)Portalzugriffsbeschränkt

Der Preis dieser Aufteilung: der Katalog wird zweimal gerendert — einmal öffentlich in Nuxt, einmal preisführend im Portal, aus derselben PIM-Quelle. Das ist bewusst in Kauf genommen: die Alternative wäre, kundenspezifische Preise auf gecachte Marketing-Seiten zu bringen, und genau das verbietet die SSR-Falle oben.

Die Naht wird genau einmal überquert — beim Login. Danach arbeitet der Händler im Portal (Inertia-SPA, schnelle Übergänge) und muss für seine Arbeit nicht mehr zurück auf die Marketing-Site. Sichtbar bleibt: die URL wechselt auf haendler.matev.eu, und der Übergang ist ein voller Seitenwechsel statt eines SPA-Übergangs. Sind Header, Footer, Schriften und Farben identisch, ist das kaum als "andere Anwendung" wahrnehmbar.

Die unterschätzte Naht: Sprache — geschlossen ​

staging/nuxt.config.ts fährt de/en/fr mit prefix_except_default, der Header hat einen LangSwitcher. Das b2b-Starter-Kit hatte kein i18n — ein Händler von /fr/ wäre in einer deutschen Oberfläche ohne Sprachumschalter gelandet, in einem Header, der identisch aussehen soll. Das liest sich stärker als Anwendungswechsel als der URL-Wechsel.

Entschieden: das Portal spricht dieselben drei Sprachen. Umgesetzt über Laravel-Sprachdateien (b2b/lang/{de,en,fr}/portal.php), die als Prop über Inertia mitgehen — keine zweite Übersetzungs-Infrastruktur im Frontend, und serverseitige Mails und PDFs greifen später auf dieselben Schlüssel zu.

Zwei Details, die dabei zählen:

  • Die Sprache liegt in der Session, nicht im Pfad: das Portal ist komplett hinter dem Login und braucht keine indexierbaren Sprach-URLs.
  • Ohne Wahl gilt die erste konfigurierte Portal-Sprache (de), nicht APP_LOCALE. Sonst hängt die Sprache des Händlerportals am Laravel-Default en.

Optische Nahtlosigkeit ​

Nuxt nutzt Pico CSS + Open Props, b2b nutzt Tailwind 4. Damit die Naht nicht sichtbar wird:

  1. Design-Tokens (Farben, Typo, Spacing, Radien) als gemeinsame Quelle in shared/ bzw. design-system/; beide Stacks konsumieren sie.
  2. Header/Footer zweimal implementiert (Nuxt-Vue und Inertia-Vue), aber gegen dieselben Tokens und dasselbe Markup-Gerüst.
  3. Navigation aus Kirby: b2b holt die Nav-Items serverseitig per KQL (kurze Cache-TTL) — sonst driften die Menüs auseinander, sobald die Redaktion etwas ändert.
  4. Brand-CSS kommt heute aus Kirby (siehe staging/layouts/default.vue) — dieser Mechanismus sollte für b2b mitgenutzt und nicht durch einen dritten ersetzt werden.

Nutzer-Identität (zu klären) ​

b2b ist derzeit Starter-Kit-Stand: eigene users-Tabelle in matev_b2b. Im PIM existiert bereits Organization ↔ User als belongsToMany mit is_default-Pivot (admin/app/Models/Organization.php) — also das Modell "Nutzer gehört zu Händlerbetrieb(en)".

Damit Stammdaten nicht auseinanderlaufen: Händler-Accounts gehören nach matev_admin, b2b liest sie über eine zweite DB-Connection; matev_b2b bleibt für portal-spezifische Tabellen (Warenkorb, Bestellentwürfe, Merklisten). Das deckt sich mit docs/architecture.md.

Wichtig: Händler dürfen nicht auf dem Filament-Admin-Guard landen — getrennte Guards, sonst ist der PIM-Zugriff nur eine Rollen-Fehlkonfiguration entfernt.

Umsetzungsreihenfolge ​

  1. Domains/Caddy + SESSION_DOMAIN und getrennte SESSION_COOKIE (admin/b2b) — die Basis, auf der alles andere steht.
  2. Händler-Guard + Organization-Anbindung in b2b.
  3. GET /api/session in b2b + CORS für die Nuxt-Origin.
  4. Header/Footer gegen gemeinsame Tokens, in beiden Apps.
  5. /haendlerportal-Seite in Nuxt (Erklärseite + Login-CTA), Login-/Logout-Redirects.
  6. Ersatzteil-Katalog + Warenkorb im Portal; Nuxt-Produktseiten bekommen den "Im Händlerportal bestellen"-Link.
  7. Sprach-Entscheidung fürs Portal — erledigt: de/en/fr über Laravel-Sprachdateien.

Stand: Klickdummy ​

Die Masken stehen als bedienbarer Inertia-Klickdummy im b2b-Portal — Produktkonfigurator (/konfigurator), Zubehör-Maske (/konfigurator/{slug}), Artikelshop (/artikelshop) und Warenkorb (/warenkorb), dazu Header, Footer, Schnellzugriff-Leiste und Registrierungs-Hinweis.

Zwei Dinge daran sind für die Architektur wichtiger als die Optik:

Die Komponenten sind geteilt, nicht kopiert. Alle Bausteine liegen in design-system/src/components/ und werden per Vite-Alias eingebunden. MatevSiteHeader und MatevSiteFooter sind buchstäblich dieselben Dateien, die die Nuxt-Marketing-Site rendern wird — das ist der Mechanismus hinter der „eine Website"-Wirkung. Damit beide Frameworks funktionieren, injiziert der Konsument seine Link-Komponente über provideLinkComponent() (Inertia-Link bzw. NuxtLink, Fallback <a>); die Lib selbst importiert kein Framework.

Die Datenschicht ist schon headless. Das Portal spricht ausschließlich gegen App\Contracts\CatalogRepository. Heute liefert FixtureCatalogRepository eine JSON-Datei, deren Form bereits den /api/v1-Antworten von admin entspricht. Der Wechsel auf das echte PIM ist ein weiterer Zweig in AppServiceProvider::registerCatalog() — Controller, Komponenten und Warenkorb bleiben unberührt.

Was noch Klickdummy ist: die Preise stammen aus der Fixture und sind für alle Händler gleich (echte Preise sind BelongsToOrganization), die Zusammenfassung im Konfigurator ist noch ohne Ziel, und der Demo-Zugang liegt in einem Seeder statt in matev_admin.

Verworfene Alternativen ​

  • Portal in Nuxt neu bauen: wirft die vorhandene Inertia-App weg und dupliziert Fortify (2FA, Password-Reset).
  • iframe-Einbettung des Portals: Cookie-Restriktionen (SameSite), kaputtes Deep-Linking, schlechte Mobile-UX.
  • Token in localStorage: XSS-exponiert und im SSR nicht lesbar.