diff --git a/.env.example b/.env.example
index 0567a0c..f3cf3a5 100644
--- a/.env.example
+++ b/.env.example
@@ -132,6 +132,13 @@ PHOTOBOOTH_IMPORT_ROOT=/var/www/storage/app/photobooth
PHOTOBOOTH_IMPORT_MAX_FILES=50
PHOTOBOOTH_ALLOWED_EXTENSIONS=jpg,jpeg,png,webp
+# Matomo Analytics
+MATOMO_ENABLED=false
+MATOMO_URL=
+MATOMO_SITE_ID_MARKETING=
+MATOMO_SITE_ID_GUEST=
+MATOMO_SITE_ID_ADMIN=
+
DOKPLOY_API_BASE_URL=
DOKPLOY_API_KEY=
DOKPLOY_WEB_URL=
diff --git a/app/Http/Controllers/Api/EventPublicController.php b/app/Http/Controllers/Api/EventPublicController.php
index 31da30e..791abd1 100644
--- a/app/Http/Controllers/Api/EventPublicController.php
+++ b/app/Http/Controllers/Api/EventPublicController.php
@@ -463,10 +463,17 @@ class EventPublicController extends BaseController
$fallbacks = $this->localeFallbackChain($locale, $eventDefaultLocale);
$rows = DB::table('tasks')
- ->join('task_collection_task', 'tasks.id', '=', 'task_collection_task.task_id')
- ->join('event_task_collection', 'task_collection_task.task_collection_id', '=', 'event_task_collection.task_collection_id')
+ ->leftJoin('task_collection_task', 'tasks.id', '=', 'task_collection_task.task_id')
+ ->leftJoin('event_task_collection', 'task_collection_task.task_collection_id', '=', 'event_task_collection.task_collection_id')
+ ->leftJoin('event_task', function ($join) use ($eventId) {
+ $join->on('tasks.id', '=', 'event_task.task_id')
+ ->where('event_task.event_id', '=', $eventId);
+ })
->leftJoin('emotions', 'tasks.emotion_id', '=', 'emotions.id')
- ->where('event_task_collection.event_id', $eventId)
+ ->where(function ($query) use ($eventId) {
+ $query->where('event_task_collection.event_id', $eventId)
+ ->orWhereNotNull('event_task.event_id');
+ })
->select([
'tasks.id',
'tasks.title',
@@ -474,10 +481,12 @@ class EventPublicController extends BaseController
'tasks.example_text',
'tasks.emotion_id',
'tasks.sort_order',
+ 'event_task.sort_order as direct_sort_order',
+ 'event_task_collection.sort_order as collection_sort_order',
'emotions.name as emotion_name',
'emotions.id as emotion_lookup_id',
])
- ->orderBy('event_task_collection.sort_order')
+ ->orderByRaw('COALESCE(event_task_collection.sort_order, event_task.sort_order, tasks.sort_order, 0)')
->orderBy('tasks.sort_order')
->limit(20)
->get();
diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php
index f402849..e402945 100644
--- a/app/Providers/AppServiceProvider.php
+++ b/app/Providers/AppServiceProvider.php
@@ -182,7 +182,7 @@ class AppServiceProvider extends ServiceProvider
Inertia::share('analytics', static function () {
$config = config('services.matomo');
- if (! ($config['enabled'] ?? false)) {
+ if (! ($config['enabled'] ?? false) || empty($config['url']) || empty($config['site_id_marketing'])) {
return [
'matomo' => [
'enabled' => false,
@@ -194,7 +194,7 @@ class AppServiceProvider extends ServiceProvider
'matomo' => [
'enabled' => true,
'url' => rtrim((string) ($config['url'] ?? ''), '/'),
- 'siteId' => (string) ($config['site_id'] ?? ''),
+ 'siteId' => (string) ($config['site_id_marketing'] ?? ''),
],
];
});
diff --git a/config/services.php b/config/services.php
index 92e4337..2183765 100644
--- a/config/services.php
+++ b/config/services.php
@@ -64,6 +64,14 @@ return [
'redirect' => env('GOOGLE_REDIRECT_URI', rtrim(env('APP_URL', ''), '/').'/checkout/auth/google/callback'),
],
+ 'matomo' => [
+ 'enabled' => env('MATOMO_ENABLED', false),
+ 'url' => env('MATOMO_URL'),
+ 'site_id_marketing' => env('MATOMO_SITE_ID_MARKETING'),
+ 'site_id_guest' => env('MATOMO_SITE_ID_GUEST'),
+ 'site_id_admin' => env('MATOMO_SITE_ID_ADMIN'),
+ ],
+
'revenuecat' => [
'webhook' => env('REVENUECAT_WEBHOOK_SECRET', ''),
'product_mappings' => env('REVENUECAT_PRODUCT_MAPPINGS', ''),
diff --git a/resources/js/admin/components/dashboard/DashboardEventFocusCard.tsx b/resources/js/admin/components/dashboard/DashboardEventFocusCard.tsx
index 11576a0..c329d2a 100644
--- a/resources/js/admin/components/dashboard/DashboardEventFocusCard.tsx
+++ b/resources/js/admin/components/dashboard/DashboardEventFocusCard.tsx
@@ -200,30 +200,6 @@ export function DashboardEventFocusCard({
- {limitWarnings.length > 0 && (
-
- {limitWarnings.map((warning) => (
-
-
-
- {t(`limitWarnings.${warning.scope}`, {
- defaultValue:
- warning.scope === 'photos'
- ? 'Fotos'
- : warning.scope === 'guests'
- ? 'Gäste'
- : 'Galerie',
- })}
-
- {warning.message}
-
- ))}
-
- )}
);
}
diff --git a/resources/js/admin/i18n/locales/de/common.json b/resources/js/admin/i18n/locales/de/common.json
index 18cc8bd..a9e8e8f 100644
--- a/resources/js/admin/i18n/locales/de/common.json
+++ b/resources/js/admin/i18n/locales/de/common.json
@@ -71,10 +71,10 @@
},
"limits": {
"photosTitle": "Foto-Limit",
- "photosWarning": "Nur noch {remaining} von {limit} Foto-Uploads verfügbar.",
+ "photosWarning": "Nur noch {{remaining}} von {{limit}} Foto-Uploads verfügbar.",
"photosBlocked": "Foto-Uploads sind blockiert. Bitte Paket upgraden oder erweitern.",
"guestsTitle": "Gäste-Limit",
- "guestsWarning": "Nur noch {remaining} von {limit} Gästelinks verfügbar.",
+ "guestsWarning": "Nur noch {{remaining}} von {{limit}} Gästelinks verfügbar.",
"guestsBlocked": "Alle Gästeplätze sind belegt. Upgrade euer Paket oder erweitert das Kontingent, um weitere Gäste zuzulassen.",
"galleryTitle": "Galerie",
"galleryWarningDay": "Galerie läuft in {days} Tag ab.",
diff --git a/resources/js/admin/i18n/locales/en/common.json b/resources/js/admin/i18n/locales/en/common.json
index c9f60dd..a007a2c 100644
--- a/resources/js/admin/i18n/locales/en/common.json
+++ b/resources/js/admin/i18n/locales/en/common.json
@@ -71,10 +71,10 @@
},
"limits": {
"photosTitle": "Photo limit",
- "photosWarning": "Only {remaining} of {limit} photo uploads remaining.",
+ "photosWarning": "Only {{remaining}} of {{limit}} photo uploads remaining.",
"photosBlocked": "Photo uploads are blocked. Please upgrade or extend your package.",
"guestsTitle": "Guest limit",
- "guestsWarning": "Only {remaining} of {limit} guest invites remaining.",
+ "guestsWarning": "Only {{remaining}} of {{limit}} guest invites remaining.",
"guestsBlocked": "All guest slots are filled. Upgrade your package or extend the quota to welcome more guests.",
"galleryTitle": "Gallery",
"galleryWarningDay": "Gallery expires in {days} day.",
diff --git a/resources/js/admin/main.tsx b/resources/js/admin/main.tsx
index 879e1ce..52bf486 100644
--- a/resources/js/admin/main.tsx
+++ b/resources/js/admin/main.tsx
@@ -11,6 +11,9 @@ import './dev-tools';
import { initializeTheme } from '@/hooks/use-appearance';
import { OnboardingProgressProvider } from './onboarding';
import { EventProvider } from './context/EventContext';
+import MatomoTracker from '@/components/analytics/MatomoTracker';
+import { ConsentProvider } from '@/contexts/consent';
+import CookieBanner from '@/components/consent/CookieBanner';
const DevTenantSwitcher = React.lazy(() => import('./components/DevTenantSwitcher'));
@@ -40,28 +43,32 @@ if ('serviceWorker' in navigator) {
createRoot(rootEl).render(
-
-
-
-
-
- Oberfläche wird geladen …
-
- )}
- >
-
-
-
-
-
-
- {enableDevSwitcher ? (
-
-
-
- ) : null}
-
+
+
+
+
+
+
+
+ Oberfläche wird geladen …
+
+ )}
+ >
+
+
+
+
+
+
+
+ {enableDevSwitcher ? (
+
+
+
+ ) : null}
+
+
);
diff --git a/resources/js/admin/pages/DashboardPage.tsx b/resources/js/admin/pages/DashboardPage.tsx
index 0c6b13e..b62d2b5 100644
--- a/resources/js/admin/pages/DashboardPage.tsx
+++ b/resources/js/admin/pages/DashboardPage.tsx
@@ -13,8 +13,6 @@ import {
ClipboardList,
Package as PackageIcon,
} from 'lucide-react';
-import toast from 'react-hot-toast';
-
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
@@ -254,20 +252,7 @@ export default function DashboardPage() {
const shownToastsRef = React.useRef>(new Set());
- React.useEffect(() => {
- limitWarnings.forEach((warning) => {
- const toastKey = `${warning.id}-${warning.message}`;
- if (shownToastsRef.current.has(toastKey)) {
- return;
- }
-
- shownToastsRef.current.add(toastKey);
- toast(warning.message, {
- icon: warning.tone === 'danger' ? '🚨' : '⚠️',
- id: toastKey,
- });
- });
- }, [limitWarnings]);
+ // Limit warnings werden ausschließlich in der Limits-Karte angezeigt; keine Toasts mehr
const limitScopeLabels = React.useMemo(
() => ({
@@ -533,13 +518,6 @@ export default function DashboardPage() {
return (
- {errorMessage && (
-
- {t('dashboard.alerts.errorTitle')}
- {errorMessage}
-
- )}
-
{loading ? (
) : (
diff --git a/resources/js/components/analytics/MatomoTracker.tsx b/resources/js/components/analytics/MatomoTracker.tsx
index b9d2ea4..278865f 100644
--- a/resources/js/components/analytics/MatomoTracker.tsx
+++ b/resources/js/components/analytics/MatomoTracker.tsx
@@ -21,10 +21,29 @@ interface MatomoTrackerProps {
}
const MatomoTracker: React.FC = ({ config }) => {
- const page = usePage<{ security?: { csp?: { scriptNonce?: string } } }>();
- const { hasConsent } = useConsent();
- const scriptNonce = (page.props.security as { csp?: { scriptNonce?: string } } | undefined)?.csp?.scriptNonce;
- const analyticsConsent = hasConsent('analytics');
+ let scriptNonce: string | undefined;
+ let analyticsConsent = false;
+ let pageUrl = typeof window !== 'undefined' ? window.location.pathname + window.location.search : '';
+
+ try {
+ const page = usePage<{ security?: { csp?: { scriptNonce?: string } } }>();
+ scriptNonce = (page.props.security as { csp?: { scriptNonce?: string } } | undefined)?.csp?.scriptNonce;
+ pageUrl = page.url || pageUrl;
+ } catch {
+ // Not in Inertia context; fall back to window values/meta tags
+ const metaNonce = typeof document !== 'undefined'
+ ? document.querySelector('meta[name="csp-nonce"]')?.getAttribute('content')
+ : undefined;
+ scriptNonce = metaNonce ?? undefined;
+ }
+
+ try {
+ const consent = useConsent();
+ analyticsConsent = consent.hasConsent('analytics');
+ } catch {
+ // No consent provider available; default to no analytics
+ analyticsConsent = false;
+ }
useEffect(() => {
if (!config?.enabled || !config.url || !config.siteId || typeof window === 'undefined') {
@@ -92,14 +111,14 @@ const MatomoTracker: React.FC = ({ config }) => {
window._paq = window._paq || [];
const { _paq } = window;
const currentUrl =
- typeof window !== 'undefined' ? `${window.location.origin}${page.url}` : page.url;
+ typeof window !== 'undefined' ? `${window.location.origin}${pageUrl}` : pageUrl;
_paq.push(['setCustomUrl', currentUrl]);
if (typeof document !== 'undefined') {
_paq.push(['setDocumentTitle', document.title]);
}
_paq.push(['trackPageView']);
- }, [config, analyticsConsent, page.url]);
+ }, [config, analyticsConsent, pageUrl]);
if (!config?.enabled || !config.url || !config.siteId || !analyticsConsent) {
return null;
diff --git a/resources/js/guest/main.tsx b/resources/js/guest/main.tsx
index 32770a0..f5a95cc 100644
--- a/resources/js/guest/main.tsx
+++ b/resources/js/guest/main.tsx
@@ -25,6 +25,8 @@ const appRoot = async () => {
const { router } = await import('./router');
const { ToastProvider } = await import('./components/ToastHost');
const { LocaleProvider } = await import('./i18n/LocaleContext');
+ const { default: MatomoTracker } = await import('@/components/analytics/MatomoTracker');
+ const matomoConfig = (window as any).__MATOMO_GUEST__ as { enabled?: boolean; url?: string; siteId?: string };
// Register a minimal service worker for background sync (best-effort)
if ('serviceWorker' in navigator) {
@@ -45,6 +47,7 @@ const appRoot = async () => {
+
diff --git a/resources/views/admin.blade.php b/resources/views/admin.blade.php
index f49ab7b..9103aee 100644
--- a/resources/views/admin.blade.php
+++ b/resources/views/admin.blade.php
@@ -13,8 +13,69 @@
@viteReactRefresh
@vite(['resources/css/app.css', 'resources/js/admin/main.tsx'])
+ @php
+ $matomoConfig = config('services.matomo');
+ $matomoAdmin = ($matomoConfig['enabled'] ?? false) && !empty($matomoConfig['url']) && !empty($matomoConfig['site_id_admin'])
+ ? [
+ 'enabled' => true,
+ 'url' => rtrim($matomoConfig['url'], '/'),
+ 'siteId' => (string) $matomoConfig['site_id_admin'],
+ ]
+ : ['enabled' => false];
+ @endphp
+
+ @php
+ $noscriptLocale = in_array(app()->getLocale(), ['de', 'en'], true) ? app()->getLocale() : 'de';
+ @endphp
+