Appearance
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.
Die eine Entscheidung, aus der alles folgt: Cookie-Scope
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:
| App | Domain (prod) | Domain (lokal) |
|---|---|---|
Nuxt-Marketing (staging) | www.matev.eu | staging.matev.ddev.site |
Händlerportal (b2b) | haendler.matev.eu | b2b.matev.ddev.site |
PIM (admin) | admin.matev.eu | admin.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_sessionFalle 1 — Cookie-Kollision:
adminundb2bsind 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 eigenenSESSION_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.eugescoptes Cookie bei jedem Request an jede Subdomain mitgesendet wird. Das Händler-Session-Cookie liefe also auch anadmin.matev.eu. Deshalb: nurb2bbekommt den Wildcard-Scope,adminbleibt 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
b2bmitintended-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.tshatkql: { server: { cache: true } }. Wird der Auth-Status im SSR gelesen (Cookie viauseRequestHeadersweiterreichen), 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
redirectauf 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 inGET /api/sessionbleibt 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.
| Inhalt | Ort | Warum |
|---|---|---|
| Marketing, Redaktion, Landingpages | Nuxt (Kirby) | öffentlich, cachebar |
| Produkte/Anbaugeräte als Präsentation | Nuxt (Kirby-Content + PIM-Stammdaten, ohne Preise) | Interessenten und SEO |
| Ersatzteil-Shop mit Händlerpreisen | Portal | Price ist BelongsToOrganization — pro Betrieb verschieden |
| Warenkorb, Bestellungen, Bestellhistorie | Portal | session-gebunden |
| Konto, 2FA, Nutzer des Betriebs | Portal | Fortify liegt dort |
| Händler-Downloads (Preislisten, Montageanleitungen) | Portal | zugriffsbeschrä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-Defaulten.
Optische Nahtlosigkeit
Nuxt nutzt Pico CSS + Open Props, b2b nutzt Tailwind 4. Damit die Naht nicht sichtbar wird:
- Design-Tokens (Farben, Typo, Spacing, Radien) als gemeinsame Quelle in
shared/bzw.design-system/; beide Stacks konsumieren sie. - Header/Footer zweimal implementiert (Nuxt-Vue und Inertia-Vue), aber gegen dieselben Tokens und dasselbe Markup-Gerüst.
- Navigation aus Kirby:
b2bholt die Nav-Items serverseitig per KQL (kurze Cache-TTL) — sonst driften die Menüs auseinander, sobald die Redaktion etwas ändert. - Brand-CSS kommt heute aus Kirby (siehe
staging/layouts/default.vue) — dieser Mechanismus sollte fürb2bmitgenutzt 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
- Domains/Caddy +
SESSION_DOMAINund getrennteSESSION_COOKIE(admin/b2b) — die Basis, auf der alles andere steht. - Händler-Guard + Organization-Anbindung in
b2b. GET /api/sessioninb2b+ CORS für die Nuxt-Origin.- Header/Footer gegen gemeinsame Tokens, in beiden Apps.
/haendlerportal-Seite in Nuxt (Erklärseite + Login-CTA), Login-/Logout-Redirects.- Ersatzteil-Katalog + Warenkorb im Portal; Nuxt-Produktseiten bekommen den "Im Händlerportal bestellen"-Link.
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.