- GlitchTip über die Sentry-SDKs; Datenschutzfilter (nur User-ID, keine
Request-Bodys, Tokens aus URL/Query/Referer, E-Mails aus Meldungen).
Browser-SDK erst beim ersten Fehler nachgeladen.
- ReportErrorSignals: tote interne Links (404 mit eigenem Referrer), 403/429
für Angemeldete, 419 als gedrosselte Warnung.
- /health (Bearer-Token): DB, Cache, Queue-Rückstau, Scheduler-Herzschlag,
Plattenplatz → 200/503; Scheduler und Plattenplatz auch im Systemstatus.
- OperationalAlert zusätzlich per ntfy (JSON-POST), unabhängig von der Mail.
- Verlauf: login_failed (Grund unbekannt/kein Passwort/falsch),
login_lockout, two_factor_failed; IP nur als gekürzter HMAC.
- Umami: Kundenbereich mit eigenem Schalter und identify('user-<id>'),
nie im Admin/bei Impersonation; Query-Filter auch auf den Webseiten
(signierte Links gingen bisher an Umami).
- queue:prune-failed nach 30 Tagen.
- Versionsnummer: config('app.version') aus APP_VERSION oder Datei RELEASE
(git describe --tags), in GlitchTip, /health, Systemstatus, artisan about.
Plan: dev/betrieb/monitoring-plan.md (Entscheidungen M1–M9).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
176 lines
7 KiB
PHP
176 lines
7 KiB
PHP
<!DOCTYPE html>
|
||
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
|
||
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||
|
||
@php
|
||
$siteName = $domainName ?? config('app.name', 'Laravel');
|
||
// Inline-@section('title', …)-Werte kommen von Blade bereits escaped an —
|
||
// einmal dekodieren, damit {{ }} unten nicht doppelt escapt („&amp;").
|
||
$pageTitle = html_entity_decode(trim($__env->yieldContent('title')), ENT_QUOTES, 'UTF-8') ?: $siteName;
|
||
$pageDescription = html_entity_decode(trim($__env->yieldContent('meta_description')), ENT_QUOTES, 'UTF-8') ?: $siteName;
|
||
// Self-Canonical als Default aus der Portal-URL + Request-Pfad (ohne Query):
|
||
// pro Portal/Domain getrennt und unabhängig von URL::forceRootUrl – verhindert Cross-Portal-Duplicate.
|
||
$portalBase = \App\Support\StaticPageCanonical::selfBase($domainUrl ?? null, ($webPortalContextApplied ?? false) === true);
|
||
// Statische Seiten liegen auf Hub UND beiden Portalen mit identischem
|
||
// Text (Anhang 2, E10): dort führt ein Host, die anderen verweisen
|
||
// dorthin. Rechtstexte sind ausgenommen — siehe config/canonical.php.
|
||
$canonicalUrl = trim($__env->yieldContent('canonical'))
|
||
?: (\App\Support\StaticPageCanonical::urlFor(request()->route()?->getName(), request()->getPathInfo())
|
||
?: $portalBase.request()->getPathInfo());
|
||
$ogType = trim($__env->yieldContent('og_type')) ?: 'website';
|
||
$ogImage = trim($__env->yieldContent('og_image'));
|
||
@endphp
|
||
|
||
<meta name="description" content="{{ $pageDescription }}">
|
||
|
||
<title>{{ $pageTitle }}</title>
|
||
|
||
@php
|
||
$robotsDirective = trim($__env->yieldContent('robots'));
|
||
@endphp
|
||
|
||
{{-- Robots-Anweisung (z. B. noindex für private Vorschauen) --}}
|
||
@if ($robotsDirective !== '')
|
||
<meta name="robots" content="{{ $robotsDirective }}">
|
||
@endif
|
||
|
||
{{-- Kanonische URL (entfällt bei noindex — es gibt keine öffentliche URL) --}}
|
||
@if (! str_contains($robotsDirective, 'noindex'))
|
||
<link rel="canonical" href="{{ $canonicalUrl }}">
|
||
@endif
|
||
|
||
{{-- Sprach-Alternates (de/en) – pro Seite optional gesetzt --}}
|
||
@hasSection('hreflang')
|
||
@yield('hreflang')
|
||
@endif
|
||
|
||
{{-- RSS-Autodiscovery (Roadmap 3.1) – Portal-Feed und, auf Branchenseiten, der Feed der Branche --}}
|
||
@yield('feed_links')
|
||
|
||
{{-- OpenGraph / Social --}}
|
||
<meta property="og:type" content="{{ $ogType }}">
|
||
<meta property="og:site_name" content="{{ $siteName }}">
|
||
<meta property="og:title" content="{{ $pageTitle }}">
|
||
<meta property="og:description" content="{{ $pageDescription }}">
|
||
<meta property="og:url" content="{{ $canonicalUrl }}">
|
||
<meta property="og:locale" content="{{ app()->getLocale() === 'de' ? 'de_DE' : 'en_US' }}">
|
||
@if ($ogImage)
|
||
<meta property="og:image" content="{{ $ogImage }}">
|
||
@endif
|
||
<meta name="twitter:card" content="{{ $ogImage ? 'summary_large_image' : 'summary' }}">
|
||
<meta name="twitter:title" content="{{ $pageTitle }}">
|
||
<meta name="twitter:description" content="{{ $pageDescription }}">
|
||
@if ($ogImage)
|
||
<meta name="twitter:image" content="{{ $ogImage }}">
|
||
@endif
|
||
|
||
{{-- Seitenspezifische Zusatz-Metas (z. B. article:* auf PM-Detailseiten) --}}
|
||
@yield('meta_extra')
|
||
|
||
{{-- Strukturierte Daten (JSON-LD) – pro Seite optional gesetzt --}}
|
||
@yield('structured_data')
|
||
|
||
<!-- Domain-spezifisches Favicon -->
|
||
<link rel="icon" href="{{ asset(\App\Helpers\ThemeHelper::getFaviconPath()) }}" type="image/svg+xml">
|
||
<link rel="apple-touch-icon" href="{{ asset(\App\Helpers\ThemeHelper::getAppIconPath()) }}">
|
||
|
||
<!-- Fonts -->
|
||
@include('partials.local-fonts')
|
||
|
||
@php
|
||
$font = \App\Helpers\ThemeHelper::getFont();
|
||
$theme = config('app.theme', 'businessportal24');
|
||
@endphp
|
||
|
||
{{-- app.js bündelt Livewire + Alpine (livewire.esm) — @livewireScriptConfig
|
||
unten verhindert, dass Livewire zusätzlich sein eigenes Script injiziert
|
||
(sonst zwei Alpine-Instanzen und kaputte wire:model-Bindings). --}}
|
||
@livewireStyles
|
||
@vite([\App\Helpers\ThemeHelper::getThemeCssPath(), 'resources/js/app.js'], $domainConfig['assets_dir'] ?? 'build/web')
|
||
|
||
<!-- Sticky Header Script -->
|
||
<script>
|
||
document.addEventListener('DOMContentLoaded', function() {
|
||
const topbar = document.getElementById('topbar');
|
||
const header = document.getElementById('header');
|
||
|
||
if (!topbar || !header) return;
|
||
|
||
let topbarHeight = topbar.offsetHeight;
|
||
let isHeaderSticky = false;
|
||
|
||
// Platzhalter hält im Fluss die Header-Höhe frei, sobald der
|
||
// Header per position:fixed aus dem Fluss genommen wird —
|
||
// ohne den Spacer würde der Inhalt beim Fixieren nach oben springen.
|
||
const spacer = document.createElement('div');
|
||
spacer.setAttribute('aria-hidden', 'true');
|
||
|
||
function makeSticky() {
|
||
spacer.style.height = header.offsetHeight + 'px';
|
||
header.parentNode.insertBefore(spacer, header);
|
||
header.classList.remove('header-normal');
|
||
header.classList.add('header-sticky');
|
||
isHeaderSticky = true;
|
||
}
|
||
|
||
function clearSticky() {
|
||
header.classList.remove('header-sticky');
|
||
header.classList.add('header-normal');
|
||
if (spacer.parentNode) {
|
||
spacer.parentNode.removeChild(spacer);
|
||
}
|
||
isHeaderSticky = false;
|
||
}
|
||
|
||
function updateHeaderPosition() {
|
||
const scrollTop = window.pageYOffset || document.documentElement.scrollTop;
|
||
|
||
if (scrollTop >= topbarHeight && !isHeaderSticky) {
|
||
makeSticky();
|
||
} else if (scrollTop < topbarHeight && isHeaderSticky) {
|
||
clearSticky();
|
||
}
|
||
}
|
||
|
||
updateHeaderPosition();
|
||
|
||
window.addEventListener('scroll', updateHeaderPosition, { passive: true });
|
||
|
||
window.addEventListener('resize', function() {
|
||
topbarHeight = topbar.offsetHeight;
|
||
if (isHeaderSticky) {
|
||
spacer.style.height = header.offsetHeight + 'px';
|
||
}
|
||
updateHeaderPosition();
|
||
});
|
||
});
|
||
</script>
|
||
|
||
<!-- Additional Styles -->
|
||
@stack('styles')
|
||
|
||
<x-web.umami />
|
||
<x-monitoring.browser-errors :build="$domainConfig['assets_dir'] ?? 'build/web'" area="web" />
|
||
|
||
</head>
|
||
|
||
<body class="antialiased" style="background-color: hsl(var(--background)); color: hsl(var(--foreground));">
|
||
|
||
<!-- TopBar - statisch oben -->
|
||
|
||
@yield('content')
|
||
|
||
{{-- Schwebendes Kontakt-Widget (unten rechts, alle Web-Domains) --}}
|
||
<x-web.contact-launcher />
|
||
|
||
<!-- Additional Scripts -->
|
||
@stack('scripts')
|
||
|
||
@livewireScriptConfig
|
||
</body>
|
||
|
||
</html>
|