diff --git a/app/Console/Commands/CheckEventPackages.php b/app/Console/Commands/CheckEventPackages.php index 7df9271..b68f9ed 100644 --- a/app/Console/Commands/CheckEventPackages.php +++ b/app/Console/Commands/CheckEventPackages.php @@ -87,6 +87,15 @@ class CheckEventPackages extends Command $daysDiff = $now->diffInDays($expiresAt, false); + if ($warningDays->isNotEmpty() && $package->gallery_warning_sent_at) { + $maxWarningDay = $warningDays->max(); + if ($maxWarningDay !== null && $daysDiff > $maxWarningDay) { + $package->forceFill([ + 'gallery_warning_sent_at' => null, + ])->save(); + } + } + if ($daysDiff < 0) { if (! $package->gallery_expired_notified_at) { event(new EventPackageGalleryExpired($package)); @@ -139,8 +148,15 @@ class CheckEventPackages extends Command continue; } - if ($tenantPackage->expiry_warning_sent_at) { - continue; + if ($eventPackageExpiryDays->isNotEmpty() && $tenantPackage->expiry_warning_sent_at) { + $maxTenantWarning = $eventPackageExpiryDays->max(); + if ($maxTenantWarning !== null && $daysDiff > $maxTenantWarning) { + $tenantPackage->forceFill(['expiry_warning_sent_at' => null])->save(); + } else { + continue; + } + } elseif ($tenantPackage->expiry_warning_sent_at) { + $tenantPackage->forceFill(['expiry_warning_sent_at' => null])->save(); } foreach ($eventPackageExpiryDays as $day) { diff --git a/app/Exceptions/Handler.php b/app/Exceptions/Handler.php index 94b717f..b8cd258 100644 --- a/app/Exceptions/Handler.php +++ b/app/Exceptions/Handler.php @@ -2,10 +2,10 @@ namespace App\Exceptions; +use App\Support\ApiError; use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler; -use Throwable; use Illuminate\Validation\ValidationException; -use Inertia\Inertia; +use Throwable; class Handler extends ExceptionHandler { @@ -32,35 +32,46 @@ class Handler extends ExceptionHandler public function render($request, Throwable $e) { - \Illuminate\Support\Facades\Log::info('Handler render called', ['inertia' => $request->inertia(), 'exception' => get_class($e), 'url' => $request->url()]); + if ($request->expectsJson()) { + if ($e instanceof ValidationException) { + return ApiError::response( + 'validation_failed', + 'Validation failed', + 'The given data was invalid.', + 422, + ['errors' => $e->errors()], + ); + } - if ($e instanceof \Symfony\Component\HttpKernel\Exception\NotFoundHttpException) { - \Illuminate\Support\Facades\Log::error('Route not found (404)', [ - 'url' => $request->url(), - 'method' => $request->method(), - 'referer' => $request->header('referer'), - 'user_agent' => $request->header('user-agent') - ]); + $status = $this->isHttpException($e) + ? $this->toHttpException($e)->getStatusCode() + : 500; + + $code = $status >= 500 ? 'server_error' : 'request_failed'; + + return ApiError::response( + $code, + $status >= 500 ? 'Unexpected error' : 'Request could not be completed', + $this->buildGenericMessage($status), + $status, + ); } if ($request->inertia()) { if ($e instanceof ValidationException) { - \Illuminate\Support\Facades\Log::info('ValidationException in Inertia', ['errors' => $e->errors(), 'url' => $request->url()]); - return response()->json([ - 'message' => 'The given data was invalid.', - 'errors' => $e->errors(), - ], 422)->header('X-Inertia-Error', 'true'); - } - - if ($e instanceof \Exception) { - \Illuminate\Support\Facades\Log::info('Exception in Inertia', ['message' => $e->getMessage(), 'url' => $request->url()]); - return response()->json([ - 'message' => 'Registrierung fehlgeschlagen.', - 'errors' => ['general' => $e->getMessage()], - ], 500)->header('X-Inertia-Error', 'true'); + return back()->withErrors($e->errors())->withInput($request->all()); } } return parent::render($request, $e); } -} \ No newline at end of file + + private function buildGenericMessage(int $status): string + { + if ($status >= 500) { + return 'Something went wrong on our side. Please try again later.'; + } + + return 'Your request could not be processed. Please verify the details and try again.'; + } +} diff --git a/app/Http/Controllers/Api/EventPublicController.php b/app/Http/Controllers/Api/EventPublicController.php index bb692dd..9d49a58 100644 --- a/app/Http/Controllers/Api/EventPublicController.php +++ b/app/Http/Controllers/Api/EventPublicController.php @@ -150,12 +150,16 @@ class EventPublicController extends BaseController Response::HTTP_FORBIDDEN ); - return response()->json([ - 'error' => [ - 'code' => 'event_not_public', - 'message' => 'This event is not publicly accessible.', - ], - ], Response::HTTP_FORBIDDEN); + return ApiError::response( + 'event_not_public', + 'Event Not Public', + 'This event is not publicly accessible.', + Response::HTTP_FORBIDDEN, + [ + 'token' => Str::limit($token, 12), + 'event_id' => $event->id ?? null, + ] + ); } RateLimiter::clear($rateLimiterKey); @@ -199,12 +203,15 @@ class EventPublicController extends BaseController $event = Event::with(['tenant', 'eventPackage.package'])->find($eventRecord->id); if (! $event) { - return response()->json([ - 'error' => [ - 'code' => 'event_not_found', - 'message' => 'The event associated with this gallery could not be located.', - ], - ], Response::HTTP_NOT_FOUND); + return ApiError::response( + 'event_not_found', + 'Event Not Found', + 'The event associated with this gallery could not be located.', + Response::HTTP_NOT_FOUND, + [ + 'token' => Str::limit($token, 12), + ] + ); } $expiresAt = optional($event->eventPackage)->gallery_expires_at; @@ -222,13 +229,16 @@ class EventPublicController extends BaseController Response::HTTP_GONE ); - return response()->json([ - 'error' => [ - 'code' => 'gallery_expired', - 'message' => 'The gallery is no longer available for this event.', + return ApiError::response( + 'gallery_expired', + 'Gallery Expired', + 'The gallery is no longer available for this event.', + Response::HTTP_GONE, + [ + 'event_id' => $event->id, 'expired_at' => $expiresAt->toIso8601String(), - ], - ], Response::HTTP_GONE); + ] + ); } $this->recordTokenEvent( @@ -271,12 +281,13 @@ class EventPublicController extends BaseController Response::HTTP_TOO_MANY_REQUESTS ); - return response()->json([ - 'error' => [ - 'code' => 'token_rate_limited', - 'message' => 'Too many invalid join token attempts. Try again later.', - ], - ], Response::HTTP_TOO_MANY_REQUESTS); + return ApiError::response( + 'token_rate_limited', + 'Too Many Attempts', + 'Too many invalid join token attempts. Try again later.', + Response::HTTP_TOO_MANY_REQUESTS, + array_merge($context, ['rate_limiter_key' => $rateLimiterKey]) + ); } RateLimiter::hit($rateLimiterKey, $failureDecay * 60); @@ -295,12 +306,13 @@ class EventPublicController extends BaseController $status ); - return response()->json([ - 'error' => [ - 'code' => $code, - 'message' => $this->tokenErrorMessage($code), - ], - ], $status); + return ApiError::response( + $code, + $this->tokenErrorTitle($code), + $this->tokenErrorMessage($code), + $status, + $context + ); } private function tokenErrorMessage(string $code): string @@ -313,6 +325,17 @@ class EventPublicController extends BaseController }; } + private function tokenErrorTitle(string $code): string + { + return match ($code) { + 'invalid_token' => 'Invalid Join Token', + 'token_expired' => 'Join Token Expired', + 'token_revoked' => 'Join Token Revoked', + 'token_rate_limited' => 'Join Token Rate Limited', + default => 'Access Denied', + }; + } + private function recordTokenEvent( ?EventJoinToken $joinToken, Request $request, @@ -347,12 +370,16 @@ class EventPublicController extends BaseController Response::HTTP_TOO_MANY_REQUESTS ); - return response()->json([ - 'error' => [ - 'code' => 'access_rate_limited', - 'message' => 'Too many requests. Please slow down.', - ], - ], Response::HTTP_TOO_MANY_REQUESTS); + return ApiError::response( + 'access_rate_limited', + 'Too Many Requests', + 'Too many requests. Please slow down.', + Response::HTTP_TOO_MANY_REQUESTS, + [ + 'limit' => $limit, + 'decay_minutes' => $decay, + ] + ); } RateLimiter::hit($key, $decay * 60); @@ -383,12 +410,16 @@ class EventPublicController extends BaseController Response::HTTP_TOO_MANY_REQUESTS ); - return response()->json([ - 'error' => [ - 'code' => 'download_rate_limited', - 'message' => 'Download rate limit exceeded. Please wait a moment.', - ], - ], Response::HTTP_TOO_MANY_REQUESTS); + return ApiError::response( + 'download_rate_limited', + 'Download Rate Limited', + 'Download rate limit exceeded. Please wait a moment.', + Response::HTTP_TOO_MANY_REQUESTS, + [ + 'limit' => $limit, + 'decay_minutes' => $decay, + ] + ); } RateLimiter::hit($key, $decay * 60); @@ -664,12 +695,16 @@ class EventPublicController extends BaseController ->first(); if (! $record) { - return response()->json([ - 'error' => [ - 'code' => 'photo_not_found', - 'message' => 'The requested photo is no longer available.', - ], - ], Response::HTTP_NOT_FOUND); + return ApiError::response( + 'photo_not_found', + 'Photo Not Found', + 'The requested photo is no longer available.', + Response::HTTP_NOT_FOUND, + [ + 'photo_id' => $photo, + 'event_id' => $event->id, + ] + ); } $variantPreference = $variant === 'thumbnail' @@ -697,12 +732,16 @@ class EventPublicController extends BaseController ->first(); if (! $record) { - return response()->json([ - 'error' => [ - 'code' => 'photo_not_found', - 'message' => 'The requested photo is no longer available.', - ], - ], Response::HTTP_NOT_FOUND); + return ApiError::response( + 'photo_not_found', + 'Photo Not Found', + 'The requested photo is no longer available.', + Response::HTTP_NOT_FOUND, + [ + 'photo_id' => $photo, + 'event_id' => $event->id, + ] + ); } return $this->streamGalleryPhoto($event, $record, ['original'], 'attachment'); @@ -763,6 +802,69 @@ class EventPublicController extends BaseController ])->header('Cache-Control', 'no-store'); } + public function package(Request $request, string $token) + { + $result = $this->resolvePublishedEvent($request, $token, ['id']); + + if ($result instanceof JsonResponse) { + return $result; + } + + [$eventRecord, $joinToken] = $result; + + $event = Event::with(['tenant', 'eventPackage.package', 'eventPackages.package']) + ->findOrFail($eventRecord->id); + + if (! $event->tenant) { + return ApiError::response( + 'event_not_found', + 'Event not accessible', + 'The selected event is no longer available.', + Response::HTTP_NOT_FOUND, + ['scope' => 'photos', 'event_id' => $event->id] + ); + } + + $eventPackage = $this->packageLimitEvaluator->resolveEventPackageForPhotoUpload( + $event->tenant, + $event->id, + $event + ); + + if (! $eventPackage || ! $eventPackage->package) { + return response()->json([ + 'id' => null, + 'event_id' => $event->id, + 'package_id' => null, + 'package' => null, + 'used_photos' => (int) ($eventPackage?->used_photos ?? 0), + 'used_guests' => (int) ($eventPackage?->used_guests ?? 0), + 'expires_at' => $eventPackage?->gallery_expires_at?->toIso8601String(), + 'limits' => null, + ])->header('Cache-Control', 'no-store'); + } + + $package = $eventPackage->package; + $summary = $this->packageLimitEvaluator->summarizeEventPackage($eventPackage); + + return response()->json([ + 'id' => $eventPackage->id, + 'event_id' => $event->id, + 'package_id' => $eventPackage->package_id, + 'package' => [ + 'id' => $eventPackage->package_id, + 'name' => $package?->getNameForLocale(app()->getLocale()) ?? $package?->name, + 'max_photos' => $package?->max_photos, + 'max_guests' => $package?->max_guests, + 'gallery_days' => $package?->gallery_days, + ], + 'used_photos' => (int) $eventPackage->used_photos, + 'used_guests' => (int) $eventPackage->used_guests, + 'expires_at' => $eventPackage->gallery_expires_at?->toIso8601String(), + 'limits' => $summary, + ])->header('Cache-Control', 'no-store'); + } + private function streamGalleryPhoto(Event $event, Photo $record, array $variantPreference, string $disposition) { foreach ($variantPreference as $variant) { @@ -852,12 +954,16 @@ class EventPublicController extends BaseController return redirect()->away($fallbackUrl); } - return response()->json([ - 'error' => [ - 'code' => 'photo_unavailable', - 'message' => 'The requested photo could not be loaded.', - ], - ], Response::HTTP_NOT_FOUND); + return ApiError::response( + 'photo_unavailable', + 'Photo Unavailable', + 'The requested photo could not be loaded.', + Response::HTTP_NOT_FOUND, + [ + 'photo_id' => $record->id, + 'event_id' => $event->id, + ] + ); } private function resolvePhotoVariant(Photo $record, string $variant): array @@ -1191,7 +1297,13 @@ class EventPublicController extends BaseController ->where('events.status', 'published') ->first(); if (! $row) { - return response()->json(['error' => ['code' => 'not_found', 'message' => 'Photo not found or event not public']], 404); + return ApiError::response( + 'photo_not_found', + 'Photo Not Found', + 'Photo not found or event not public.', + Response::HTTP_NOT_FOUND, + ['photo_id' => $id] + ); } $row->file_path = $this->toPublicUrl((string) ($row->file_path ?? '')); $row->thumbnail_path = $this->toPublicUrl((string) ($row->thumbnail_path ?? '')); @@ -1219,7 +1331,13 @@ class EventPublicController extends BaseController ->where('events.status', 'published') ->first(['photos.id', 'photos.event_id']); if (! $photo) { - return response()->json(['error' => ['code' => 'not_found', 'message' => 'Photo not found or event not public']], 404); + return ApiError::response( + 'photo_not_found', + 'Photo Not Found', + 'Photo not found or event not public.', + Response::HTTP_NOT_FOUND, + ['photo_id' => $id] + ); } // Idempotent like per device diff --git a/app/Http/Controllers/Api/LegalController.php b/app/Http/Controllers/Api/LegalController.php index 333fa04..0c98db3 100644 --- a/app/Http/Controllers/Api/LegalController.php +++ b/app/Http/Controllers/Api/LegalController.php @@ -3,8 +3,10 @@ namespace App\Http\Controllers\Api; use App\Models\LegalPage; +use App\Support\ApiError; use Illuminate\Http\Request; use Illuminate\Routing\Controller as BaseController; +use Symfony\Component\HttpFoundation\Response; class LegalController extends BaseController { @@ -27,7 +29,13 @@ class LegalController extends BaseController ->orderByDesc('version') ->first(); if (! $page) { - return response()->json(['error' => ['code' => 'not_found', 'message' => 'Legal page not found']], 404); + return ApiError::response( + 'legal_page_not_found', + 'Legal Page Not Found', + 'The requested legal document does not exist.', + Response::HTTP_NOT_FOUND, + ['slug' => $resolved] + ); } $title = $page->title[$locale] ?? $page->title[$page->locale_fallback] ?? $page->title['de'] ?? $page->title['en'] ?? $page->slug; diff --git a/app/Http/Controllers/Api/StripeWebhookController.php b/app/Http/Controllers/Api/StripeWebhookController.php index 44f442f..fea37a6 100644 --- a/app/Http/Controllers/Api/StripeWebhookController.php +++ b/app/Http/Controllers/Api/StripeWebhookController.php @@ -16,9 +16,7 @@ use Stripe\Webhook; class StripeWebhookController extends Controller { - public function __construct(private CheckoutWebhookService $checkoutWebhooks) - { - } + public function __construct(private CheckoutWebhookService $checkoutWebhooks) {} public function handleWebhook(Request $request) { @@ -33,9 +31,19 @@ class StripeWebhookController extends Controller $endpointSecret ); } catch (SignatureVerificationException $e) { - return response()->json(['error' => 'Invalid signature'], 400); + return ApiError::response( + 'stripe_invalid_signature', + 'Ungültige Signatur', + 'Die Signatur der Stripe-Anfrage ist ungültig.', + 400 + ); } catch (\UnexpectedValueException $e) { - return response()->json(['error' => 'Invalid payload'], 400); + return ApiError::response( + 'stripe_invalid_payload', + 'Ungültige Daten', + 'Der Stripe Payload konnte nicht gelesen werden.', + 400 + ); } $eventArray = method_exists($event, 'toArray') ? $event->toArray() : (array) $event; @@ -78,6 +86,7 @@ class StripeWebhookController extends Controller if (! $packageId || ! $type) { Log::warning('Stripe intent missing metadata payload', ['metadata' => $metadata]); + return; } @@ -178,4 +187,3 @@ class StripeWebhookController extends Controller ]); } } - diff --git a/app/Http/Controllers/Api/Tenant/EventController.php b/app/Http/Controllers/Api/Tenant/EventController.php index 6ed3581..ab40d75 100644 --- a/app/Http/Controllers/Api/Tenant/EventController.php +++ b/app/Http/Controllers/Api/Tenant/EventController.php @@ -13,6 +13,7 @@ use App\Models\Package; use App\Models\Photo; use App\Models\Tenant; use App\Services\EventJoinTokenService; +use App\Support\ApiError; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\AnonymousResourceCollection; @@ -204,7 +205,13 @@ class EventController extends Controller $tenantId = $request->attributes->get('tenant_id'); if ($event->tenant_id !== $tenantId) { - return response()->json(['error' => 'Event not found'], 404); + return ApiError::response( + 'event_not_found', + 'Event not accessible', + 'Das Event konnte nicht gefunden werden.', + 404, + ['event_slug' => $event->slug ?? null] + ); } $event->load([ @@ -228,7 +235,13 @@ class EventController extends Controller $tenantId = $request->attributes->get('tenant_id'); if ($event->tenant_id !== $tenantId) { - return response()->json(['error' => 'Event not found'], 404); + return ApiError::response( + 'event_not_found', + 'Event not accessible', + 'Das Event konnte nicht gefunden werden.', + 404, + ['event_slug' => $event->slug ?? null] + ); } $validated = $request->validated(); @@ -264,7 +277,13 @@ class EventController extends Controller $tenantId = $request->attributes->get('tenant_id'); if ($event->tenant_id !== $tenantId) { - return response()->json(['error' => 'Event not found'], 404); + return ApiError::response( + 'event_not_found', + 'Event not accessible', + 'Das Event konnte nicht gefunden werden.', + 404, + ['event_slug' => $event->slug ?? null] + ); } $event->delete(); @@ -279,7 +298,13 @@ class EventController extends Controller $tenantId = $request->attributes->get('tenant_id'); if ($event->tenant_id !== $tenantId) { - return response()->json(['error' => 'Event not found'], 404); + return ApiError::response( + 'event_not_found', + 'Event not accessible', + 'Das Event konnte nicht gefunden werden.', + 404, + ['event_slug' => $event->slug ?? null] + ); } $totalPhotos = Photo::where('event_id', $event->id)->count(); @@ -304,7 +329,13 @@ class EventController extends Controller $tenantId = $request->attributes->get('tenant_id'); if ($event->tenant_id !== $tenantId) { - return response()->json(['error' => 'Event not found'], 404); + return ApiError::response( + 'event_not_found', + 'Event not accessible', + 'Das Event konnte nicht gefunden werden.', + 404, + ['event_slug' => $event->slug ?? null] + ); } $event->load(['eventType', 'eventPackage.package']); @@ -439,7 +470,13 @@ class EventController extends Controller $tenantId = $request->attributes->get('tenant_id'); if ($event->tenant_id !== $tenantId) { - return response()->json(['error' => 'Event not found'], 404); + return ApiError::response( + 'event_not_found', + 'Event not accessible', + 'Das Event konnte nicht gefunden werden.', + 404, + ['event_slug' => $event->slug ?? null] + ); } $activate = ! (bool) $event->is_active; @@ -466,7 +503,13 @@ class EventController extends Controller $tenantId = $request->attributes->get('tenant_id'); if ($event->tenant_id !== $tenantId) { - return response()->json(['error' => 'Event not found'], 404); + return ApiError::response( + 'event_not_found', + 'Event not accessible', + 'Das Event konnte nicht gefunden werden.', + 404, + ['event_slug' => $event->slug ?? null] + ); } $validated = $request->validate([ diff --git a/app/Http/Controllers/Api/Tenant/PhotoController.php b/app/Http/Controllers/Api/Tenant/PhotoController.php index fbdec2b..ca49bbb 100644 --- a/app/Http/Controllers/Api/Tenant/PhotoController.php +++ b/app/Http/Controllers/Api/Tenant/PhotoController.php @@ -22,6 +22,7 @@ use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Storage; use Illuminate\Support\Str; use Illuminate\Validation\ValidationException; +use Symfony\Component\HttpFoundation\Response; class PhotoController extends Controller { @@ -46,27 +47,16 @@ class PhotoController extends Controller ->where('tenant_id', $tenantId) ->firstOrFail(); + $event->loadMissing(['tenant', 'eventPackage.package', 'eventPackages.package']); $tenant = $event->tenant; - if ($tenant) { - $violation = $this->packageLimitEvaluator->assessPhotoUpload($tenant, $event->id, $event); - - if ($violation !== null) { - return ApiError::response( - $violation['code'], - $violation['title'], - $violation['message'], - $violation['status'], - $violation['meta'] - ); - } - } - $eventPackage = $tenant ? $this->packageLimitEvaluator->resolveEventPackageForPhotoUpload($tenant, $event->id, $event) : null; - $previousUsedPhotos = $eventPackage?->used_photos ?? 0; + $limitSummary = $eventPackage + ? $this->packageLimitEvaluator->summarizeEventPackage($eventPackage) + : null; $query = Photo::where('event_id', $event->id) ->with('event')->withCount('likes') @@ -84,7 +74,9 @@ class PhotoController extends Controller $perPage = $request->get('per_page', 20); $photos = $query->paginate($perPage); - return PhotoResource::collection($photos); + return PhotoResource::collection($photos)->additional([ + 'limits' => $limitSummary, + ]); } /** @@ -97,6 +89,29 @@ class PhotoController extends Controller ->where('tenant_id', $tenantId) ->firstOrFail(); + $event->loadMissing(['tenant', 'eventPackage.package', 'eventPackages.package']); + $tenant = $event->tenant; + + $eventPackage = $tenant + ? $this->packageLimitEvaluator->resolveEventPackageForPhotoUpload($tenant, $event->id, $event) + : null; + + if ($tenant) { + $violation = $this->packageLimitEvaluator->assessPhotoUpload($tenant, $event->id, $event); + + if ($violation !== null) { + return ApiError::response( + $violation['code'], + $violation['title'], + $violation['message'], + $violation['status'], + $violation['meta'] + ); + } + } + + $previousUsedPhotos = $eventPackage?->used_photos ?? 0; + $validated = $request->validated(); $file = $request->file('photo'); @@ -197,12 +212,17 @@ class PhotoController extends Controller $this->packageUsageTracker->recordPhotoUsage($eventPackage, $previousUsedPhotos, 1); } + $limitSummary = $eventPackage + ? $this->packageLimitEvaluator->summarizeEventPackage($eventPackage) + : null; + $photo->load('event')->loadCount('likes'); return response()->json([ 'message' => 'Photo uploaded successfully. Awaiting moderation.', 'data' => new PhotoResource($photo), 'moderation_notice' => 'Your photo has been uploaded and will be reviewed shortly.', + 'limits' => $limitSummary, ], 201); } @@ -217,7 +237,13 @@ class PhotoController extends Controller ->firstOrFail(); if ($photo->event_id !== $event->id) { - return response()->json(['error' => 'Photo not found'], 404); + return ApiError::response( + 'photo_not_found', + 'Foto nicht gefunden', + 'Das Foto gehört nicht zu diesem Event.', + 404, + ['photo_id' => $photo->id] + ); } $photo->load('event')->loadCount('likes'); @@ -239,7 +265,13 @@ class PhotoController extends Controller ->firstOrFail(); if ($photo->event_id !== $event->id) { - return response()->json(['error' => 'Photo not found'], 404); + return ApiError::response( + 'photo_not_found', + 'Foto nicht gefunden', + 'Das Foto gehört nicht zu diesem Event.', + 404, + ['photo_id' => $photo->id] + ); } $validated = $request->validate([ @@ -251,7 +283,13 @@ class PhotoController extends Controller // Only tenant admins can moderate if (isset($validated['status']) && ! $this->tokenHasScope($request, 'tenant:write')) { - return response()->json(['error' => 'Insufficient scopes'], 403); + return ApiError::response( + 'insufficient_scope', + 'Insufficient Scopes', + 'You are not allowed to moderate photos for this event.', + Response::HTTP_FORBIDDEN, + ['required_scope' => 'tenant:write'] + ); } $photo->update($validated); @@ -279,7 +317,13 @@ class PhotoController extends Controller ->firstOrFail(); if ($photo->event_id !== $event->id) { - return response()->json(['error' => 'Photo not found'], 404); + return ApiError::response( + 'photo_not_found', + 'Foto nicht gefunden', + 'Das Foto gehört nicht zu diesem Event.', + 404, + ['photo_id' => $photo->id] + ); } $assets = EventMediaAsset::where('photo_id', $photo->id)->get(); @@ -303,6 +347,9 @@ class PhotoController extends Controller Storage::disk($fallbackDisk)->delete([$photo->path, $photo->thumbnail_path]); } + $eventPackage = $event->eventPackage; + $usageTracker = app(\App\Services\Packages\PackageUsageTracker::class); + // Delete record and likes DB::transaction(function () use ($photo, $assets) { $photo->likes()->delete(); @@ -312,6 +359,15 @@ class PhotoController extends Controller $photo->delete(); }); + if ($eventPackage && $eventPackage->package) { + $previousUsed = (int) $eventPackage->used_photos; + if ($previousUsed > 0) { + $eventPackage->decrement('used_photos'); + $eventPackage->refresh(); + $usageTracker->recordPhotoUsage($eventPackage, $previousUsed, -1); + } + } + return response()->json([ 'message' => 'Photo deleted successfully', ]); @@ -328,7 +384,13 @@ class PhotoController extends Controller ->firstOrFail(); if ($photo->event_id !== $event->id) { - return response()->json(['error' => 'Photo not found'], 404); + return ApiError::response( + 'photo_not_found', + 'Photo not found', + 'The specified photo could not be located for this event.', + Response::HTTP_NOT_FOUND, + ['photo_id' => $photo->id, 'event_id' => $event->id] + ); } $photo->update(['is_featured' => true]); @@ -345,7 +407,13 @@ class PhotoController extends Controller ->firstOrFail(); if ($photo->event_id !== $event->id) { - return response()->json(['error' => 'Photo not found'], 404); + return ApiError::response( + 'photo_not_found', + 'Photo not found', + 'The specified photo could not be located for this event.', + Response::HTTP_NOT_FOUND, + ['photo_id' => $photo->id, 'event_id' => $event->id] + ); } $photo->update(['is_featured' => false]); @@ -569,7 +637,13 @@ class PhotoController extends Controller ]); if ($request->event_id !== $event->id) { - return response()->json(['error' => 'Invalid event ID'], 400); + return ApiError::response( + 'event_mismatch', + 'Invalid Event', + 'The provided event does not match the authenticated tenant event.', + Response::HTTP_BAD_REQUEST, + ['payload_event_id' => $request->event_id, 'expected_event_id' => $event->id] + ); } $event->load('storageAssignments.storageTarget'); diff --git a/app/Http/Controllers/Api/Tenant/SettingsController.php b/app/Http/Controllers/Api/Tenant/SettingsController.php index 2a79f7e..7bfab31 100644 --- a/app/Http/Controllers/Api/Tenant/SettingsController.php +++ b/app/Http/Controllers/Api/Tenant/SettingsController.php @@ -3,10 +3,14 @@ namespace App\Http\Controllers\Api\Tenant; use App\Http\Controllers\Controller; +use App\Http\Requests\Tenant\NotificationPreferencesRequest; use App\Http\Requests\Tenant\SettingsStoreRequest; use App\Models\Tenant; +use App\Services\Packages\TenantNotificationPreferences; +use App\Support\ApiError; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Symfony\Component\HttpFoundation\Response; class SettingsController extends Controller { @@ -27,6 +31,62 @@ class SettingsController extends Controller ]); } + public function notificationPreferences( + Request $request, + TenantNotificationPreferences $preferencesService + ): JsonResponse { + $tenant = $request->tenant; + $defaults = TenantNotificationPreferences::defaults(); + $resolved = []; + + foreach (array_keys($defaults) as $key) { + $resolved[$key] = $preferencesService->shouldNotify($tenant, $key); + } + + return response()->json([ + 'data' => [ + 'defaults' => $defaults, + 'preferences' => $resolved, + 'overrides' => $tenant->notification_preferences ?? null, + 'meta' => [ + 'credit_warning_sent_at' => $tenant->credit_warning_sent_at?->toIso8601String(), + 'credit_warning_threshold' => $tenant->credit_warning_threshold, + ], + ], + ]); + } + + public function updateNotificationPreferences( + NotificationPreferencesRequest $request, + TenantNotificationPreferences $preferencesService + ): JsonResponse { + $tenant = $request->tenant; + $payload = $request->validated()['preferences']; + + $tenant->update([ + 'notification_preferences' => $payload, + ]); + + $tenant->refresh(); + + $resolved = []; + foreach (array_keys(TenantNotificationPreferences::defaults()) as $key) { + $resolved[$key] = $preferencesService->shouldNotify($tenant->fresh(), $key); + } + + return response()->json([ + 'message' => 'Benachrichtigungseinstellungen aktualisiert.', + 'data' => [ + 'preferences' => $resolved, + 'overrides' => $tenant->notification_preferences, + 'meta' => [ + 'credit_warning_sent_at' => $tenant->credit_warning_sent_at?->toIso8601String(), + 'credit_warning_threshold' => $tenant->credit_warning_threshold, + ], + ], + ]); + } + /** * Update the tenant's settings. */ @@ -98,7 +158,12 @@ class SettingsController extends Controller $domain = $request->input('domain'); if (! $domain) { - return response()->json(['error' => 'Domain ist erforderlich.'], 400); + return ApiError::response( + 'domain_missing', + 'Domain erforderlich', + 'Bitte gib eine Domain an.', + Response::HTTP_BAD_REQUEST + ); } if (! $this->isValidDomain($domain)) { diff --git a/app/Http/Controllers/Api/Tenant/TaskController.php b/app/Http/Controllers/Api/Tenant/TaskController.php index a7e24d3..19d8721 100644 --- a/app/Http/Controllers/Api/Tenant/TaskController.php +++ b/app/Http/Controllers/Api/Tenant/TaskController.php @@ -6,20 +6,19 @@ use App\Http\Controllers\Controller; use App\Http\Requests\Tenant\TaskStoreRequest; use App\Http\Requests\Tenant\TaskUpdateRequest; use App\Http\Resources\Tenant\TaskResource; +use App\Models\Event; use App\Models\Task; use App\Models\TaskCollection; -use App\Models\Event; +use App\Support\ApiError; +use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\AnonymousResourceCollection; -use Illuminate\Http\JsonResponse; +use Symfony\Component\HttpFoundation\Response; class TaskController extends Controller { /** * Display a listing of the tenant's tasks. - * - * @param Request $request - * @return AnonymousResourceCollection */ public function index(Request $request): AnonymousResourceCollection { @@ -38,7 +37,7 @@ class TaskController extends Controller // Search and filters if ($search = $request->get('search')) { $query->where(function ($inner) use ($search) { - $like = '%' . $search . '%'; + $like = '%'.$search.'%'; $inner->where('title->de', 'like', $like) ->orWhere('title->en', 'like', $like) ->orWhere('description->de', 'like', $like) @@ -47,11 +46,11 @@ class TaskController extends Controller } if ($collectionId = $request->get('collection_id')) { - $query->whereHas('taskCollection', fn($q) => $q->where('id', $collectionId)); + $query->whereHas('taskCollection', fn ($q) => $q->where('id', $collectionId)); } if ($eventId = $request->get('event_id')) { - $query->whereHas('assignedEvents', fn($q) => $q->where('id', $eventId)); + $query->whereHas('assignedEvents', fn ($q) => $q->where('id', $eventId)); } $perPage = $request->get('per_page', 15); @@ -62,9 +61,6 @@ class TaskController extends Controller /** * Store a newly created task in storage. - * - * @param TaskStoreRequest $request - * @return JsonResponse */ public function store(TaskStoreRequest $request): JsonResponse { @@ -91,10 +87,6 @@ class TaskController extends Controller /** * Display the specified task. - * - * @param Request $request - * @param Task $task - * @return JsonResponse */ public function show(Request $request, Task $task): JsonResponse { @@ -109,10 +101,6 @@ class TaskController extends Controller /** * Update the specified task in storage. - * - * @param TaskUpdateRequest $request - * @param Task $task - * @return JsonResponse */ public function update(TaskUpdateRequest $request, Task $task): JsonResponse { @@ -142,10 +130,6 @@ class TaskController extends Controller /** * Remove the specified task from storage. - * - * @param Request $request - * @param Task $task - * @return JsonResponse */ public function destroy(Request $request, Task $task): JsonResponse { @@ -162,11 +146,6 @@ class TaskController extends Controller /** * Assign task to an event. - * - * @param Request $request - * @param Task $task - * @param Event $event - * @return JsonResponse */ public function assignToEvent(Request $request, Task $task, Event $event): JsonResponse { @@ -187,10 +166,6 @@ class TaskController extends Controller /** * Bulk assign tasks to an event. - * - * @param Request $request - * @param Event $event - * @return JsonResponse */ public function bulkAssignToEvent(Request $request, Event $event): JsonResponse { @@ -200,7 +175,12 @@ class TaskController extends Controller $taskIds = $request->input('task_ids', []); if (empty($taskIds)) { - return response()->json(['error' => 'Keine Task-IDs angegeben.'], 400); + return ApiError::response( + 'task_ids_missing', + 'Keine Aufgaben angegeben', + 'Bitte wähle mindestens eine Aufgabe aus.', + Response::HTTP_BAD_REQUEST + ); } $tasks = Task::whereIn('id', $taskIds) @@ -209,7 +189,7 @@ class TaskController extends Controller $attached = 0; foreach ($tasks as $task) { - if (!$task->assignedEvents()->where('event_id', $event->id)->exists()) { + if (! $task->assignedEvents()->where('event_id', $event->id)->exists()) { $task->assignedEvents()->attach($event->id); $attached++; } @@ -222,10 +202,6 @@ class TaskController extends Controller /** * Get tasks for a specific event. - * - * @param Request $request - * @param Event $event - * @return AnonymousResourceCollection */ public function forEvent(Request $request, Event $event): AnonymousResourceCollection { @@ -233,7 +209,7 @@ class TaskController extends Controller abort(404); } - $tasks = Task::whereHas('assignedEvents', fn($q) => $q->where('event_id', $event->id)) + $tasks = Task::whereHas('assignedEvents', fn ($q) => $q->where('event_id', $event->id)) ->with(['taskCollection']) ->orderBy('created_at', 'desc') ->paginate($request->get('per_page', 15)); @@ -243,10 +219,6 @@ class TaskController extends Controller /** * Get tasks from a specific collection. - * - * @param Request $request - * @param TaskCollection $collection - * @return AnonymousResourceCollection */ public function fromCollection(Request $request, TaskCollection $collection): AnonymousResourceCollection { @@ -321,9 +293,7 @@ class TaskController extends Controller } /** - * @param mixed $value - * @param array|null $fallback - * + * @param array|null $fallback * @return array|null */ protected function normalizeTranslations(mixed $value, ?array $fallback = null, bool $allowNull = false): ?array diff --git a/app/Http/Controllers/Api/TenantController.php b/app/Http/Controllers/Api/TenantController.php index ad1cc60..8de7354 100644 --- a/app/Http/Controllers/Api/TenantController.php +++ b/app/Http/Controllers/Api/TenantController.php @@ -6,32 +6,40 @@ use App\Models\Event; use App\Models\Photo; use App\Models\User; use App\Services\EventJoinTokenService; +use App\Support\ApiError; +use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Routing\Controller as BaseController; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Str; +use Symfony\Component\HttpFoundation\Response; class TenantController extends BaseController { - public function __construct(private readonly EventJoinTokenService $joinTokenService) - { - } + public function __construct(private readonly EventJoinTokenService $joinTokenService) {} public function login(Request $request) { $creds = $request->validate([ - 'email' => ['required','email'], - 'password' => ['required','string'], + 'email' => ['required', 'email'], + 'password' => ['required', 'string'], ]); if (! Auth::attempt($creds)) { - return response()->json(['error' => ['code' => 'invalid_credentials']], 401); + return ApiError::response( + 'invalid_credentials', + 'Invalid Credentials', + 'The provided credentials are incorrect.', + Response::HTTP_UNAUTHORIZED, + ['email' => $creds['email']] + ); } /** @var User $user */ $user = Auth::user(); // naive token (cache-based), expires in 8 hours $token = Str::random(80); Cache::put('api_token:'.$token, $user->id, now()->addHours(8)); + return response()->json([ 'token' => $token, 'user' => [ @@ -46,6 +54,7 @@ class TenantController extends BaseController public function me(Request $request) { $u = Auth::user(); + return response()->json([ 'id' => $u->id, 'name' => $u->name, @@ -62,7 +71,8 @@ class TenantController extends BaseController if ($tenantId) { $q->where('tenant_id', $tenantId); } - return response()->json(['data' => $q->orderByDesc('created_at')->limit(100)->get(['id','name','slug','date','is_active'])]); + + return response()->json(['data' => $q->orderByDesc('created_at')->limit(100)->get(['id', 'name', 'slug', 'date', 'is_active'])]); } public function showEvent(int $id) @@ -71,9 +81,10 @@ class TenantController extends BaseController $tenantId = $u->tenant_id ?? null; $ev = Event::findOrFail($id); if ($tenantId && $ev->tenant_id !== $tenantId) { - return response()->json(['error' => ['code' => 'forbidden']], 403); + return $this->forbiddenResponse('events.show', ['event_id' => $ev->id, 'tenant_id' => $tenantId]); } - return response()->json($ev->only(['id','name','slug','date','is_active','default_locale'])); + + return response()->json($ev->only(['id', 'name', 'slug', 'date', 'is_active', 'default_locale'])); } public function storeEvent(Request $request) @@ -81,19 +92,20 @@ class TenantController extends BaseController $u = Auth::user(); $tenantId = $u->tenant_id ?? null; $data = $request->validate([ - 'name' => ['required','string','max:255'], - 'slug' => ['required','string','max:255'], - 'date' => ['nullable','date'], + 'name' => ['required', 'string', 'max:255'], + 'slug' => ['required', 'string', 'max:255'], + 'date' => ['nullable', 'date'], 'is_active' => ['boolean'], ]); - $ev = new Event(); + $ev = new Event; $ev->tenant_id = $tenantId ?? $ev->tenant_id; $ev->name = ['de' => $data['name'], 'en' => $data['name']]; $ev->slug = $data['slug']; $ev->date = $data['date'] ?? null; - $ev->is_active = (bool)($data['is_active'] ?? true); + $ev->is_active = (bool) ($data['is_active'] ?? true); $ev->default_locale = 'de'; $ev->save(); + return response()->json(['id' => $ev->id]); } @@ -103,19 +115,28 @@ class TenantController extends BaseController $tenantId = $u->tenant_id ?? null; $ev = Event::findOrFail($id); if ($tenantId && $ev->tenant_id !== $tenantId) { - return response()->json(['error' => ['code' => 'forbidden']], 403); + return $this->forbiddenResponse('events.update', ['event_id' => $ev->id, 'tenant_id' => $tenantId]); } $data = $request->validate([ - 'name' => ['nullable','string','max:255'], - 'slug' => ['nullable','string','max:255'], - 'date' => ['nullable','date'], - 'is_active' => ['nullable','boolean'], + 'name' => ['nullable', 'string', 'max:255'], + 'slug' => ['nullable', 'string', 'max:255'], + 'date' => ['nullable', 'date'], + 'is_active' => ['nullable', 'boolean'], ]); - if (isset($data['name'])) $ev->name = ['de' => $data['name'], 'en' => $data['name']]; - if (isset($data['slug'])) $ev->slug = $data['slug']; - if (array_key_exists('date', $data)) $ev->date = $data['date']; - if (array_key_exists('is_active', $data)) $ev->is_active = (bool)$data['is_active']; + if (isset($data['name'])) { + $ev->name = ['de' => $data['name'], 'en' => $data['name']]; + } + if (isset($data['slug'])) { + $ev->slug = $data['slug']; + } + if (array_key_exists('date', $data)) { + $ev->date = $data['date']; + } + if (array_key_exists('is_active', $data)) { + $ev->is_active = (bool) $data['is_active']; + } $ev->save(); + return response()->json(['ok' => true]); } @@ -125,11 +146,12 @@ class TenantController extends BaseController $tenantId = $u->tenant_id ?? null; $ev = Event::findOrFail($id); if ($tenantId && $ev->tenant_id !== $tenantId) { - return response()->json(['error' => ['code' => 'forbidden']], 403); + return $this->forbiddenResponse('events.toggle', ['event_id' => $ev->id, 'tenant_id' => $tenantId]); } $ev->is_active = ! (bool) $ev->is_active; $ev->save(); - return response()->json(['is_active' => (bool)$ev->is_active]); + + return response()->json(['is_active' => (bool) $ev->is_active]); } public function eventStats(int $id) @@ -138,15 +160,16 @@ class TenantController extends BaseController $tenantId = $u->tenant_id ?? null; $ev = Event::findOrFail($id); if ($tenantId && $ev->tenant_id !== $tenantId) { - return response()->json(['error' => ['code' => 'forbidden']], 403); + return $this->forbiddenResponse('events.stats', ['event_id' => $ev->id, 'tenant_id' => $tenantId]); } $total = Photo::where('event_id', $id)->count(); $featured = Photo::where('event_id', $id)->where('is_featured', 1)->count(); $likes = Photo::where('event_id', $id)->sum('likes_count'); + return response()->json([ - 'total' => (int)$total, - 'featured' => (int)$featured, - 'likes' => (int)$likes, + 'total' => (int) $total, + 'featured' => (int) $featured, + 'likes' => (int) $likes, ]); } @@ -156,7 +179,7 @@ class TenantController extends BaseController $tenantId = $u->tenant_id ?? null; $ev = Event::findOrFail($id); if ($tenantId && $ev->tenant_id !== $tenantId) { - return response()->json(['error' => ['code' => 'forbidden']], 403); + return $this->forbiddenResponse('events.invite', ['event_id' => $ev->id, 'tenant_id' => $tenantId]); } $joinToken = $this->joinTokenService->createToken($ev, [ @@ -176,9 +199,10 @@ class TenantController extends BaseController $tenantId = $u->tenant_id ?? null; $ev = Event::findOrFail($id); if ($tenantId && $ev->tenant_id !== $tenantId) { - return response()->json(['error' => ['code' => 'forbidden']], 403); + return $this->forbiddenResponse('events.photos', ['event_id' => $ev->id, 'tenant_id' => $tenantId]); } - $rows = Photo::where('event_id', $id)->orderByDesc('created_at')->limit(100)->get(['id','thumbnail_path','file_path','likes_count','is_featured','created_at']); + $rows = Photo::where('event_id', $id)->orderByDesc('created_at')->limit(100)->get(['id', 'thumbnail_path', 'file_path', 'likes_count', 'is_featured', 'created_at']); + return response()->json(['data' => $rows]); } @@ -186,7 +210,9 @@ class TenantController extends BaseController { $p = Photo::findOrFail($photoId); $this->authorizePhoto($p); - $p->is_featured = 1; $p->save(); + $p->is_featured = 1; + $p->save(); + return response()->json(['ok' => true]); } @@ -194,7 +220,9 @@ class TenantController extends BaseController { $p = Photo::findOrFail($photoId); $this->authorizePhoto($p); - $p->is_featured = 0; $p->save(); + $p->is_featured = 0; + $p->save(); + return response()->json(['ok' => true]); } @@ -203,9 +231,21 @@ class TenantController extends BaseController $p = Photo::findOrFail($photoId); $this->authorizePhoto($p); $p->delete(); + return response()->json(['ok' => true]); } + private function forbiddenResponse(string $action, array $meta = []): JsonResponse + { + return ApiError::response( + 'forbidden', + 'Forbidden', + 'You are not allowed to perform this action.', + Response::HTTP_FORBIDDEN, + array_merge(['action' => $action], $meta) + ); + } + protected function authorizePhoto(Photo $p): void { $u = Auth::user(); diff --git a/app/Http/Controllers/Api/TenantPackageController.php b/app/Http/Controllers/Api/TenantPackageController.php index 3295041..44fff29 100644 --- a/app/Http/Controllers/Api/TenantPackageController.php +++ b/app/Http/Controllers/Api/TenantPackageController.php @@ -4,8 +4,10 @@ namespace App\Http\Controllers\Api; use App\Http\Controllers\Controller; use App\Models\TenantPackage; -use Illuminate\Http\Request; +use App\Support\ApiError; use Illuminate\Http\JsonResponse; +use Illuminate\Http\Request; +use Symfony\Component\HttpFoundation\Response; class TenantPackageController extends Controller { @@ -13,8 +15,13 @@ class TenantPackageController extends Controller { $tenant = $request->attributes->get('tenant'); - if (!$tenant) { - return response()->json(['error' => 'Tenant not found.'], 404); + if (! $tenant) { + return ApiError::response( + 'tenant_not_found', + 'Tenant Not Found', + 'The authenticated tenant context could not be resolved.', + Response::HTTP_NOT_FOUND + ); } $packages = TenantPackage::where('tenant_id', $tenant->id) @@ -33,4 +40,4 @@ class TenantPackageController extends Controller 'message' => 'Tenant packages loaded successfully.', ]); } -} \ No newline at end of file +} diff --git a/app/Http/Controllers/OAuthController.php b/app/Http/Controllers/OAuthController.php index 6394d09..9133db8 100644 --- a/app/Http/Controllers/OAuthController.php +++ b/app/Http/Controllers/OAuthController.php @@ -7,6 +7,7 @@ use App\Models\OAuthCode; use App\Models\RefreshToken; use App\Models\Tenant; use App\Models\TenantToken; +use App\Support\ApiError; use Firebase\JWT\JWT; use GuzzleHttp\Client; use Illuminate\Http\Request; @@ -17,6 +18,7 @@ use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Validator; use Illuminate\Support\Str; +use Symfony\Component\HttpFoundation\Response; class OAuthController extends Controller { @@ -690,7 +692,12 @@ class OAuthController extends Controller { $tenant = $request->user()->tenant ?? null; if (! $tenant) { - return response()->json(['error' => 'Tenant not found'], 404); + return ApiError::response( + 'tenant_not_found', + 'Tenant not found', + 'The authenticated user is not assigned to a tenant.', + Response::HTTP_NOT_FOUND + ); } $state = Str::random(40); diff --git a/app/Http/Controllers/RevenueCatWebhookController.php b/app/Http/Controllers/RevenueCatWebhookController.php index 24ef674..4da0107 100644 --- a/app/Http/Controllers/RevenueCatWebhookController.php +++ b/app/Http/Controllers/RevenueCatWebhookController.php @@ -3,9 +3,11 @@ namespace App\Http\Controllers; use App\Jobs\ProcessRevenueCatWebhook; +use App\Support\ApiError; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Log; +use Symfony\Component\HttpFoundation\Response; class RevenueCatWebhookController extends Controller { @@ -15,17 +17,33 @@ class RevenueCatWebhookController extends Controller if ($secret === '') { Log::error('RevenueCat webhook secret not configured'); - return response()->json(['error' => 'Webhook not configured'], 500); + + return ApiError::response( + 'webhook_not_configured', + 'Webhook Not Configured', + 'RevenueCat webhook secret is missing.', + Response::HTTP_INTERNAL_SERVER_ERROR + ); } $signature = trim((string) $request->header('X-Signature', '')); if ($signature === '') { - return response()->json(['error' => 'Signature missing'], 400); + return ApiError::response( + 'signature_missing', + 'Signature Missing', + 'The RevenueCat webhook request did not include a signature.', + Response::HTTP_BAD_REQUEST + ); } $payload = $request->getContent(); if (! $this->signatureMatches($payload, $signature, $secret)) { - return response()->json(['error' => 'Invalid signature'], 400); + return ApiError::response( + 'signature_invalid', + 'Invalid Signature', + 'The webhook signature could not be validated.', + Response::HTTP_BAD_REQUEST + ); } $decoded = json_decode($payload, true); @@ -33,7 +51,14 @@ class RevenueCatWebhookController extends Controller Log::warning('RevenueCat webhook received invalid JSON', [ 'error' => json_last_error_msg(), ]); - return response()->json(['error' => 'Invalid payload'], 400); + + return ApiError::response( + 'payload_invalid', + 'Invalid Payload', + 'The webhook payload could not be decoded as JSON.', + Response::HTTP_BAD_REQUEST, + ['json_error' => json_last_error_msg()] + ); } ProcessRevenueCatWebhook::dispatch( diff --git a/app/Http/Controllers/StripePaymentController.php b/app/Http/Controllers/StripePaymentController.php index 8a1d511..ee0e345 100644 --- a/app/Http/Controllers/StripePaymentController.php +++ b/app/Http/Controllers/StripePaymentController.php @@ -2,14 +2,15 @@ namespace App\Http\Controllers; -use Illuminate\Http\Request; +use App\Models\Package; +use App\Support\ApiError; use Illuminate\Http\JsonResponse; +use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Log; -use Stripe\Stripe; use Stripe\PaymentIntent; -use App\Models\Package; -use App\Models\Tenant; +use Stripe\Stripe; +use Symfony\Component\HttpFoundation\Response; class StripePaymentController extends Controller { @@ -25,13 +26,23 @@ class StripePaymentController extends Controller ]); $user = Auth::user(); - if (!$user) { - return response()->json(['error' => 'Nicht authentifiziert'], 401); + if (! $user) { + return ApiError::response( + 'unauthenticated', + 'Nicht authentifiziert', + 'Bitte melde dich an, um einen Kauf zu starten.', + Response::HTTP_UNAUTHORIZED + ); } $tenant = $user->tenant; - if (!$tenant) { - return response()->json(['error' => 'Kein Tenant gefunden'], 403); + if (! $tenant) { + return ApiError::response( + 'tenant_not_found', + 'Tenant nicht gefunden', + 'Für dein Benutzerkonto konnte kein Tenant gefunden werden.', + Response::HTTP_FORBIDDEN + ); } $package = Package::findOrFail($request->package_id); @@ -40,13 +51,13 @@ class StripePaymentController extends Controller if ($package->price <= 0) { return response()->json([ 'type' => 'free', - 'message' => 'Kostenloses Paket - kein Payment Intent nötig' + 'message' => 'Kostenloses Paket - kein Payment Intent nötig', ]); } try { $paymentIntent = PaymentIntent::create([ - 'amount' => (int)($package->price * 100), // In Cent + 'amount' => (int) ($package->price * 100), // In Cent 'currency' => 'eur', 'metadata' => [ 'package_id' => $package->id, @@ -65,7 +76,7 @@ class StripePaymentController extends Controller 'payment_intent_id' => $paymentIntent->id, 'package_id' => $package->id, 'tenant_id' => $tenant->id, - 'amount' => $package->price + 'amount' => $package->price, ]); return response()->json([ @@ -76,10 +87,16 @@ class StripePaymentController extends Controller Log::error('Stripe Payment Intent Fehler', [ 'error' => $e->getMessage(), 'package_id' => $request->package_id, - 'user_id' => $user->id + 'user_id' => $user->id, ]); - return response()->json(['error' => $e->getMessage()], 400); + return ApiError::response( + 'stripe_payment_error', + 'Stripe Fehler', + 'Die Zahlung konnte nicht vorbereitet werden.', + Response::HTTP_BAD_REQUEST, + ['stripe_message' => $e->getMessage()] + ); } } -} \ No newline at end of file +} diff --git a/app/Http/Middleware/ApiTokenAuth.php b/app/Http/Middleware/ApiTokenAuth.php index f761b0c..43feecb 100644 --- a/app/Http/Middleware/ApiTokenAuth.php +++ b/app/Http/Middleware/ApiTokenAuth.php @@ -2,11 +2,14 @@ namespace App\Http\Middleware; -use Closure; -use Illuminate\Http\Request; -use Illuminate\Support\Facades\Cache; -use Illuminate\Support\Facades\Auth; use App\Models\User; +use App\Support\ApiError; +use Closure; +use Illuminate\Http\JsonResponse; +use Illuminate\Http\Request; +use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Facades\Cache; +use Symfony\Component\HttpFoundation\Response; class ApiTokenAuth { @@ -14,19 +17,30 @@ class ApiTokenAuth { $header = $request->header('Authorization', ''); if (! str_starts_with($header, 'Bearer ')) { - return response()->json(['error' => ['code' => 'unauthorized']], 401); + return $this->unauthorizedResponse('missing_bearer'); } $token = substr($header, 7); $userId = Cache::get('api_token:'.$token); if (! $userId) { - return response()->json(['error' => ['code' => 'unauthorized']], 401); + return $this->unauthorizedResponse('token_unknown'); } $user = User::find($userId); if (! $user) { - return response()->json(['error' => ['code' => 'unauthorized']], 401); + return $this->unauthorizedResponse('user_missing'); } Auth::login($user); // for policies if needed + return $next($request); } -} + private function unauthorizedResponse(string $reason): JsonResponse + { + return ApiError::response( + 'unauthorized', + 'Unauthorized', + 'Authentication is required to access this resource.', + Response::HTTP_UNAUTHORIZED, + ['reason' => $reason] + ); + } +} diff --git a/app/Http/Middleware/TenantIsolation.php b/app/Http/Middleware/TenantIsolation.php index 9452ea1..a76f135 100644 --- a/app/Http/Middleware/TenantIsolation.php +++ b/app/Http/Middleware/TenantIsolation.php @@ -2,9 +2,12 @@ namespace App\Http\Middleware; +use App\Support\ApiError; use Closure; +use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; +use Symfony\Component\HttpFoundation\Response; class TenantIsolation { @@ -15,15 +18,15 @@ class TenantIsolation { $tenantId = $request->attributes->get('tenant_id'); - if (!$tenantId) { - return response()->json(['error' => 'Tenant ID not found in token'], 401); + if (! $tenantId) { + return $this->missingTenantIdResponse(); } // Get the tenant from request (query param, route param, or header) $requestTenantId = $this->getTenantIdFromRequest($request); if ($requestTenantId && $requestTenantId != $tenantId) { - return response()->json(['error' => 'Tenant isolation violation'], 403); + return $this->tenantIsolationViolationResponse((int) $tenantId, (int) $requestTenantId); } // Set tenant context for query scoping @@ -32,7 +35,6 @@ class TenantIsolation $connection->statement('SET @tenant_id = ?', [$tenantId]); } - // Add tenant context to request for easy access in controllers $request->attributes->set('current_tenant_id', $tenantId); @@ -62,4 +64,28 @@ class TenantIsolation // 4. For tenant-specific resources, use token tenant_id return null; } + + private function missingTenantIdResponse(): JsonResponse + { + return ApiError::response( + 'tenant_context_missing', + 'Tenant Context Missing', + 'Tenant ID not found in access token.', + Response::HTTP_UNAUTHORIZED + ); + } + + private function tenantIsolationViolationResponse(int $tokenTenantId, int $requestTenantId): JsonResponse + { + return ApiError::response( + 'tenant_isolation_violation', + 'Tenant Isolation Violation', + 'The requested resource belongs to a different tenant.', + Response::HTTP_FORBIDDEN, + [ + 'token_tenant_id' => $tokenTenantId, + 'request_tenant_id' => $requestTenantId, + ] + ); + } } diff --git a/app/Http/Middleware/TenantTokenGuard.php b/app/Http/Middleware/TenantTokenGuard.php index 5e409e4..c555c75 100644 --- a/app/Http/Middleware/TenantTokenGuard.php +++ b/app/Http/Middleware/TenantTokenGuard.php @@ -4,15 +4,18 @@ namespace App\Http\Middleware; use App\Models\Tenant; use App\Models\TenantToken; +use App\Support\ApiError; use Closure; use Firebase\JWT\JWT; use Firebase\JWT\Key; -use Illuminate\Support\Facades\File; use Illuminate\Auth\GenericUser; +use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Cache; +use Illuminate\Support\Facades\File; use Illuminate\Support\Str; +use Symfony\Component\HttpFoundation\Response; class TenantTokenGuard { @@ -26,36 +29,76 @@ class TenantTokenGuard $token = $this->getTokenFromRequest($request); if (! $token) { - return response()->json(['error' => 'Token not provided'], 401); + return $this->errorResponse( + 'token_missing', + 'Token Missing', + 'Authentication token not provided.', + Response::HTTP_UNAUTHORIZED + ); } try { $decoded = $this->decodeToken($token); } catch (\Exception $e) { - return response()->json(['error' => 'Invalid token'], 401); + return $this->errorResponse( + 'token_invalid', + 'Invalid Token', + 'Authentication token cannot be decoded.', + Response::HTTP_UNAUTHORIZED + ); } if ($this->isTokenBlacklisted($decoded)) { - return response()->json(['error' => 'Token has been revoked'], 401); + return $this->errorResponse( + 'token_revoked', + 'Token Revoked', + 'The provided token is no longer valid.', + Response::HTTP_UNAUTHORIZED, + ['jti' => $decoded['jti'] ?? null] + ); } if (! empty($scopes) && ! $this->hasScopes($decoded, $scopes)) { - return response()->json(['error' => 'Insufficient scopes'], 403); + return $this->errorResponse( + 'token_scope_violation', + 'Insufficient Scopes', + 'The provided token does not include the required scopes.', + Response::HTTP_FORBIDDEN, + ['required_scopes' => $scopes, 'token_scopes' => $decoded['scopes'] ?? []] + ); } if (($decoded['exp'] ?? 0) < time()) { $this->blacklistToken($decoded); - return response()->json(['error' => 'Token expired'], 401); + + return $this->errorResponse( + 'token_expired', + 'Token Expired', + 'Authentication token has expired.', + Response::HTTP_UNAUTHORIZED, + ['expired_at' => $decoded['exp'] ?? null] + ); } $tenantId = $decoded['tenant_id'] ?? $decoded['sub'] ?? null; if (! $tenantId) { - return response()->json(['error' => 'Invalid token payload'], 401); + return $this->errorResponse( + 'token_payload_invalid', + 'Invalid Token Payload', + 'Authentication token does not include tenant context.', + Response::HTTP_UNAUTHORIZED + ); } $tenant = Tenant::query()->find($tenantId); if (! $tenant) { - return response()->json(['error' => 'Tenant not found'], 404); + return $this->errorResponse( + 'tenant_not_found', + 'Tenant Not Found', + 'The tenant belonging to the token could not be located.', + Response::HTTP_NOT_FOUND, + ['tenant_id' => $tenantId] + ); } $scopesFromToken = $this->normaliseScopes($decoded['scopes'] ?? []); @@ -127,6 +170,7 @@ class TenantTokenGuard } $decodedHeader = json_decode(base64_decode($segments[0]), true); + return is_array($decodedHeader) ? ($decodedHeader['kid'] ?? null) : null; } @@ -170,12 +214,14 @@ class TenantTokenGuard if ($tokenRecord->revoked_at) { Cache::put($cacheKey, true, $this->cacheTtlFromDecoded($decoded)); + return true; } if ($tokenRecord->expires_at && $tokenRecord->expires_at->isPast()) { $tokenRecord->update(['revoked_at' => now()]); Cache::put($cacheKey, true, $this->cacheTtlFromDecoded($decoded)); + return true; } @@ -187,7 +233,7 @@ class TenantTokenGuard */ private function blacklistToken(array $decoded): void { - $jti = $decoded['jti'] ?? md5(($decoded['sub'] ?? '') . ($decoded['iat'] ?? '')); + $jti = $decoded['jti'] ?? md5(($decoded['sub'] ?? '').($decoded['iat'] ?? '')); $cacheKey = "blacklisted_token:{$jti}"; Cache::put($cacheKey, true, $this->cacheTtlFromDecoded($decoded)); @@ -201,6 +247,7 @@ class TenantTokenGuard 'revoked_at' => now(), 'expires_at' => $record->expires_at ?? now(), ]); + return; } @@ -254,5 +301,9 @@ class TenantTokenGuard return $ttl; } -} + private function errorResponse(string $code, string $title, string $message, int $status, array $meta = []): JsonResponse + { + return ApiError::response($code, $title, $message, $status, $meta); + } +} diff --git a/app/Http/Requests/Tenant/NotificationPreferencesRequest.php b/app/Http/Requests/Tenant/NotificationPreferencesRequest.php new file mode 100644 index 0000000..7552045 --- /dev/null +++ b/app/Http/Requests/Tenant/NotificationPreferencesRequest.php @@ -0,0 +1,42 @@ + + */ + public function rules(): array + { + $rules = [ + 'preferences' => ['required', 'array'], + ]; + + foreach (array_keys(TenantNotificationPreferences::defaults()) as $key) { + $rules["preferences.{$key}"] = ['required', 'boolean']; + } + + return $rules; + } + + protected function prepareForValidation(): void + { + $this->merge([ + 'preferences' => $this->input('preferences', []), + ]); + } +} diff --git a/app/Http/Resources/Tenant/EventResource.php b/app/Http/Resources/Tenant/EventResource.php index 59fb2c1..5889a53 100644 --- a/app/Http/Resources/Tenant/EventResource.php +++ b/app/Http/Resources/Tenant/EventResource.php @@ -2,6 +2,7 @@ namespace App\Http\Resources\Tenant; +use App\Services\Packages\PackageLimitEvaluator; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; use Illuminate\Http\Resources\MissingValue; @@ -29,6 +30,11 @@ class EventResource extends JsonResource } } + $limitEvaluator = null; + if ($eventPackage) { + $limitEvaluator = app()->make(PackageLimitEvaluator::class); + } + return [ 'id' => $this->id, 'name' => $this->name, @@ -67,6 +73,9 @@ class EventResource extends JsonResource 'purchased_at' => $eventPackage->purchased_at?->toIso8601String(), 'expires_at' => $eventPackage->gallery_expires_at?->toIso8601String(), ] : null, + 'limits' => $eventPackage && $limitEvaluator + ? $limitEvaluator->summarizeEventPackage($eventPackage) + : null, ]; } } diff --git a/app/Services/Packages/PackageLimitEvaluator.php b/app/Services/Packages/PackageLimitEvaluator.php index c68649e..9405eb8 100644 --- a/app/Services/Packages/PackageLimitEvaluator.php +++ b/app/Services/Packages/PackageLimitEvaluator.php @@ -113,6 +113,36 @@ class PackageLimitEvaluator return $eventPackage; } + public function summarizeEventPackage(EventPackage $eventPackage): array + { + $package = $eventPackage->package; + + $photoSummary = $this->buildUsageSummary( + (int) $eventPackage->used_photos, + $package?->max_photos, + config('package-limits.photo_thresholds', []) + ); + + $guestSummary = $this->buildUsageSummary( + (int) $eventPackage->used_guests, + $package?->max_guests, + config('package-limits.guest_thresholds', []) + ); + + $gallerySummary = $this->buildGallerySummary( + $eventPackage, + config('package-limits.gallery_warning_days', []) + ); + + return [ + 'photos' => $photoSummary, + 'guests' => $guestSummary, + 'gallery' => $gallerySummary, + 'can_upload_photos' => $photoSummary['state'] !== 'limit_reached' && $gallerySummary['state'] !== 'expired', + 'can_add_guests' => $guestSummary['state'] !== 'limit_reached', + ]; + } + /** * @return array{0: ?Event, 1: ?\App\Models\EventPackage} */ @@ -148,4 +178,120 @@ class PackageLimitEvaluator return [$event, $eventPackage]; } + + /** + * @param array $rawThresholds + */ + private function buildUsageSummary(int $used, ?int $limit, array $rawThresholds): array + { + $thresholds = collect($rawThresholds) + ->filter(fn ($value) => is_numeric($value) && $value > 0 && $value < 1) + ->map(fn ($value) => round((float) $value, 4)) + ->unique() + ->sort() + ->values() + ->all(); + + if ($limit === null || $limit <= 0) { + return [ + 'limit' => null, + 'used' => $used, + 'remaining' => null, + 'percentage' => null, + 'state' => 'unlimited', + 'threshold_reached' => null, + 'next_threshold' => $thresholds[0] ?? null, + 'thresholds' => $thresholds, + ]; + } + + $clampedLimit = max(1, (int) $limit); + $ratio = $used / $clampedLimit; + $percentage = round(min(1, $ratio), 4); + $remaining = max(0, $clampedLimit - $used); + $state = 'ok'; + $thresholdReached = null; + $nextThreshold = null; + + foreach ($thresholds as $threshold) { + if ($percentage >= $threshold) { + $thresholdReached = $threshold; + if ($state !== 'limit_reached') { + $state = 'warning'; + } + } elseif ($nextThreshold === null) { + $nextThreshold = $threshold; + } + } + + if ($used >= $clampedLimit) { + $state = 'limit_reached'; + $thresholdReached = 1.0; + $nextThreshold = null; + } + + return [ + 'limit' => $clampedLimit, + 'used' => $used, + 'remaining' => $remaining, + 'percentage' => $percentage, + 'state' => $state, + 'threshold_reached' => $thresholdReached, + 'next_threshold' => $nextThreshold, + 'thresholds' => $thresholds, + ]; + } + + /** + * @param array $warningDays + */ + private function buildGallerySummary(EventPackage $eventPackage, array $warningDays): array + { + $expiresAt = $eventPackage->gallery_expires_at; + $warningValues = collect($warningDays) + ->filter(fn ($value) => is_numeric($value) && $value >= 0) + ->map(fn ($value) => (int) $value) + ->unique() + ->sort() + ->values() + ->all(); + + if (! $expiresAt) { + return [ + 'state' => 'unlimited', + 'expires_at' => null, + 'days_remaining' => null, + 'warning_thresholds' => $warningValues, + 'warning_triggered' => null, + 'warning_sent_at' => null, + 'expired_notified_at' => null, + ]; + } + + $daysRemaining = now()->diffInDays($expiresAt, false); + $state = 'ok'; + $warningTriggered = null; + + foreach ($warningValues as $threshold) { + if ($daysRemaining <= $threshold && $daysRemaining >= 0) { + $warningTriggered = $threshold; + $state = 'warning'; + break; + } + } + + if ($daysRemaining < 0) { + $state = 'expired'; + } + + return [ + 'state' => $state, + 'expires_at' => $expiresAt->toIso8601String(), + 'days_remaining' => $daysRemaining, + 'warning_thresholds' => $warningValues, + 'warning_triggered' => $warningTriggered, + 'warning_sent_at' => $eventPackage->gallery_warning_sent_at?->toIso8601String(), + 'expired_notified_at' => $eventPackage->gallery_expired_notified_at?->toIso8601String(), + ]; + } } diff --git a/app/Services/Packages/TenantNotificationPreferences.php b/app/Services/Packages/TenantNotificationPreferences.php index c054817..8c50b77 100644 --- a/app/Services/Packages/TenantNotificationPreferences.php +++ b/app/Services/Packages/TenantNotificationPreferences.php @@ -20,6 +20,11 @@ class TenantNotificationPreferences 'credits_low' => true, ]; + public static function defaults(): array + { + return self::DEFAULTS; + } + public function shouldNotify(Tenant $tenant, string $preferenceKey): bool { $preferences = $tenant->notification_preferences ?? []; diff --git a/docs/todo/package-limit-experience-overhaul.md b/docs/todo/package-limit-experience-overhaul.md index 8392b24..cab86bc 100644 --- a/docs/todo/package-limit-experience-overhaul.md +++ b/docs/todo/package-limit-experience-overhaul.md @@ -21,9 +21,9 @@ ### 1. Backend Unification - [x] Paket-Limit-Service erstellen (Fotos, Gäste, Aufgaben, Events/Jahr, Galerie-Laufzeit). *(Initial evaluator + Middleware Integration für Events/Fotos)* - [x] Public Uploads um Paketlimit-Prüfung erweitern (inkl. Events ohne Aktivpaket blockieren). *(Guest Upload prüft & erhöht Zähler)* -- [ ] Konsistentes Fehler-Response-Schema (`code`, `title`, `message`, `meta`) implementieren. *(Begonnen: Gästeadmin/Admin Uploads nutzen ApiError)* -- [ ] Domain-Events für Grenzwerte & Ablaufzustände emitten. -- [ ] Feature-/Unit-Tests für neue Services & Events. +- [x] Konsistentes Fehler-Response-Schema (`code`, `title`, `message`, `meta`) implementieren. +- [x] Domain-Events für Grenzwerte & Ablaufzustände emitten. +- [x] Feature-/Unit-Tests für neue Services & Events. ### 2. Threshold Detection & Storage - [x] Schwellenwerte konfigurieren (Fotos/Gäste, Gallery D-7/D-1). @@ -31,9 +31,9 @@ - [x] Persistenz für Galerie-Benachrichtigungen (warning/expired timestamps). ### 3. Guest PWA Improvements -- [ ] Limit-Status im Upload-Flow anzeigen (Warnbanner + Sperrzustände). -- [ ] Upload-Fehlercodes auswerten und freundliche Dialoge zeigen. -- [ ] Galerie-Countdown/Badge für Ablaufdatum + Call-to-Action. +- [x] Limit-Status im Upload-Flow anzeigen (Warnbanner + Sperrzustände). +- [x] Upload-Fehlercodes auswerten und freundliche Dialoge zeigen. +- [x] Galerie-Countdown/Badge für Ablaufdatum + Call-to-Action. - [ ] E2E-Tests für Limitwarnungen & abgelaufene Galerie aktualisieren. ### 4. Tenant Admin PWA Improvements diff --git a/public/joyous_wedding_guests_posing.jpg b/public/joyous_wedding_guests_posing.jpg new file mode 100644 index 0000000..60d441a Binary files /dev/null and b/public/joyous_wedding_guests_posing.jpg differ diff --git a/resources/js/admin/api.ts b/resources/js/admin/api.ts index 3f08d08..7a4193f 100644 --- a/resources/js/admin/api.ts +++ b/resources/js/admin/api.ts @@ -1,5 +1,6 @@ import { authorizedFetch } from './auth/tokens'; import { ApiError } from './lib/apiError'; +import type { EventLimitSummary } from './lib/limitWarnings'; import i18n from './i18n'; type JsonValue = Record; @@ -62,6 +63,7 @@ export type TenantEvent = { purchased_at: string | null; expires_at: string | null; } | null; + limits?: EventLimitSummary | null; [key: string]: unknown; }; @@ -128,6 +130,13 @@ export type TenantPackageSummary = { package_limits: Record | null; }; +export type NotificationPreferences = Record; + +export type NotificationPreferencesMeta = { + credit_warning_sent_at?: string | null; + credit_warning_threshold?: number | null; +}; + export type CreditBalance = { balance: number; free_event_granted_at?: string | null; @@ -490,6 +499,7 @@ function normalizeEvent(event: JsonValue): TenantEvent { engagement_mode: engagementMode, settings, package: event.package ?? null, + limits: (event.limits ?? null) as EventLimitSummary | null, }; return normalized; @@ -779,10 +789,17 @@ export async function getEventTypes(): Promise { .filter((row): row is TenantEventType => Boolean(row)); } -export async function getEventPhotos(slug: string): Promise { +export async function getEventPhotos(slug: string): Promise<{ photos: TenantPhoto[]; limits: EventLimitSummary | null }> { const response = await authorizedFetch(`${eventEndpoint(slug)}/photos`); - const data = await jsonOrThrow<{ data?: TenantPhoto[] }>(response, 'Failed to load photos'); - return (data.data ?? []).map(normalizePhoto); + const data = await jsonOrThrow<{ data?: TenantPhoto[]; limits?: EventLimitSummary | null }>( + response, + 'Failed to load photos' + ); + + return { + photos: (data.data ?? []).map(normalizePhoto), + limits: (data.limits ?? null) as EventLimitSummary | null, + }; } export async function featurePhoto(slug: string, id: number): Promise { @@ -1049,6 +1066,56 @@ export async function getTenantPackagesOverview(): Promise<{ return { packages, activePackage }; } +export type NotificationPreferenceResponse = { + defaults: NotificationPreferences; + preferences: NotificationPreferences; + overrides: NotificationPreferences | null; + meta: NotificationPreferencesMeta | null; +}; + +export async function getNotificationPreferences(): Promise { + const response = await authorizedFetch('/api/v1/tenant/settings/notifications'); + const payload = await jsonOrThrow<{ data?: { defaults?: NotificationPreferences; preferences?: NotificationPreferences; overrides?: NotificationPreferences; meta?: NotificationPreferencesMeta } }>( + response, + 'Failed to load notification preferences' + ); + + const data = payload.data ?? {}; + + return { + defaults: data.defaults ?? {}, + preferences: data.preferences ?? {}, + overrides: data.overrides ?? null, + meta: data.meta ?? null, + }; +} + +export async function updateNotificationPreferences( + preferences: NotificationPreferences +): Promise { + const response = await authorizedFetch('/api/v1/tenant/settings/notifications', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ preferences }), + }); + + const payload = await jsonOrThrow<{ data?: { preferences?: NotificationPreferences; overrides?: NotificationPreferences; meta?: NotificationPreferencesMeta } }>( + response, + 'Failed to update notification preferences' + ); + + const data = payload.data ?? {}; + + return { + defaults: {}, + preferences: data.preferences ?? preferences, + overrides: data.overrides ?? null, + meta: data.meta ?? null, + }; +} + export async function getTenantPaddleTransactions(cursor?: string): Promise<{ data: PaddleTransactionSummary[]; nextCursor: string | null; diff --git a/resources/js/admin/i18n/locales/de/common.json b/resources/js/admin/i18n/locales/de/common.json index ec79bce..1f76b56 100644 --- a/resources/js/admin/i18n/locales/de/common.json +++ b/resources/js/admin/i18n/locales/de/common.json @@ -28,5 +28,18 @@ "creditsExhausted": "Keine Event-Credits mehr verfügbar. Bitte buche Credits oder upgrade dein Paket.", "photoLimit": "Für dieses Event ist das Foto-Upload-Limit erreicht.", "goToBilling": "Zur Paketverwaltung" + }, + "limits": { + "photosTitle": "Foto-Limit", + "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.", + "guestsBlocked": "Gästeinladungen sind blockiert. Bitte Paket upgraden oder Kontingent freigeben.", + "galleryTitle": "Galerie", + "galleryWarningDay": "Galerie läuft in {days} Tag ab.", + "galleryWarningDays": "Galerie läuft in {days} Tagen ab.", + "galleryExpired": "Galerie ist abgelaufen. Gäste sehen keine Inhalte mehr.", + "unlimited": "Unbegrenzt" } } diff --git a/resources/js/admin/i18n/locales/de/dashboard.json b/resources/js/admin/i18n/locales/de/dashboard.json index 1141909..ab77713 100644 --- a/resources/js/admin/i18n/locales/de/dashboard.json +++ b/resources/js/admin/i18n/locales/de/dashboard.json @@ -86,6 +86,18 @@ "description": "Aktives Paket und Historie einsehen." } }, + "limitsCard": { + "title": "Kontingente & Laufzeiten", + "description": "Fokus-Event: {{name}}", + "descriptionFallback": "Kein Fokus-Event ausgewählt", + "photosLabel": "Foto-Uploads", + "guestsLabel": "Gastzugänge", + "galleryLabel": "Galerie-Laufzeit", + "usageLabel": "{{used}} von {{limit}} genutzt", + "remainingLabel": "{{remaining}} übrig (Limit {{limit}})", + "galleryExpires": "Läuft am {{date}} ab", + "galleryNoExpiry": "Keine Ablaufzeit hinterlegt" + }, "upcoming": { "title": "Kommende Events", "description": "Die nächsten Termine inklusive Status & Zugriff.", @@ -158,6 +170,18 @@ "description": "Aktives Paket und Historie einsehen." } }, + "limitsCard": { + "title": "Kontingente & Laufzeiten", + "description": "Fokus-Event: {{name}}", + "descriptionFallback": "Kein Fokus-Event ausgewählt", + "photosLabel": "Foto-Uploads", + "guestsLabel": "Gastzugänge", + "galleryLabel": "Galerie-Laufzeit", + "usageLabel": "{{used}} von {{limit}} genutzt", + "remainingLabel": "{{remaining}} übrig (Limit {{limit}})", + "galleryExpires": "Läuft am {{date}} ab", + "galleryNoExpiry": "Keine Ablaufzeit hinterlegt" + }, "upcoming": { "title": "Kommende Events", "description": "Die nächsten Termine inklusive Status & Zugriff.", diff --git a/resources/js/admin/i18n/locales/de/management.json b/resources/js/admin/i18n/locales/de/management.json index fcea22f..92790a3 100644 --- a/resources/js/admin/i18n/locales/de/management.json +++ b/resources/js/admin/i18n/locales/de/management.json @@ -32,6 +32,12 @@ "label": "Läuft ab", "helper": "Automatische Verlängerung, falls aktiv" } + }, + "warnings": { + "noEvents": "Event-Kontingent aufgebraucht. Bitte Paket upgraden oder erneuern.", + "lowEvents": "Nur noch {{remaining}} Event-Slots verfügbar.", + "expiresSoon": "Paket läuft am {{date}} ab.", + "expired": "Paket ist abgelaufen." } }, "packages": { @@ -43,7 +49,13 @@ "statusInactive": "Inaktiv", "used": "Genutzte Events", "available": "Verfügbar", - "expires": "Läuft ab" + "expires": "Läuft ab", + "warnings": { + "noEvents": "Event-Kontingent aufgebraucht.", + "lowEvents": "Nur noch {{remaining}} Events verbleiben.", + "expiresSoon": "Läuft am {{date}} ab.", + "expired": "Paket ist abgelaufen." + } } }, "transactions": { @@ -81,6 +93,39 @@ } } }, + "photos": { + "moderation": { + "title": "Fotos moderieren", + "subtitle": "Setze Highlights oder entferne unpassende Uploads." + }, + "alerts": { + "errorTitle": "Aktion fehlgeschlagen" + }, + "gallery": { + "title": "Galerie", + "description": "Klick auf ein Foto, um es hervorzuheben oder zu löschen.", + "emptyTitle": "Noch keine Fotos vorhanden", + "emptyDescription": "Motiviere deine Gäste zum Hochladen - hier erscheint anschließend die Galerie." + } + }, + "events": { + "list": { + "title": "Deine Events", + "subtitle": "Plane Momente, die in Erinnerung bleiben. Hier verwaltest du alles rund um deine Veranstaltungen.", + "actions": { + "create": "Neues Event", + "settings": "Einstellungen" + }, + "overview": { + "title": "Übersicht", + "empty": "Noch keine Events - starte jetzt und lege dein erstes Event an.", + "count": "{{count}} {{count, plural, one {Event} other {Events}}} aktiv verwaltet.", + "badge": { + "dashboard": "Tenant Dashboard" + } + } + } + }, "members": { "title": "Event-Mitglieder", "subtitle": "Verwalte Moderatoren, Admins und Helfer für dieses Event.", @@ -715,4 +760,68 @@ } } } + , + "settings": { + "notifications": { + "title": "Benachrichtigungen", + "description": "Lege fest, für welche Ereignisse wir dich per E-Mail informieren.", + "errorLoad": "Benachrichtigungseinstellungen konnten nicht geladen werden.", + "errorSave": "Speichern fehlgeschlagen. Bitte versuche es erneut.", + "hint": "Du kannst Benachrichtigungen jederzeit wieder aktivieren.", + "actions": { + "save": "Speichern", + "reset": "Auf Standard setzen" + }, + "meta": { + "creditLast": "Letzte Credit-Warnung: {{date}}", + "creditNever": "Noch keine Credit-Warnung versendet." + }, + "items": { + "photoThresholds": { + "label": "Warnung bei Foto-Schwellen", + "description": "Sende Warnungen bei 80 % und 95 % Foto-Auslastung." + }, + "photoLimits": { + "label": "Sperre bei Foto-Limit", + "description": "Informiere mich, sobald keine Foto-Uploads mehr möglich sind." + }, + "guestThresholds": { + "label": "Warnung bei Gästekontingent", + "description": "Warnung kurz bevor alle Gästelinks vergeben sind." + }, + "guestLimits": { + "label": "Sperre bei Gästelimit", + "description": "Hinweis, wenn keine neuen Gästelinks mehr erzeugt werden können." + }, + "galleryWarnings": { + "label": "Galerie läuft bald ab", + "description": "Erhalte 7 und 1 Tag vor Ablauf eine Erinnerung." + }, + "galleryExpired": { + "label": "Galerie ist abgelaufen", + "description": "Informiere mich, sobald Gäste die Galerie nicht mehr sehen können." + }, + "eventThresholds": { + "label": "Warnung bei Event-Kontingent", + "description": "Hinweis, wenn das Reseller-Paket fast ausgeschöpft ist." + }, + "eventLimits": { + "label": "Sperre bei Event-Kontingent", + "description": "Nachricht, sobald keine weiteren Events erstellt werden können." + }, + "packageExpiring": { + "label": "Paket läuft bald ab", + "description": "Erinnerungen bei 30, 7 und 1 Tag vor Paketablauf." + }, + "packageExpired": { + "label": "Paket ist abgelaufen", + "description": "Benachrichtige mich, wenn das Paket abgelaufen ist." + }, + "creditsLow": { + "label": "Event-Credits werden knapp", + "description": "Informiert mich bei niedrigen Credit-Schwellen." + } + } + } + } } diff --git a/resources/js/admin/i18n/locales/en/common.json b/resources/js/admin/i18n/locales/en/common.json index 75cce72..56b5514 100644 --- a/resources/js/admin/i18n/locales/en/common.json +++ b/resources/js/admin/i18n/locales/en/common.json @@ -28,5 +28,18 @@ "creditsExhausted": "You have no event credits remaining. Purchase credits or upgrade your package.", "photoLimit": "This event reached its photo upload limit.", "goToBilling": "Manage subscription" + }, + "limits": { + "photosTitle": "Photo limit", + "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.", + "guestsBlocked": "Guest invites are blocked. Please upgrade your package.", + "galleryTitle": "Gallery", + "galleryWarningDay": "Gallery expires in {days} day.", + "galleryWarningDays": "Gallery expires in {days} days.", + "galleryExpired": "Gallery has expired. Guests can no longer access the photos.", + "unlimited": "Unlimited" } } diff --git a/resources/js/admin/i18n/locales/en/dashboard.json b/resources/js/admin/i18n/locales/en/dashboard.json index 68bd043..545f2ef 100644 --- a/resources/js/admin/i18n/locales/en/dashboard.json +++ b/resources/js/admin/i18n/locales/en/dashboard.json @@ -86,6 +86,18 @@ "description": "View your active package and history." } }, + "limitsCard": { + "title": "Limits & gallery status", + "description": "Focus event: {{name}}", + "descriptionFallback": "No focus event selected", + "photosLabel": "Photo uploads", + "guestsLabel": "Guest invites", + "galleryLabel": "Gallery runtime", + "usageLabel": "{{used}} of {{limit}} used", + "remainingLabel": "{{remaining}} remaining (limit {{limit}})", + "galleryExpires": "Expires on {{date}}", + "galleryNoExpiry": "No expiry configured" + }, "upcoming": { "title": "Upcoming events", "description": "The next dates including status and quick access.", @@ -158,6 +170,18 @@ "description": "View your active package and history." } }, + "limitsCard": { + "title": "Limits & gallery status", + "description": "Focus event: {{name}}", + "descriptionFallback": "No focus event selected", + "photosLabel": "Photo uploads", + "guestsLabel": "Guest invites", + "galleryLabel": "Gallery runtime", + "usageLabel": "{{used}} of {{limit}} used", + "remainingLabel": "{{remaining}} remaining (limit {{limit}})", + "galleryExpires": "Expires on {{date}}", + "galleryNoExpiry": "No expiry configured" + }, "upcoming": { "title": "Upcoming events", "description": "The next dates including status and quick access.", diff --git a/resources/js/admin/i18n/locales/en/management.json b/resources/js/admin/i18n/locales/en/management.json index d5c2597..13584ad 100644 --- a/resources/js/admin/i18n/locales/en/management.json +++ b/resources/js/admin/i18n/locales/en/management.json @@ -32,6 +32,12 @@ "label": "Expires", "helper": "Auto-renews if enabled" } + }, + "warnings": { + "noEvents": "Event allowance exhausted. Please upgrade or renew your package.", + "lowEvents": "Only {{remaining}} event slots remaining.", + "expiresSoon": "Package expires on {{date}}.", + "expired": "Package has expired." } }, "packages": { @@ -43,7 +49,13 @@ "statusInactive": "Inactive", "used": "Events used", "available": "Remaining", - "expires": "Expires" + "expires": "Expires", + "warnings": { + "noEvents": "Event allowance exhausted.", + "lowEvents": "Only {{remaining}} events left.", + "expiresSoon": "Expires on {{date}}.", + "expired": "Package has expired." + } } }, "transactions": { @@ -81,6 +93,39 @@ } } }, + "photos": { + "moderation": { + "title": "Moderate photos", + "subtitle": "Highlight favourites or remove unsuitable uploads." + }, + "alerts": { + "errorTitle": "Action failed" + }, + "gallery": { + "title": "Gallery", + "description": "Click a photo to feature it or remove it.", + "emptyTitle": "No photos yet", + "emptyDescription": "Encourage your guests to upload – the gallery will appear here." + } + }, + "events": { + "list": { + "title": "Your events", + "subtitle": "Plan memorable moments. Manage everything around your events here.", + "actions": { + "create": "New event", + "settings": "Settings" + }, + "overview": { + "title": "Overview", + "empty": "No events yet – create your first one to get started.", + "count": "{{count}} {{count, plural, one {event} other {events}}} managed.", + "badge": { + "dashboard": "Tenant dashboard" + } + } + } + }, "members": { "title": "Event members", "subtitle": "Manage moderators, admins, and helpers for this event.", @@ -715,4 +760,68 @@ } } } + , + "settings": { + "notifications": { + "title": "Notifications", + "description": "Choose which events should trigger an email notification.", + "errorLoad": "Unable to load notification preferences.", + "errorSave": "Saving failed. Please try again.", + "hint": "You can re-enable notifications at any time.", + "actions": { + "save": "Save", + "reset": "Reset to defaults" + }, + "meta": { + "creditLast": "Last credit warning: {{date}}", + "creditNever": "No credit warning sent yet." + }, + "items": { + "photoThresholds": { + "label": "Photo thresholds", + "description": "Send warnings when photo usage reaches 80% and 95%." + }, + "photoLimits": { + "label": "Photo limit reached", + "description": "Let me know when no further uploads are possible." + }, + "guestThresholds": { + "label": "Guest quota warning", + "description": "Warn me shortly before all guest links are in use." + }, + "guestLimits": { + "label": "Guest quota exhausted", + "description": "Inform me when no more guest links can be generated." + }, + "galleryWarnings": { + "label": "Gallery ends soon", + "description": "Receive reminders 7 and 1 day before the gallery expires." + }, + "galleryExpired": { + "label": "Gallery expired", + "description": "Let me know when guests can no longer access the gallery." + }, + "eventThresholds": { + "label": "Event quota warning", + "description": "Notify me when the reseller package is almost used up." + }, + "eventLimits": { + "label": "Event quota exhausted", + "description": "Notify me when no further events can be created." + }, + "packageExpiring": { + "label": "Package expires soon", + "description": "Reminders 30, 7, and 1 day before the package expires." + }, + "packageExpired": { + "label": "Package expired", + "description": "Inform me once the package has expired." + }, + "creditsLow": { + "label": "Event credits running low", + "description": "Warn me when credit thresholds are reached." + } + } + } + } } diff --git a/resources/js/admin/lib/apiError.ts b/resources/js/admin/lib/apiError.ts index 69fb1cb..9ae7603 100644 --- a/resources/js/admin/lib/apiError.ts +++ b/resources/js/admin/lib/apiError.ts @@ -13,3 +13,23 @@ export class ApiError extends Error { export function isApiError(value: unknown): value is ApiError { return value instanceof ApiError; } + +export function getApiErrorMessage(error: unknown, fallback: string): string { + if (isApiError(error)) { + if (error.message) { + return error.message; + } + + if (error.status && error.status >= 500) { + return 'Der Server hat nicht reagiert. Bitte versuche es später erneut.'; + } + + return fallback; + } + + if (error instanceof Error && error.message) { + return error.message; + } + + return fallback; +} diff --git a/resources/js/admin/lib/limitWarnings.ts b/resources/js/admin/lib/limitWarnings.ts new file mode 100644 index 0000000..8ea15ef --- /dev/null +++ b/resources/js/admin/lib/limitWarnings.ts @@ -0,0 +1,118 @@ +export type LimitWarningTone = 'warning' | 'danger'; + +export type LimitWarning = { + id: string; + scope: 'photos' | 'guests' | 'gallery'; + tone: LimitWarningTone; + message: string; +}; + +export type LimitUsageSummary = { + limit: number | null; + used: number; + remaining: number | null; + percentage: number | null; + state: 'ok' | 'warning' | 'limit_reached' | 'unlimited'; + threshold_reached: number | null; + next_threshold: number | null; + thresholds: number[]; +} | null; + +export type GallerySummary = { + state: 'ok' | 'warning' | 'expired' | 'unlimited'; + expires_at: string | null; + days_remaining: number | null; + warning_thresholds: number[]; + warning_triggered: number | null; + warning_sent_at: string | null; + expired_notified_at: string | null; +} | null; + +export type EventLimitSummary = { + photos: LimitUsageSummary; + guests: LimitUsageSummary; + gallery: GallerySummary; + can_upload_photos: boolean; + can_add_guests: boolean; +} | null | undefined; + +type TranslateFn = (key: string, options?: Record) => string; + +function hasRemaining(summary: LimitUsageSummary): summary is LimitUsageSummary & { remaining: number; limit: number } { + return Boolean(summary) + && typeof summary?.remaining === 'number' + && typeof summary?.limit === 'number'; +} + +export function buildLimitWarnings(limits: EventLimitSummary, t: TranslateFn): LimitWarning[] { + if (!limits) { + return []; + } + + const warnings: LimitWarning[] = []; + + if (limits.photos) { + if (limits.photos.state === 'limit_reached') { + warnings.push({ + id: 'photos-limit', + scope: 'photos', + tone: 'danger', + message: t('photosBlocked'), + }); + } else if (limits.photos.state === 'warning' && hasRemaining(limits.photos)) { + warnings.push({ + id: 'photos-warning', + scope: 'photos', + tone: 'warning', + message: t('photosWarning', { + remaining: limits.photos.remaining, + limit: limits.photos.limit, + }), + }); + } + } + + if (limits.guests) { + if (limits.guests.state === 'limit_reached') { + warnings.push({ + id: 'guests-limit', + scope: 'guests', + tone: 'danger', + message: t('guestsBlocked'), + }); + } else if (limits.guests.state === 'warning' && hasRemaining(limits.guests)) { + warnings.push({ + id: 'guests-warning', + scope: 'guests', + tone: 'warning', + message: t('guestsWarning', { + remaining: limits.guests.remaining, + limit: limits.guests.limit, + }), + }); + } + } + + if (limits.gallery) { + if (limits.gallery.state === 'expired') { + warnings.push({ + id: 'gallery-expired', + scope: 'gallery', + tone: 'danger', + message: t('galleryExpired'), + }); + } else if (limits.gallery.state === 'warning') { + const days = limits.gallery.days_remaining ?? 0; + const safeDays = Math.max(0, days); + const key = safeDays === 1 ? 'galleryWarningDay' : 'galleryWarningDays'; + warnings.push({ + id: 'gallery-warning', + scope: 'gallery', + tone: 'warning', + message: t(key, { days: safeDays }), + }); + } + } + + return warnings; +} diff --git a/resources/js/admin/pages/BillingPage.tsx b/resources/js/admin/pages/BillingPage.tsx index fa14f30..6841ce1 100644 --- a/resources/js/admin/pages/BillingPage.tsx +++ b/resources/js/admin/pages/BillingPage.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { Loader2, RefreshCw, Sparkles } from 'lucide-react'; +import { AlertTriangle, Loader2, RefreshCw, Sparkles } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; @@ -12,6 +12,8 @@ import { AdminLayout } from '../components/AdminLayout'; import { getTenantPackagesOverview, getTenantPaddleTransactions, PaddleTransactionSummary, TenantPackageSummary } from '../api'; import { isAuthError } from '../auth/tokens'; +type PackageWarning = { id: string; tone: 'warning' | 'danger'; message: string }; + export default function BillingPage() { const { t, i18n } = useTranslation(['management', 'dashboard']); const locale = React.useMemo( @@ -112,6 +114,11 @@ export default function BillingPage() { ); + const activeWarnings = React.useMemo( + () => buildPackageWarnings(activePackage, t, formatDate, 'billing.sections.overview.warnings'), + [activePackage, t, formatDate], + ); + return ( {activePackage ? ( -
- - - - +
+ {activeWarnings.length > 0 && ( +
+ {activeWarnings.map((warning) => ( + + + + {warning.message} + + + ))} +
+ )} + +
+ + + + +
) : ( @@ -194,16 +220,20 @@ export default function BillingPage() { {packages.length === 0 ? ( ) : ( - packages.map((pkg) => ( - - )) + packages.map((pkg) => { + const warnings = buildPackageWarnings(pkg, t, formatDate, 'billing.sections.packages.card.warnings'); + return ( + + ); + }) )} @@ -369,6 +399,7 @@ function PackageCard({ labels, formatDate, formatCurrency, + warnings = [], }: { pkg: TenantPackageSummary; isActive: boolean; @@ -381,9 +412,26 @@ function PackageCard({ }; formatDate: (value: string | null | undefined) => string; formatCurrency: (value: number | null | undefined, currency?: string) => string; + warnings?: PackageWarning[]; }) { return (
+ {warnings.length > 0 && ( +
+ {warnings.map((warning) => ( + + + + {warning.message} + + + ))} +
+ )}

{pkg.package_name}

@@ -422,6 +470,60 @@ function EmptyState({ message }: { message: string }) { ); } +function buildPackageWarnings( + pkg: TenantPackageSummary | null | undefined, + translate: (key: string, options?: Record) => string, + formatDate: (value: string | null | undefined) => string, + keyPrefix: string, +): PackageWarning[] { + if (!pkg) { + return []; + } + + const warnings: PackageWarning[] = []; + const remaining = typeof pkg.remaining_events === 'number' ? pkg.remaining_events : null; + + if (remaining !== null) { + if (remaining <= 0) { + warnings.push({ + id: `${pkg.id}-no-events`, + tone: 'danger', + message: translate(`${keyPrefix}.noEvents`), + }); + } else if (remaining <= 2) { + warnings.push({ + id: `${pkg.id}-low-events`, + tone: 'warning', + message: translate(`${keyPrefix}.lowEvents`, { remaining }), + }); + } + } + + const expiresAt = pkg.expires_at ? new Date(pkg.expires_at) : null; + if (expiresAt && !Number.isNaN(expiresAt.getTime())) { + const now = new Date(); + const diffMillis = expiresAt.getTime() - now.getTime(); + const diffDays = Math.ceil(diffMillis / (1000 * 60 * 60 * 24)); + const formatted = formatDate(pkg.expires_at); + + if (diffDays < 0) { + warnings.push({ + id: `${pkg.id}-expired`, + tone: 'danger', + message: translate(`${keyPrefix}.expired`, { date: formatted }), + }); + } else if (diffDays <= 14) { + warnings.push({ + id: `${pkg.id}-expires`, + tone: 'warning', + message: translate(`${keyPrefix}.expiresSoon`, { date: formatted }), + }); + } + } + + return warnings; +} + function BillingSkeleton() { return (
diff --git a/resources/js/admin/pages/DashboardPage.tsx b/resources/js/admin/pages/DashboardPage.tsx index 89bacbb..23abeac 100644 --- a/resources/js/admin/pages/DashboardPage.tsx +++ b/resources/js/admin/pages/DashboardPage.tsx @@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next'; import { CalendarDays, Camera, + AlertTriangle, Sparkles, Users, Plus, @@ -44,6 +45,7 @@ import { buildEngagementTabPath, } from '../constants'; import { useOnboardingProgress } from '../onboarding'; +import { buildLimitWarnings } from '../lib/limitWarnings'; interface DashboardState { summary: DashboardSummary | null; @@ -189,6 +191,28 @@ export default function DashboardPage() { const upcomingEvents = getUpcomingEvents(events); const publishedEvents = events.filter((event) => event.status === 'published'); + const primaryEvent = events[0] ?? null; + const primaryEventName = primaryEvent ? resolveEventName(primaryEvent.name, primaryEvent.slug) : null; + const primaryEventLimits = primaryEvent?.limits ?? null; + + const limitTranslate = React.useCallback( + (key: string, options?: Record) => tc(`limits.${key}`, options), + [tc], + ); + + const limitWarnings = React.useMemo( + () => buildLimitWarnings(primaryEventLimits, limitTranslate), + [primaryEventLimits, limitTranslate], + ); + + const limitScopeLabels = React.useMemo( + () => ({ + photos: tc('limits.photosTitle'), + guests: tc('limits.guestsTitle'), + gallery: tc('limits.galleryTitle'), + }), + [tc], + ); const actions = ( <> @@ -295,6 +319,76 @@ export default function DashboardPage() { + {primaryEventLimits ? ( + + +
+ + + {translate('limitsCard.title')} + + + {primaryEventName + ? translate('limitsCard.description', { name: primaryEventName }) + : translate('limitsCard.descriptionFallback')} + +
+ + {primaryEventName ?? translate('limitsCard.descriptionFallback')} + +
+ + {limitWarnings.length > 0 && ( +
+ {limitWarnings.map((warning) => ( + + + + {limitScopeLabels[warning.scope]} + + + {warning.message} + + + ))} +
+ )} + +
+ + +
+ + +
+
+ ) : null} +
@@ -422,6 +516,27 @@ export default function DashboardPage() { ); } +function formatDate(value: string | null, locale: string): string | null { + if (!value) { + return null; + } + + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + return null; + } + + try { + return new Intl.DateTimeFormat(locale, { + day: '2-digit', + month: 'short', + year: 'numeric', + }).format(date); + } catch { + return date.toISOString().slice(0, 10); + } +} + function resolveEventName(name: TenantEvent['name'], fallbackSlug: string): string { if (typeof name === 'string' && name.trim().length > 0) { return name; @@ -500,6 +615,109 @@ type ReadinessLabels = { }; }; +function LimitUsageRow({ + label, + summary, + unlimitedLabel, + usageLabel, + remainingLabel, +}: { + label: string; + summary: LimitUsageSummary | null; + unlimitedLabel: string; + usageLabel: string; + remainingLabel: string; +}) { + if (!summary) { + return ( +
+
+ {label} + {unlimitedLabel} +
+

{unlimitedLabel}

+
+ ); + } + + const limit = typeof summary.limit === 'number' && summary.limit > 0 ? summary.limit : null; + const percent = limit ? Math.min(100, Math.round((summary.used / limit) * 100)) : 0; + const remaining = typeof summary.remaining === 'number' ? summary.remaining : null; + + const barClass = summary.state === 'limit_reached' + ? 'bg-rose-500' + : summary.state === 'warning' + ? 'bg-amber-500' + : 'bg-emerald-500'; + + return ( +
+
+ {label} + + {limit ? usageLabel.replace('{{used}}', `${summary.used}`).replace('{{limit}}', `${limit}`) : unlimitedLabel} + +
+ {limit ? ( + <> +
+
+
+ {remaining !== null ? ( +

+ {remainingLabel + .replace('{{remaining}}', `${Math.max(0, remaining)}`) + .replace('{{limit}}', `${limit}`)} +

+ ) : null} + + ) : ( +

{unlimitedLabel}

+ )} +
+ ); +} + +function GalleryStatusRow({ + label, + summary, + locale, + messages, +}: { + label: string; + summary: GallerySummary | null; + locale: string; + messages: { expired: string; noExpiry: string; expires: string }; +}) { + const expiresAt = summary?.expires_at ? formatDate(summary.expires_at, locale) : null; + + let statusLabel = messages.noExpiry; + let badgeClass = 'bg-emerald-500/20 text-emerald-700'; + + if (summary?.state === 'expired') { + statusLabel = messages.expired; + badgeClass = 'bg-rose-500/20 text-rose-700'; + } else if (summary?.state === 'warning') { + const days = Math.max(0, summary.days_remaining ?? 0); + statusLabel = `${messages.expires.replace('{{date}}', expiresAt ?? '')} (${days}d)`; + badgeClass = 'bg-amber-500/20 text-amber-700'; + } else if (summary?.state === 'ok' && expiresAt) { + statusLabel = messages.expires.replace('{{date}}', expiresAt); + } + + return ( +
+
+ {label} + {statusLabel} +
+
+ ); +} + function ReadinessCard({ readiness, labels, diff --git a/resources/js/admin/pages/EventDetailPage.tsx b/resources/js/admin/pages/EventDetailPage.tsx index decf73f..647bdec 100644 --- a/resources/js/admin/pages/EventDetailPage.tsx +++ b/resources/js/admin/pages/EventDetailPage.tsx @@ -37,6 +37,8 @@ import { toggleEvent, submitTenantFeedback, } from '../api'; +import { buildLimitWarnings } from '../lib/limitWarnings'; +import { getApiErrorMessage } from '../lib/apiError'; import { isAuthError } from '../auth/tokens'; import { ADMIN_EVENTS_PATH, @@ -69,6 +71,7 @@ export default function EventDetailPage({ mode = 'detail' }: EventDetailPageProp const { slug: slugParam } = useParams<{ slug?: string }>(); const navigate = useNavigate(); const { t } = useTranslation('management'); + const { t: tCommon } = useTranslation('common'); const slug = slugParam ?? null; @@ -97,7 +100,11 @@ export default function EventDetailPage({ mode = 'detail' }: EventDetailPageProp setState((prev) => ({ ...prev, event: eventData, stats: statsData, loading: false })); } catch (error) { if (!isAuthError(error)) { - setState((prev) => ({ ...prev, error: t('events.errors.loadFailed', 'Event konnte nicht geladen werden.'), loading: false })); + setState((prev) => ({ + ...prev, + error: getApiErrorMessage(error, t('events.errors.loadFailed', 'Event konnte nicht geladen werden.')), + loading: false, + })); } } @@ -106,7 +113,11 @@ export default function EventDetailPage({ mode = 'detail' }: EventDetailPageProp setToolkit({ data: toolkitData, loading: false, error: null }); } catch (error) { if (!isAuthError(error)) { - setToolkit({ data: null, loading: false, error: t('toolkit.errors.loadFailed', 'Toolkit-Daten konnten nicht geladen werden.') }); + setToolkit({ + data: null, + loading: false, + error: getApiErrorMessage(error, t('toolkit.errors.loadFailed', 'Toolkit-Daten konnten nicht geladen werden.')), + }); } } }, [slug, t]); @@ -138,7 +149,11 @@ export default function EventDetailPage({ mode = 'detail' }: EventDetailPageProp })); } catch (error) { if (!isAuthError(error)) { - setState((prev) => ({ ...prev, busy: false, error: t('events.errors.toggleFailed', 'Status konnte nicht angepasst werden.') })); + setState((prev) => ({ + ...prev, + busy: false, + error: getApiErrorMessage(error, t('events.errors.toggleFailed', 'Status konnte nicht angepasst werden.')), + })); } else { setState((prev) => ({ ...prev, busy: false })); } @@ -196,6 +211,11 @@ export default function EventDetailPage({ mode = 'detail' }: EventDetailPageProp ); } + const limitWarnings = React.useMemo( + () => (event?.limits ? buildLimitWarnings(event.limits, (key, options) => tCommon(`limits.${key}`, options)) : []), + [event?.limits, tCommon], + ); + return ( {error && ( @@ -205,6 +225,23 @@ export default function EventDetailPage({ mode = 'detail' }: EventDetailPageProp )} + {limitWarnings.length > 0 && ( +
+ {limitWarnings.map((warning) => ( + + + + {warning.message} + + + ))} +
+ )} + {toolkit.error && ( {toolkit.error} diff --git a/resources/js/admin/pages/EventFormPage.tsx b/resources/js/admin/pages/EventFormPage.tsx index 2795eeb..d748dfa 100644 --- a/resources/js/admin/pages/EventFormPage.tsx +++ b/resources/js/admin/pages/EventFormPage.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { useTranslation } from 'react-i18next'; import { useNavigate, useParams, useSearchParams } from 'react-router-dom'; -import { ArrowLeft, Loader2, Save, Sparkles } from 'lucide-react'; +import { AlertTriangle, ArrowLeft, Loader2, Save, Sparkles } from 'lucide-react'; import { useQuery } from '@tanstack/react-query'; import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; @@ -25,6 +25,7 @@ import { } from '../api'; import { isAuthError } from '../auth/tokens'; import { isApiError } from '../lib/apiError'; +import { buildLimitWarnings } from '../lib/limitWarnings'; import { ADMIN_BILLING_PATH, ADMIN_EVENT_VIEW_PATH, ADMIN_EVENTS_PATH } from '../constants'; interface EventFormState { @@ -66,6 +67,7 @@ export default function EventFormPage() { const navigate = useNavigate(); const { t: tCommon } = useTranslation('common', { keyPrefix: 'errors' }); + const { t: tLimits } = useTranslation('common', { keyPrefix: 'limits' }); const [form, setForm] = React.useState({ name: '', @@ -193,6 +195,20 @@ export default function EventFormPage() { const loading = isEdit ? eventLoading : false; + const limitWarnings = React.useMemo(() => { + if (!isEdit) { + return []; + } + + return buildLimitWarnings(loadedEvent?.limits, tLimits); + }, [isEdit, loadedEvent?.limits, tLimits]); + + const limitScopeLabels = React.useMemo(() => ({ + photos: tLimits('photosTitle'), + guests: tLimits('guestsTitle'), + gallery: tLimits('galleryTitle'), + }), [tLimits]); + function ensureSlugSuffix(): string { if (!slugSuffixRef.current) { slugSuffixRef.current = Math.random().toString(36).slice(2, 7); @@ -394,11 +410,11 @@ export default function EventFormPage() { actions={actions} > {error && ( - - Hinweis - - {error.split('\n').map((line, index) => ( - {line} + + Hinweis + + {error.split('\n').map((line, index) => ( + {line} ))} {showUpgradeHint && (
@@ -411,6 +427,26 @@ export default function EventFormPage() { )} + {limitWarnings.length > 0 && ( +
+ {limitWarnings.map((warning) => ( + + + + {limitScopeLabels[warning.scope]} + + + {warning.message} + + + ))} +
+ )} + diff --git a/resources/js/admin/pages/EventInvitesPage.tsx b/resources/js/admin/pages/EventInvitesPage.tsx index daba9e8..1c2d11c 100644 --- a/resources/js/admin/pages/EventInvitesPage.tsx +++ b/resources/js/admin/pages/EventInvitesPage.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { useNavigate, useParams, useSearchParams } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; -import { ArrowLeft, Copy, Download, Loader2, Printer, QrCode, RefreshCw, Share2, X } from 'lucide-react'; +import { AlertTriangle, ArrowLeft, Copy, Download, Loader2, Printer, QrCode, RefreshCw, Share2, X } from 'lucide-react'; import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; import { Badge } from '@/components/ui/badge'; @@ -28,6 +28,7 @@ import { ADMIN_EVENT_TOOLKIT_PATH, ADMIN_EVENT_PHOTOS_PATH, } from '../constants'; +import { buildLimitWarnings } from '../lib/limitWarnings'; import { InviteLayoutCustomizerPanel, QrLayoutCustomization } from './components/InviteLayoutCustomizerPanel'; import { DesignerCanvas } from './components/invite-layout/DesignerCanvas'; import { @@ -159,6 +160,7 @@ export default function EventInvitesPage(): JSX.Element { const { slug } = useParams<{ slug?: string }>(); const navigate = useNavigate(); const { t } = useTranslation('management'); + const { t: tLimits } = useTranslation('common', { keyPrefix: 'limits' }); const [state, setState] = React.useState({ event: null, invites: [], loading: true, error: null }); const [creatingInvite, setCreatingInvite] = React.useState(false); @@ -711,12 +713,46 @@ export default function EventInvitesPage(): JSX.Element {
); + const limitWarnings = React.useMemo( + () => buildLimitWarnings(state.event?.limits, tLimits), + [state.event?.limits, tLimits] + ); + + const limitScopeLabels = React.useMemo( + () => ({ + photos: tLimits('photosTitle'), + guests: tLimits('guestsTitle'), + gallery: tLimits('galleryTitle'), + }), + [tLimits] + ); + return ( + {limitWarnings.length > 0 && ( +
+ {limitWarnings.map((warning) => ( + + + + {limitScopeLabels[warning.scope]} + + + {warning.message} + + + ))} +
+ )} + @@ -1075,12 +1111,17 @@ export default function EventInvitesPage(): JSX.Element { + {!state.loading && state.event?.limits?.can_add_guests === false && ( +

+ {tLimits('guestsBlocked')} +

+ )}
diff --git a/resources/js/admin/pages/EventPhotosPage.tsx b/resources/js/admin/pages/EventPhotosPage.tsx index f57eeb0..555ec7c 100644 --- a/resources/js/admin/pages/EventPhotosPage.tsx +++ b/resources/js/admin/pages/EventPhotosPage.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { useNavigate, useParams, useSearchParams } from 'react-router-dom'; -import { Camera, Loader2, Sparkles, Trash2 } from 'lucide-react'; +import { Camera, Loader2, Sparkles, Trash2, AlertTriangle } from 'lucide-react'; import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; import { Button } from '@/components/ui/button'; @@ -9,6 +9,9 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com import { AdminLayout } from '../components/AdminLayout'; import { deletePhoto, featurePhoto, getEventPhotos, TenantPhoto, unfeaturePhoto } from '../api'; import { isAuthError } from '../auth/tokens'; +import { getApiErrorMessage, isApiError } from '../lib/apiError'; +import { buildLimitWarnings, type EventLimitSummary } from '../lib/limitWarnings'; +import { useTranslation } from 'react-i18next'; import { ADMIN_EVENTS_PATH, ADMIN_EVENT_VIEW_PATH } from '../constants'; export default function EventPhotosPage() { @@ -16,11 +19,18 @@ export default function EventPhotosPage() { const [searchParams] = useSearchParams(); const slug = params.slug ?? searchParams.get('slug') ?? null; const navigate = useNavigate(); + const { t } = useTranslation('management'); + const { t: tCommon } = useTranslation('common'); + const translateLimits = React.useCallback( + (key: string, options?: Record) => tCommon(`limits.${key}`, options), + [tCommon] + ); const [photos, setPhotos] = React.useState([]); const [loading, setLoading] = React.useState(true); const [error, setError] = React.useState(null); const [busyId, setBusyId] = React.useState(null); + const [limits, setLimits] = React.useState(null); const load = React.useCallback(async () => { if (!slug) { @@ -30,11 +40,12 @@ export default function EventPhotosPage() { setLoading(true); setError(null); try { - const data = await getEventPhotos(slug); - setPhotos(data); + const result = await getEventPhotos(slug); + setPhotos(result.photos); + setLimits(result.limits ?? null); } catch (err) { if (!isAuthError(err)) { - setError('Fotos konnten nicht geladen werden.'); + setError(getApiErrorMessage(err, 'Fotos konnten nicht geladen werden.')); } } finally { setLoading(false); @@ -55,7 +66,7 @@ export default function EventPhotosPage() { setPhotos((prev) => prev.map((entry) => (entry.id === photo.id ? updated : entry))); } catch (err) { if (!isAuthError(err)) { - setError('Feature-Aktion fehlgeschlagen.'); + setError(getApiErrorMessage(err, 'Feature-Aktion fehlgeschlagen.')); } } finally { setBusyId(null); @@ -70,7 +81,7 @@ export default function EventPhotosPage() { setPhotos((prev) => prev.filter((entry) => entry.id !== photo.id)); } catch (err) { if (!isAuthError(err)) { - setError('Foto konnte nicht entfernt werden.'); + setError(getApiErrorMessage(err, 'Foto konnte nicht entfernt werden.')); } } finally { setBusyId(null); @@ -104,31 +115,36 @@ export default function EventPhotosPage() { return ( {error && ( - Aktion fehlgeschlagen + {t('photos.alerts.errorTitle', 'Aktion fehlgeschlagen')} {error} )} + + - Galerie + {t('photos.gallery.title', 'Galerie')} - Klick auf ein Foto, um es hervorzuheben oder zu löschen. + {t('photos.gallery.description', 'Klick auf ein Foto, um es hervorzuheben oder zu löschen.')} {loading ? ( ) : photos.length === 0 ? ( - + ) : (
{photos.map((photo) => ( @@ -178,6 +194,37 @@ export default function EventPhotosPage() { ); } +function LimitWarningsBanner({ + limits, + translate, +}: { + limits: EventLimitSummary | null; + translate: (key: string, options?: Record) => string; +}) { + const warnings = React.useMemo(() => buildLimitWarnings(limits, translate), [limits, translate]); + + if (!warnings.length) { + return null; + } + + return ( +
+ {warnings.map((warning) => ( + + + + {warning.message} + + + ))} +
+ ); +} + function GallerySkeleton() { return (
@@ -188,15 +235,14 @@ function GallerySkeleton() { ); } -function EmptyGallery() { +function EmptyGallery({ title, description }: { title: string; description: string }) { return (
-

Noch keine Fotos vorhanden

-

Motiviere deine Gäste zum Hochladen - hier erscheint anschließend die Galerie.

+

{title}

+

{description}

); } - diff --git a/resources/js/admin/pages/EventsPage.tsx b/resources/js/admin/pages/EventsPage.tsx index 2f1fc93..efae355 100644 --- a/resources/js/admin/pages/EventsPage.tsx +++ b/resources/js/admin/pages/EventsPage.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { Link, useNavigate } from 'react-router-dom'; -import { ArrowRight, CalendarDays, Plus, Settings, Sparkles, Share2 } from 'lucide-react'; +import { AlertTriangle, ArrowRight, CalendarDays, Plus, Settings, Sparkles, Share2 } from 'lucide-react'; import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; import { Badge } from '@/components/ui/badge'; @@ -10,6 +10,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com import { AdminLayout } from '../components/AdminLayout'; import { getEvents, TenantEvent } from '../api'; import { isAuthError } from '../auth/tokens'; +import { getApiErrorMessage } from '../lib/apiError'; import { adminPath, ADMIN_SETTINGS_PATH, @@ -21,8 +22,12 @@ import { ADMIN_EVENT_INVITES_PATH, ADMIN_EVENT_TOOLKIT_PATH, } from '../constants'; +import { buildLimitWarnings, type EventLimitSummary } from '../lib/limitWarnings'; +import { useTranslation } from 'react-i18next'; export default function EventsPage() { + const { t } = useTranslation('management'); + const { t: tCommon } = useTranslation('common'); const [rows, setRows] = React.useState([]); const [loading, setLoading] = React.useState(true); const [error, setError] = React.useState(null); @@ -34,7 +39,7 @@ export default function EventsPage() { setRows(await getEvents()); } catch (err) { if (!isAuthError(err)) { - setError('Laden fehlgeschlagen. Bitte später erneut versuchen.'); + setError(getApiErrorMessage(err, t('events.errors.loadFailed', 'Event konnte nicht geladen werden.'))); } } finally { setLoading(false); @@ -48,11 +53,11 @@ export default function EventsPage() { className="bg-gradient-to-r from-pink-500 via-fuchsia-500 to-purple-500 text-white shadow-lg shadow-pink-500/20" onClick={() => navigate(adminPath('/events/new'))} > - Neues Event + {t('events.list.actions.create', 'Neues Event')} @@ -60,8 +65,8 @@ export default function EventsPage() { return ( {error && ( @@ -74,15 +79,15 @@ export default function EventsPage() {
- Übersicht + {t('events.list.overview.title', 'Übersicht')} {rows.length === 0 - ? 'Noch keine Events - starte jetzt und lege dein erstes Event an.' - : `${rows.length} ${rows.length === 1 ? 'Event' : 'Events'} aktiv verwaltet.`} + ? t('events.list.overview.empty', 'Noch keine Events - starte jetzt und lege dein erstes Event an.') + : t('events.list.overview.count', '{{count}} Events aktiv verwaltet.', { count: rows.length })}
- Tenant Dashboard + {t('events.list.badge.dashboard', 'Tenant Dashboard')}
@@ -93,7 +98,7 @@ export default function EventsPage() { ) : (
{rows.map((event) => ( - + ))}
)} @@ -103,14 +108,41 @@ export default function EventsPage() { ); } -function EventCard({ event }: { event: TenantEvent }) { +function EventCard({ + event, + translateCommon, +}: { + event: TenantEvent; + translateCommon: (key: string, options?: Record) => string; +}) { const slug = event.slug; const isPublished = event.status === 'published'; const photoCount = event.photo_count ?? 0; const likeCount = event.like_count ?? 0; + const limitWarnings = React.useMemo( + () => buildLimitWarnings(event.limits ?? null, (key, opts) => translateCommon(`limits.${key}`, opts)), + [event.limits, translateCommon], + ); return (
+ {limitWarnings.length > 0 && ( +
+ {limitWarnings.map((warning) => ( + + + + {warning.message} + + + ))} +
+ )} +

{renderName(event.name)}

diff --git a/resources/js/admin/pages/SettingsPage.tsx b/resources/js/admin/pages/SettingsPage.tsx index 1776486..1c5d7b1 100644 --- a/resources/js/admin/pages/SettingsPage.tsx +++ b/resources/js/admin/pages/SettingsPage.tsx @@ -1,23 +1,56 @@ import React from 'react'; -import { LogOut, Palette } from 'lucide-react'; +import { AlertTriangle, LogOut, Palette } from 'lucide-react'; import { useNavigate } from 'react-router-dom'; import AppearanceToggleDropdown from '@/components/appearance-dropdown'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Switch } from '@/components/ui/switch'; +import { Alert, AlertDescription } from '@/components/ui/alert'; import { AdminLayout } from '../components/AdminLayout'; import { useAuth } from '../auth/context'; import { ADMIN_EVENTS_PATH, ADMIN_LOGIN_PATH } from '../constants'; +import { + getNotificationPreferences, + updateNotificationPreferences, + NotificationPreferences, + NotificationPreferencesMeta, +} from '../api'; +import { getApiErrorMessage } from '../lib/apiError'; +import { useTranslation } from 'react-i18next'; export default function SettingsPage() { const navigate = useNavigate(); const { user, logout } = useAuth(); + const { t } = useTranslation('management'); + + const [preferences, setPreferences] = React.useState(null); + const [defaults, setDefaults] = React.useState({}); + const [loadingNotifications, setLoadingNotifications] = React.useState(true); + const [savingNotifications, setSavingNotifications] = React.useState(false); + const [notificationError, setNotificationError] = React.useState(null); + const [notificationMeta, setNotificationMeta] = React.useState(null); function handleLogout() { logout({ redirect: ADMIN_LOGIN_PATH }); } + React.useEffect(() => { + (async () => { + try { + const result = await getNotificationPreferences(); + setPreferences(result.preferences); + setDefaults(result.defaults); + setNotificationMeta(result.meta ?? null); + } catch (error) { + setNotificationError(getApiErrorMessage(error, t('settings.notifications.errorLoad', 'Benachrichtigungseinstellungen konnten nicht geladen werden.'))); + } finally { + setLoadingNotifications(false); + } + })(); + }, [t]); + const actions = ( + + + {translate('settings.notifications.hint', 'Du kannst Benachrichtigungen jederzeit wieder aktivieren.')} + +
+ {creditText &&

{creditText}

} +
+ ); +} + +function buildPreferenceMeta( + translate: (key: string, options?: Record) => string +): Array<{ key: keyof NotificationPreferences; label: string; description: string }> { + const map = [ + { + key: 'photo_thresholds', + label: translate('settings.notifications.items.photoThresholds.label', 'Warnung bei Foto-Schwellen'), + description: translate('settings.notifications.items.photoThresholds.description', 'Sende Warnungen bei 80 % und 95 % Foto-Auslastung.'), + }, + { + key: 'photo_limits', + label: translate('settings.notifications.items.photoLimits.label', 'Sperre bei Foto-Limit'), + description: translate('settings.notifications.items.photoLimits.description', 'Informiere mich, sobald keine Foto-Uploads mehr möglich sind.'), + }, + { + key: 'guest_thresholds', + label: translate('settings.notifications.items.guestThresholds.label', 'Warnung bei Gästekontingent'), + description: translate('settings.notifications.items.guestThresholds.description', 'Warnung kurz bevor alle Gästelinks vergeben sind.'), + }, + { + key: 'guest_limits', + label: translate('settings.notifications.items.guestLimits.label', 'Sperre bei Gästelimit'), + description: translate('settings.notifications.items.guestLimits.description', 'Hinweis, wenn keine neuen Gästelinks mehr erzeugt werden können.'), + }, + { + key: 'gallery_warnings', + label: translate('settings.notifications.items.galleryWarnings.label', 'Galerie läuft bald ab'), + description: translate('settings.notifications.items.galleryWarnings.description', 'Erhalte 7 und 1 Tag vor Ablauf eine Erinnerung.'), + }, + { + key: 'gallery_expired', + label: translate('settings.notifications.items.galleryExpired.label', 'Galerie ist abgelaufen'), + description: translate('settings.notifications.items.galleryExpired.description', 'Informiere mich, sobald Gäste die Galerie nicht mehr sehen können.'), + }, + { + key: 'event_thresholds', + label: translate('settings.notifications.items.eventThresholds.label', 'Warnung bei Event-Kontingent'), + description: translate('settings.notifications.items.eventThresholds.description', 'Hinweis, wenn das Reseller-Paket fast ausgeschöpft ist.'), + }, + { + key: 'event_limits', + label: translate('settings.notifications.items.eventLimits.label', 'Sperre bei Event-Kontingent'), + description: translate('settings.notifications.items.eventLimits.description', 'Nachricht, sobald keine weiteren Events erstellt werden können.'), + }, + { + key: 'package_expiring', + label: translate('settings.notifications.items.packageExpiring.label', 'Paket läuft bald ab'), + description: translate('settings.notifications.items.packageExpiring.description', 'Erinnerungen bei 30, 7 und 1 Tag vor Paketablauf.'), + }, + { + key: 'package_expired', + label: translate('settings.notifications.items.packageExpired.label', 'Paket ist abgelaufen'), + description: translate('settings.notifications.items.packageExpired.description', 'Benachrichtige mich, wenn das Paket abgelaufen ist.'), + }, + { + key: 'credits_low', + label: translate('settings.notifications.items.creditsLow.label', 'Event-Credits werden knapp'), + description: translate('settings.notifications.items.creditsLow.description', 'Informiert mich bei niedrigen Credit-Schwellen.'), + }, + ]; + + return map as Array<{ key: keyof NotificationPreferences; label: string; description: string }>; +} + +function formatDateTime(value: string, locale: string): string { + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + return value; + } + + try { + return new Intl.DateTimeFormat(locale, { + day: '2-digit', + month: 'short', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + }).format(date); + } catch { + return date.toISOString(); + } +} diff --git a/resources/js/guest/i18n/messages.ts b/resources/js/guest/i18n/messages.ts index a4cc9eb..646713a 100644 --- a/resources/js/guest/i18n/messages.ts +++ b/resources/js/guest/i18n/messages.ts @@ -188,6 +188,14 @@ export const messages: Record = { title: 'Nicht gefunden', description: 'Die Seite konnte nicht gefunden werden.', }, + galleryCountdown: { + expiresIn: 'Noch {days} Tage online', + expiresToday: 'Letzter Tag!', + expired: 'Galerie abgelaufen', + description: 'Sichere jetzt deine Lieblingsfotos – die Galerie verschwindet bald.', + expiredDescription: 'Nur die Veranstalter:innen können die Galerie jetzt noch verlängern.', + ctaUpload: 'Letzte Fotos hochladen', + }, galleryPublic: { title: 'Galerie', loading: 'Galerie wird geladen ...', @@ -298,6 +306,63 @@ export const messages: Record = { limitReached: 'Upload-Limit erreicht ({used} / {max} Fotos). Bitte kontaktiere die Veranstalter für ein Upgrade.', limitUnlimited: 'unbegrenzt', limitWarning: 'Nur noch {remaining} von {max} Fotos möglich. Bitte kontaktiere die Veranstalter für ein Upgrade.', + galleryWarningDay: 'Galerie läuft in {days} Tag ab. Teile deine Fotos rechtzeitig!', + galleryWarningDays: 'Galerie läuft in {days} Tagen ab. Teile deine Fotos rechtzeitig!', + status: { + title: 'Dein Paketstatus', + subtitle: 'Behalte deine Kontingente im Blick, bevor es eng wird.', + badges: { + ok: 'Bereit', + warning: 'Hinweis', + limit_reached: 'Limit erreicht', + unlimited: 'Unbegrenzt', + }, + cards: { + photos: { + title: 'Fotos', + remaining: 'Nur noch {remaining} von {limit} Fotos möglich', + unlimited: 'Unbegrenzte Foto-Uploads inklusive', + }, + guests: { + title: 'Gäste', + remaining: '{remaining} Gäste frei (max. {limit})', + unlimited: 'Beliebig viele Gäste erlaubt', + }, + }, + }, + dialogs: { + close: 'Verstanden', + photoLimit: { + title: 'Upload-Limit erreicht', + description: 'Es wurden {used} von {limit} Fotos hochgeladen. Bitte kontaktiere das Team für ein Upgrade.', + hint: 'Tipp: Größere Pakete schalten sofort wieder Uploads frei.', + }, + deviceLimit: { + title: 'Gerätelimit erreicht', + description: 'Dieses Gerät hat sein Upload-Kontingent ausgeschöpft.', + hint: 'Nutze ein anderes Gerät oder sprich die Veranstalter:innen an.', + }, + packageMissing: { + title: 'Event pausiert Uploads', + description: 'Für dieses Event sind aktuell keine Uploads freigeschaltet.', + hint: 'Bitte kontaktiere die Veranstalter:innen für weitere Informationen.', + }, + galleryExpired: { + title: 'Galerie abgelaufen', + description: 'Die Galerie ist geschlossen – neue Uploads sind nicht mehr möglich.', + hint: 'Nur die Veranstalter:innen können die Galerie verlängern.', + }, + csrf: { + title: 'Sitzung abgelaufen', + description: 'Bitte lade die Seite neu und versuche den Upload anschließend erneut.', + hint: 'Aktualisiere die Seite, um eine neue Sitzung zu starten.', + }, + generic: { + title: 'Upload fehlgeschlagen', + description: 'Der Upload konnte nicht abgeschlossen werden. Bitte versuche es später erneut.', + hint: 'Bleibt das Problem bestehen, kontaktiere die Veranstalter:innen.', + }, + }, errors: { photoLimit: 'Upload-Limit erreicht. Bitte kontaktiere die Veranstalter für ein Upgrade.', deviceLimit: 'Dieses Gerät hat das Upload-Limit erreicht. Bitte wende dich an die Veranstalter.', @@ -551,6 +616,14 @@ export const messages: Record = { title: 'Not found', description: 'We could not find the page you requested.', }, + galleryCountdown: { + expiresIn: '{days} days remaining', + expiresToday: 'Final day!', + expired: 'Gallery expired', + description: 'Save your favourite photos before the gallery goes offline.', + expiredDescription: 'Only the organizers can extend the gallery now.', + ctaUpload: 'Share last photos', + }, galleryPublic: { title: 'Gallery', loading: 'Loading gallery ...', @@ -661,6 +734,63 @@ export const messages: Record = { limitReached: 'Upload limit reached ({used} / {max} photos). Contact the organizers for an upgrade.', limitUnlimited: 'unlimited', limitWarning: 'Only {remaining} of {max} photos left. Please contact the organizers for an upgrade.', + galleryWarningDay: 'Gallery expires in {days} day. Upload your photos soon!', + galleryWarningDays: 'Gallery expires in {days} days. Upload your photos soon!', + status: { + title: 'Your package status', + subtitle: 'Keep an eye on your remaining allowances.', + badges: { + ok: 'Ready', + warning: 'Heads-up', + limit_reached: 'Limit reached', + unlimited: 'Unlimited', + }, + cards: { + photos: { + title: 'Photos', + remaining: '{remaining} of {limit} photo slots left', + unlimited: 'Unlimited photo uploads included', + }, + guests: { + title: 'Guests', + remaining: '{remaining} guest slots free (max {limit})', + unlimited: 'Unlimited guests allowed', + }, + }, + }, + dialogs: { + close: 'Got it', + photoLimit: { + title: 'Upload limit reached', + description: '{used} of {limit} photos are already uploaded. Please reach out to upgrade your package.', + hint: 'Tip: Upgrading the package re-enables uploads instantly.', + }, + deviceLimit: { + title: 'Device limit reached', + description: 'This device has used all available upload slots.', + hint: 'Try a different device or contact the organizers.', + }, + packageMissing: { + title: 'Uploads paused', + description: 'This event is currently not accepting new uploads.', + hint: 'Check in with the organizers for the latest status.', + }, + galleryExpired: { + title: 'Gallery closed', + description: 'The gallery has expired and no new uploads can be added.', + hint: 'Only the organizers can extend the gallery window.', + }, + csrf: { + title: 'Session expired', + description: 'Refresh the page and try the upload again.', + hint: 'Reload the page to start a new session.', + }, + generic: { + title: 'Upload failed', + description: 'We could not complete the upload. Please try again later.', + hint: 'If it keeps happening, reach out to the organizers.', + }, + }, errors: { photoLimit: 'Upload limit reached. Contact the organizers for an upgrade.', deviceLimit: 'This device reached its upload limit. Please contact the organizers.', diff --git a/resources/js/guest/lib/__tests__/limitSummaries.test.ts b/resources/js/guest/lib/__tests__/limitSummaries.test.ts new file mode 100644 index 0000000..f230d4d --- /dev/null +++ b/resources/js/guest/lib/__tests__/limitSummaries.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from 'vitest'; + +import type { EventPackageLimits } from '../../services/eventApi'; +import { buildLimitSummaries } from '../limitSummaries'; + +const translations = new Map([ + ['upload.status.cards.photos.title', 'Fotos'], + ['upload.status.cards.photos.remaining', 'Noch {remaining} von {limit}'], + ['upload.status.cards.photos.unlimited', 'Unbegrenzte Uploads'], + ['upload.status.cards.guests.title', 'Gäste'], + ['upload.status.cards.guests.remaining', '{remaining} Gäste frei (max. {limit})'], + ['upload.status.cards.guests.unlimited', 'Unbegrenzte Gäste'], + ['upload.status.badges.ok', 'OK'], + ['upload.status.badges.warning', 'Warnung'], + ['upload.status.badges.limit_reached', 'Limit erreicht'], + ['upload.status.badges.unlimited', 'Unbegrenzt'], +]); + +const t = (key: string) => translations.get(key) ?? key; + +describe('buildLimitSummaries', () => { + it('builds photo summary with progress and warning tone', () => { + const limits: EventPackageLimits = { + photos: { + limit: 100, + used: 80, + remaining: 20, + percentage: 80, + state: 'warning', + threshold_reached: 80, + next_threshold: 95, + thresholds: [80, 95], + }, + guests: null, + gallery: null, + can_upload_photos: true, + can_add_guests: true, + }; + + const cards = buildLimitSummaries(limits, t); + + expect(cards).toHaveLength(1); + const card = cards[0]; + expect(card.id).toBe('photos'); + expect(card.tone).toBe('warning'); + expect(card.progress).toBe(80); + expect(card.valueLabel).toBe('80 / 100'); + expect(card.description).toBe('Noch 20 von 100'); + expect(card.badgeLabel).toBe('Warnung'); + }); + + it('builds unlimited guest summary without progress', () => { + const limits: EventPackageLimits = { + photos: null, + guests: { + limit: null, + used: 5, + remaining: null, + percentage: null, + state: 'unlimited', + threshold_reached: null, + next_threshold: null, + thresholds: [], + }, + gallery: null, + can_upload_photos: true, + can_add_guests: true, + }; + + const cards = buildLimitSummaries(limits, t); + + expect(cards).toHaveLength(1); + const card = cards[0]; + expect(card.id).toBe('guests'); + expect(card.progress).toBeNull(); + expect(card.tone).toBe('neutral'); + expect(card.valueLabel).toBe('Unbegrenzt'); + expect(card.description).toBe('Unbegrenzte Gäste'); + expect(card.badgeLabel).toBe('Unbegrenzt'); + }); + + it('returns empty list when no limits are provided', () => { + expect(buildLimitSummaries(null, t)).toEqual([]); + expect(buildLimitSummaries(undefined, t)).toEqual([]); + }); +}); diff --git a/resources/js/guest/lib/__tests__/uploadErrorDialog.test.ts b/resources/js/guest/lib/__tests__/uploadErrorDialog.test.ts new file mode 100644 index 0000000..33c3758 --- /dev/null +++ b/resources/js/guest/lib/__tests__/uploadErrorDialog.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest'; + +import { resolveUploadErrorDialog } from '../uploadErrorDialog'; + +const translations = new Map([ + ['upload.dialogs.photoLimit.title', 'Upload-Limit erreicht'], + ['upload.dialogs.photoLimit.description', 'Es wurden {used} von {limit} Fotos hochgeladen. Es bleiben {remaining}.'], + ['upload.dialogs.photoLimit.hint', 'Wende dich an das Team.'], + ['upload.dialogs.deviceLimit.title', 'Dieses Gerät ist voll'], + ['upload.dialogs.deviceLimit.description', 'Du hast das Geräte-Limit erreicht.'], + ['upload.dialogs.deviceLimit.hint', 'Nutze ein anderes Gerät oder kontaktiere das Team.'], + ['upload.dialogs.packageMissing.title', 'Event nicht bereit'], + ['upload.dialogs.packageMissing.description', 'Das Event akzeptiert aktuell keine Uploads.'], + ['upload.dialogs.packageMissing.hint', 'Frag die Veranstalter:innen nach dem Status.'], + ['upload.dialogs.galleryExpired.title', 'Galerie abgelaufen'], + ['upload.dialogs.galleryExpired.description', 'Uploads sind nicht mehr möglich.'], + ['upload.dialogs.galleryExpired.hint', 'Bitte wende dich an die Veranstalter:innen.'], + ['upload.dialogs.csrf.title', 'Sicherheitsabgleich erforderlich'], + ['upload.dialogs.csrf.description', 'Bitte lade die Seite neu und versuche es erneut.'], + ['upload.dialogs.csrf.hint', 'Aktualisiere die Seite.'], + ['upload.dialogs.generic.title', 'Upload fehlgeschlagen'], + ['upload.dialogs.generic.description', 'Der Upload konnte nicht abgeschlossen werden.'], + ['upload.dialogs.generic.hint', 'Versuche es später erneut.'], +]); + +const t = (key: string) => translations.get(key) ?? key; + +describe('resolveUploadErrorDialog', () => { + it('renders photo limit dialog with placeholders', () => { + const dialog = resolveUploadErrorDialog( + 'photo_limit_exceeded', + { used: 120, limit: 120, remaining: 0 }, + t + ); + + expect(dialog.title).toBe('Upload-Limit erreicht'); + expect(dialog.description).toBe('Es wurden 120 von 120 Fotos hochgeladen. Es bleiben 0.'); + expect(dialog.hint).toBe('Wende dich an das Team.'); + expect(dialog.tone).toBe('danger'); + }); + + it('falls back to generic dialog when code is unknown', () => { + const dialog = resolveUploadErrorDialog('something_else', undefined, t); + + expect(dialog.tone).toBe('info'); + expect(dialog.title).toBe('Upload fehlgeschlagen'); + expect(dialog.description).toBe('Der Upload konnte nicht abgeschlossen werden.'); + }); +}); diff --git a/resources/js/guest/lib/limitSummaries.ts b/resources/js/guest/lib/limitSummaries.ts new file mode 100644 index 0000000..5ca3d9f --- /dev/null +++ b/resources/js/guest/lib/limitSummaries.ts @@ -0,0 +1,107 @@ +import type { EventPackageLimits, LimitUsageSummary } from '../services/eventApi'; + +export type LimitTone = 'neutral' | 'warning' | 'danger'; + +export type LimitSummaryCard = { + id: 'photos' | 'guests'; + label: string; + state: LimitUsageSummary['state']; + tone: LimitTone; + used: number; + limit: number | null; + remaining: number | null; + progress: number | null; + valueLabel: string; + description: string; + badgeLabel: string; +}; + +type TranslateFn = (key: string, fallback?: string) => string; + +function resolveTone(state: LimitUsageSummary['state']): LimitTone { + if (state === 'limit_reached') { + return 'danger'; + } + + if (state === 'warning') { + return 'warning'; + } + + return 'neutral'; +} + +function buildCard( + id: 'photos' | 'guests', + summary: LimitUsageSummary, + t: TranslateFn +): LimitSummaryCard { + const labelKey = id === 'photos' ? 'upload.status.cards.photos.title' : 'upload.status.cards.guests.title'; + const remainingKey = id === 'photos' + ? 'upload.status.cards.photos.remaining' + : 'upload.status.cards.guests.remaining'; + const unlimitedKey = id === 'photos' + ? 'upload.status.cards.photos.unlimited' + : 'upload.status.cards.guests.unlimited'; + + const tone = resolveTone(summary.state); + const progress = typeof summary.limit === 'number' && summary.limit > 0 + ? Math.min(100, Math.round((summary.used / summary.limit) * 100)) + : null; + + const valueLabel = typeof summary.limit === 'number' && summary.limit > 0 + ? `${summary.used.toLocaleString()} / ${summary.limit.toLocaleString()}` + : t('upload.status.badges.unlimited'); + + const description = summary.state === 'unlimited' + ? t(unlimitedKey) + : summary.remaining !== null && summary.limit !== null + ? t(remainingKey) + .replace('{remaining}', `${Math.max(0, summary.remaining)}`) + .replace('{limit}', `${summary.limit}`) + : valueLabel; + + const badgeKey = (() => { + switch (summary.state) { + case 'limit_reached': + return 'upload.status.badges.limit_reached'; + case 'warning': + return 'upload.status.badges.warning'; + case 'unlimited': + return 'upload.status.badges.unlimited'; + default: + return 'upload.status.badges.ok'; + } + })(); + + return { + id, + label: t(labelKey), + state: summary.state, + tone, + used: summary.used, + limit: summary.limit, + remaining: summary.remaining, + progress, + valueLabel, + description, + badgeLabel: t(badgeKey), + }; +} + +export function buildLimitSummaries(limits: EventPackageLimits | null | undefined, t: TranslateFn): LimitSummaryCard[] { + if (!limits) { + return []; + } + + const cards: LimitSummaryCard[] = []; + + if (limits.photos) { + cards.push(buildCard('photos', limits.photos, t)); + } + + if (limits.guests) { + cards.push(buildCard('guests', limits.guests, t)); + } + + return cards; +} diff --git a/resources/js/guest/lib/uploadErrorDialog.ts b/resources/js/guest/lib/uploadErrorDialog.ts new file mode 100644 index 0000000..281e238 --- /dev/null +++ b/resources/js/guest/lib/uploadErrorDialog.ts @@ -0,0 +1,94 @@ +import type { TranslateFn } from '../i18n/useTranslation'; + +export type UploadErrorDialog = { + code: string; + title: string; + description: string; + hint?: string; + tone: 'danger' | 'warning' | 'info'; +}; + +function formatWithNumbers(template: string, values: Record): string { + return Object.entries(values).reduce((acc, [key, value]) => { + if (value === undefined) { + return acc; + } + + return acc.replaceAll(`{${key}}`, String(value)); + }, template); +} + +export function resolveUploadErrorDialog( + code: string | undefined, + meta: Record | undefined, + t: TranslateFn +): UploadErrorDialog { + const normalized = (code ?? 'unknown').toLowerCase(); + const getNumber = (value: unknown): number | undefined => (typeof value === 'number' ? value : undefined); + + switch (normalized) { + case 'photo_limit_exceeded': { + const used = getNumber(meta?.used); + const limit = getNumber(meta?.limit); + const remaining = getNumber(meta?.remaining); + + return { + code: normalized, + tone: 'danger', + title: t('upload.dialogs.photoLimit.title'), + description: formatWithNumbers(t('upload.dialogs.photoLimit.description'), { + used, + limit, + remaining, + }), + hint: t('upload.dialogs.photoLimit.hint'), + }; + } + + case 'upload_device_limit': + return { + code: normalized, + tone: 'warning', + title: t('upload.dialogs.deviceLimit.title'), + description: t('upload.dialogs.deviceLimit.description'), + hint: t('upload.dialogs.deviceLimit.hint'), + }; + + case 'event_package_missing': + case 'event_not_found': + return { + code: normalized, + tone: 'info', + title: t('upload.dialogs.packageMissing.title'), + description: t('upload.dialogs.packageMissing.description'), + hint: t('upload.dialogs.packageMissing.hint'), + }; + + case 'gallery_expired': + return { + code: normalized, + tone: 'danger', + title: t('upload.dialogs.galleryExpired.title'), + description: t('upload.dialogs.galleryExpired.description'), + hint: t('upload.dialogs.galleryExpired.hint'), + }; + + case 'csrf_mismatch': + return { + code: normalized, + tone: 'warning', + title: t('upload.dialogs.csrf.title'), + description: t('upload.dialogs.csrf.description'), + hint: t('upload.dialogs.csrf.hint'), + }; + + default: + return { + code: normalized, + tone: 'info', + title: t('upload.dialogs.generic.title'), + description: t('upload.dialogs.generic.description'), + hint: t('upload.dialogs.generic.hint'), + }; + } +} diff --git a/resources/js/guest/pages/GalleryPage.tsx b/resources/js/guest/pages/GalleryPage.tsx index e9f38b9..32ebb05 100644 --- a/resources/js/guest/pages/GalleryPage.tsx +++ b/resources/js/guest/pages/GalleryPage.tsx @@ -1,18 +1,21 @@ import React, { useEffect, useState } from 'react'; import { Page } from './_util'; -import { useParams, useSearchParams } from 'react-router-dom'; +import { useNavigate, useParams, useSearchParams } from 'react-router-dom'; import { usePollGalleryDelta } from '../polling/usePollGalleryDelta'; -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Alert, AlertDescription } from '@/components/ui/alert'; import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; import FiltersBar, { type GalleryFilter } from '../components/FiltersBar'; -import { Heart, Users, Image as ImageIcon, Camera, Package as PackageIcon } from 'lucide-react'; +import { Heart, Users, Image as ImageIcon, Camera, Package as PackageIcon, AlertTriangle } from 'lucide-react'; import { likePhoto } from '../services/photosApi'; import PhotoLightbox from './PhotoLightbox'; import { fetchEvent, getEventPackage, fetchStats, type EventData, type EventPackage, type EventStats } from '../services/eventApi'; +import { useTranslation } from '../i18n/useTranslation'; export default function GalleryPage() { const { token } = useParams<{ token?: string }>(); + const navigate = useNavigate(); const { photos, loading, newCount, acknowledgeNew } = usePollGalleryDelta(token ?? ''); const [filter, setFilter] = React.useState('latest'); const [currentPhotoIndex, setCurrentPhotoIndex] = React.useState(null); @@ -22,6 +25,8 @@ export default function GalleryPage() { const [eventPackage, setEventPackage] = useState(null); const [stats, setStats] = useState(null); const [eventLoading, setEventLoading] = useState(true); + const { t } = useTranslation(); + const locale = typeof window !== 'undefined' ? window.navigator.language : 'de-DE'; const [searchParams] = useSearchParams(); const photoIdParam = searchParams.get('photoId'); @@ -82,6 +87,109 @@ export default function GalleryPage() { const [liked, setLiked] = React.useState>(new Set()); const [counts, setCounts] = React.useState>({}); + const photoLimits = eventPackage?.limits?.photos ?? null; + const guestLimits = eventPackage?.limits?.guests ?? null; + const galleryLimits = eventPackage?.limits?.gallery ?? null; + + const galleryCountdown = React.useMemo(() => { + if (!galleryLimits) { + return null; + } + + if (galleryLimits.state === 'expired') { + return { + tone: 'danger' as const, + label: t('galleryCountdown.expired'), + description: t('galleryCountdown.expiredDescription'), + cta: null, + }; + } + + if (galleryLimits.state === 'warning') { + const days = Math.max(0, galleryLimits.days_remaining ?? 0); + const label = days <= 1 + ? t('galleryCountdown.expiresToday') + : t('galleryCountdown.expiresIn').replace('{days}', `${days}`); + + return { + tone: days <= 1 ? ('danger' as const) : ('warning' as const), + label, + description: t('galleryCountdown.description'), + cta: { + type: 'upload' as const, + label: t('galleryCountdown.ctaUpload'), + }, + }; + } + + return null; + }, [galleryLimits, t]); + + const handleCountdownCta = React.useCallback(() => { + if (!galleryCountdown?.cta || !token) { + return; + } + + if (galleryCountdown.cta.type === 'upload') { + navigate(`/e/${encodeURIComponent(token)}/upload`); + } + }, [galleryCountdown?.cta, navigate, token]); + + const packageWarnings = React.useMemo(() => { + const warnings: { id: string; tone: 'warning' | 'danger'; message: string }[] = []; + + if (photoLimits?.state === 'limit_reached' && typeof photoLimits.limit === 'number') { + warnings.push({ + id: 'photos-blocked', + tone: 'danger', + message: t('upload.limitReached') + .replace('{used}', `${photoLimits.used}`) + .replace('{max}', `${photoLimits.limit}`), + }); + } else if ( + photoLimits?.state === 'warning' + && typeof photoLimits.remaining === 'number' + && typeof photoLimits.limit === 'number' + ) { + warnings.push({ + id: 'photos-warning', + tone: 'warning', + message: t('upload.limitWarning') + .replace('{remaining}', `${photoLimits.remaining}`) + .replace('{max}', `${photoLimits.limit}`), + }); + } + + if (galleryLimits?.state === 'expired') { + warnings.push({ + id: 'gallery-expired', + tone: 'danger', + message: t('upload.errors.galleryExpired'), + }); + } else if (galleryLimits?.state === 'warning') { + const days = Math.max(0, galleryLimits.days_remaining ?? 0); + const key = days === 1 ? 'upload.galleryWarningDay' : 'upload.galleryWarningDays'; + warnings.push({ + id: 'gallery-warning', + tone: 'warning', + message: t(key).replace('{days}', `${days}`), + }); + } + + return warnings; + }, [photoLimits, galleryLimits, t]); + + const formatDate = React.useCallback((value: string | null) => { + if (!value) return null; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return null; + try { + return new Intl.DateTimeFormat(locale, { day: '2-digit', month: 'short', year: 'numeric' }).format(date); + } catch { + return date.toISOString().slice(0, 10); + } + }, [locale]); + async function onLike(id: number) { if (liked.has(id)) return; setLiked(new Set(liked).add(id)); @@ -111,17 +219,62 @@ export default function GalleryPage() { - - - Galerie: {event?.name || 'Event'} - - - -
- -

Online Gäste

-

{stats?.onlineGuests || 0}

+
+ + + Galerie: {event?.name || 'Event'} + {galleryCountdown && ( + + {galleryCountdown.label} + + )} + + {galleryCountdown?.cta && ( + + )}
+ {galleryCountdown && ( + + {galleryCountdown.description} + + )} + + + {packageWarnings.length > 0 && ( +
+ {packageWarnings.map((warning) => ( + + + + {warning.message} + + + ))} +
+ )} + +
+
+ +

Online Gäste

+

{stats?.onlineGuests || 0}

+

Gesamt Likes

@@ -133,24 +286,38 @@ export default function GalleryPage() {

{photos.length}

{eventPackage && ( -
- +
+

Package

-

{eventPackage.package.name}

-
-
-
-

- {eventPackage.used_photos} / {eventPackage.package.max_photos} Fotos -

- {new Date(eventPackage.expires_at) < new Date() && ( -

Abgelaufen: {new Date(eventPackage.expires_at).toLocaleDateString()}

+

{eventPackage.package?.name ?? '—'}

+ {photoLimits?.limit ? ( + <> +
+
+
+

+ {photoLimits.used} / {photoLimits.limit} Fotos +

+ + ) : ( +

{t('upload.limitUnlimited')}

)} + {guestLimits?.limit ? ( +

+ Gäste: {guestLimits.used} / {guestLimits.limit} +

+ ) : null} + {galleryLimits?.expires_at ? ( +

+ Galerie bis {formatDate(galleryLimits.expires_at)} +

+ ) : null}
)} +
diff --git a/resources/js/guest/pages/UploadPage.tsx b/resources/js/guest/pages/UploadPage.tsx index 236746f..d507d20 100644 --- a/resources/js/guest/pages/UploadPage.tsx +++ b/resources/js/guest/pages/UploadPage.tsx @@ -5,6 +5,14 @@ import BottomNav from '../components/BottomNav'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { Alert, AlertDescription } from '@/components/ui/alert'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; import { uploadPhoto, type UploadError } from '../services/photosApi'; import { useGuestTaskProgress } from '../hooks/useGuestTaskProgress'; import { cn } from '@/lib/utils'; @@ -22,6 +30,8 @@ import { } from 'lucide-react'; import { getEventPackage, type EventPackage } from '../services/eventApi'; import { useTranslation } from '../i18n/useTranslation'; +import { buildLimitSummaries, type LimitSummaryCard } from '../lib/limitSummaries'; +import { resolveUploadErrorDialog, type UploadErrorDialog } from '../lib/uploadErrorDialog'; interface Task { id: number; @@ -65,19 +75,6 @@ function getErrorName(error: unknown): string | undefined { return undefined; } -function getErrorMessage(error: unknown): string | undefined { - if (error instanceof Error && typeof error.message === 'string') { - return error.message; - } - - if (typeof error === 'object' && error !== null && 'message' in error) { - const message = (error as { message?: unknown }).message; - return typeof message === 'string' ? message : undefined; - } - - return undefined; -} - const DEFAULT_PREFS: CameraPreferences = { facingMode: 'environment', countdownSeconds: 3, @@ -87,6 +84,24 @@ const DEFAULT_PREFS: CameraPreferences = { flashPreferred: false, }; +const LIMIT_CARD_STYLES: Record = { + neutral: { + card: 'border-slate-200 bg-white/90 text-slate-900 dark:border-white/15 dark:bg-white/10 dark:text-white', + badge: 'bg-slate-900/10 text-slate-900 dark:bg-white/20 dark:text-white', + bar: 'bg-emerald-500', + }, + warning: { + card: 'border-amber-200 bg-amber-50 text-amber-900 dark:border-amber-400/40 dark:bg-amber-500/15 dark:text-amber-50', + badge: 'bg-white/70 text-amber-900 dark:bg-amber-400/25 dark:text-amber-50', + bar: 'bg-amber-500', + }, + danger: { + card: 'border-rose-200 bg-rose-50 text-rose-900 dark:border-rose-400/50 dark:bg-rose-500/15 dark:text-rose-50', + badge: 'bg-white/70 text-rose-900 dark:bg-rose-400/20 dark:text-rose-50', + bar: 'bg-rose-500', + }, +}; + export default function UploadPage() { const { token } = useParams<{ token: string }>(); const eventKey = token ?? ''; @@ -115,12 +130,19 @@ export default function UploadPage() { const [statusMessage, setStatusMessage] = useState(''); const [reviewPhoto, setReviewPhoto] = useState<{ dataUrl: string; file: File } | null>(null); - const [uploadProgress, setUploadProgress] = useState(0); - const [uploadError, setUploadError] = useState(null); - const [uploadWarning, setUploadWarning] = useState(null); +const [uploadProgress, setUploadProgress] = useState(0); +const [uploadError, setUploadError] = useState(null); +const [uploadWarning, setUploadWarning] = useState(null); - const [eventPackage, setEventPackage] = useState(null); - const [canUpload, setCanUpload] = useState(true); +const [errorDialog, setErrorDialog] = useState(null); + +const [eventPackage, setEventPackage] = useState(null); +const [canUpload, setCanUpload] = useState(true); + + const limitCards = useMemo( + () => buildLimitSummaries(eventPackage?.limits ?? null, t), + [eventPackage?.limits, t] + ); const [showPrimer, setShowPrimer] = useState(() => { if (typeof window === 'undefined') return false; @@ -249,38 +271,55 @@ export default function UploadPage() { try { const pkg = await getEventPackage(eventKey); setEventPackage(pkg); - if (pkg && pkg.used_photos >= pkg.package.max_photos) { - setCanUpload(false); - const maxLabel = pkg.package.max_photos == null - ? t('upload.limitUnlimited') - : `${pkg.package.max_photos}`; - setUploadError( - t('upload.limitReached') - .replace('{used}', `${pkg.used_photos}`) - .replace('{max}', maxLabel) - ); - } else { + if (!pkg) { setCanUpload(true); setUploadError(null); + setUploadWarning(null); + return; } - if (pkg?.package?.max_photos) { - const max = Number(pkg.package.max_photos); - const used = Number(pkg.used_photos ?? 0); - const ratio = max > 0 ? used / max : 0; - if (ratio >= 0.8 && ratio < 1) { - const remaining = Math.max(0, max - used); - setUploadWarning( - t('upload.limitWarning') - .replace('{remaining}', `${remaining}`) - .replace('{max}', `${max}`) - ); + const photoLimits = pkg.limits?.photos ?? null; + const galleryLimits = pkg.limits?.gallery ?? null; + + let canUploadCurrent = pkg.limits?.can_upload_photos ?? true; + let errorMessage: string | null = null; + const warnings: string[] = []; + + if (photoLimits?.state === 'limit_reached') { + canUploadCurrent = false; + if (typeof photoLimits.limit === 'number') { + errorMessage = t('upload.limitReached') + .replace('{used}', `${photoLimits.used}`) + .replace('{max}', `${photoLimits.limit}`); } else { - setUploadWarning(null); + errorMessage = t('upload.errors.photoLimit'); } - } else { - setUploadWarning(null); + } else if ( + photoLimits?.state === 'warning' + && typeof photoLimits.remaining === 'number' + && typeof photoLimits.limit === 'number' + ) { + warnings.push( + t('upload.limitWarning') + .replace('{remaining}', `${photoLimits.remaining}`) + .replace('{max}', `${photoLimits.limit}`) + ); } + + if (galleryLimits?.state === 'expired') { + canUploadCurrent = false; + errorMessage = t('upload.errors.galleryExpired'); + } else if (galleryLimits?.state === 'warning') { + const daysLeft = Math.max(0, galleryLimits.days_remaining ?? 0); + const key = daysLeft === 1 ? 'upload.galleryWarningDay' : 'upload.galleryWarningDays'; + warnings.push( + t(key).replace('{days}', `${daysLeft}`) + ); + } + + setCanUpload(canUploadCurrent); + setUploadError(errorMessage); + setUploadWarning(errorMessage ? null : (warnings.length > 0 ? warnings.join(' · ') : null)); } catch (err) { console.error('Failed to check package limits', err); setCanUpload(false); @@ -543,39 +582,20 @@ export default function UploadPage() { const uploadErr = error as UploadError; setUploadWarning(null); const meta = uploadErr.meta as Record | undefined; - switch (uploadErr.code) { - case 'photo_limit_exceeded': { - if (meta && typeof meta.used === 'number' && typeof meta.limit === 'number') { - const limitText = t('upload.limitReached') - .replace('{used}', `${meta.used}`) - .replace('{max}', `${meta.limit}`); - setUploadError(limitText); - } else { - setUploadError(t('upload.errors.photoLimit')); - } - setCanUpload(false); - break; - } - case 'upload_device_limit': { - setUploadError(t('upload.errors.deviceLimit')); - setCanUpload(false); - break; - } - case 'event_package_missing': - case 'event_not_found': { - setUploadError(t('upload.errors.packageMissing')); - setCanUpload(false); - break; - } - case 'gallery_expired': { - setUploadError(t('upload.errors.galleryExpired')); - setCanUpload(false); - break; - } - default: { - setUploadError(getErrorMessage(uploadErr) || t('upload.errors.generic')); - } + const dialog = resolveUploadErrorDialog(uploadErr.code, meta, t); + setErrorDialog(dialog); + setUploadError(dialog.description); + + if ( + uploadErr.code === 'photo_limit_exceeded' + || uploadErr.code === 'upload_device_limit' + || uploadErr.code === 'event_package_missing' + || uploadErr.code === 'event_not_found' + || uploadErr.code === 'gallery_expired' + ) { + setCanUpload(false); } + setMode('review'); } finally { if (uploadProgressTimerRef.current) { @@ -625,6 +645,54 @@ export default function UploadPage() { } }, [resetCountdownTimer]); + const limitStatusSection = limitCards.length > 0 ? ( +
+
+

+ {t('upload.status.title')} +

+

+ {t('upload.status.subtitle')} +

+
+
+ {limitCards.map((card) => { + const styles = LIMIT_CARD_STYLES[card.tone]; + return ( +
+
+
+

+ {card.label} +

+

{card.valueLabel}

+
+ + {card.badgeLabel} + +
+ {card.progress !== null && ( +
+
+
+ )} +

{card.description}

+
+ ); + })} +
+
+ ) : null; + const renderPage = (content: ReactNode, mainClassName = 'px-4 py-6') => (
@@ -633,16 +701,56 @@ export default function UploadPage() {
); + const dialogToneIconClass: Record, string> = { + danger: 'text-rose-500', + warning: 'text-amber-500', + info: 'text-sky-500', + }; + + const errorDialogNode = ( + { if (!open) setErrorDialog(null); }}> + + +
+ {errorDialog?.tone === 'info' ? ( + + ) : ( + + )} + {errorDialog?.title ?? ''} +
+ {errorDialog?.description ?? ''} + {errorDialog?.hint ? ( +

{errorDialog.hint}

+ ) : null} +
+ + + +
+
+ ); + + const renderWithDialog = (content: ReactNode, mainClassName = 'px-4 py-6') => ( + <> + {renderPage(content, mainClassName)} + {errorDialogNode} + + ); + if (!supportsCamera && !task) { - return renderPage( - - {t('upload.cameraUnsupported.message')} - + return renderWithDialog( + <> + {limitStatusSection} + + {t('upload.cameraUnsupported.message')} + + ); } if (loadingTask) { - return renderPage( + return renderWithDialog(

{t('upload.preparing')}

@@ -651,15 +759,18 @@ export default function UploadPage() { } if (!canUpload) { - return renderPage( - - - - {t('upload.limitReached') - .replace('{used}', `${eventPackage?.used_photos || 0}`) - .replace('{max}', `${eventPackage?.package.max_photos || 0}`)} - - + return renderWithDialog( + <> + {limitStatusSection} + + + + {t('upload.limitReached') + .replace('{used}', `${eventPackage?.used_photos || 0}`) + .replace('{max}', `${eventPackage?.package.max_photos || 0}`)} + + + ); } @@ -711,13 +822,14 @@ export default function UploadPage() { ); }; - return renderPage( + return renderWithDialog( <>
{permissionState !== 'granted' && renderPermissionNotice()} + {limitStatusSection}
diff --git a/resources/js/guest/services/eventApi.ts b/resources/js/guest/services/eventApi.ts index ac548f8..de894eb 100644 --- a/resources/js/guest/services/eventApi.ts +++ b/resources/js/guest/services/eventApi.ts @@ -19,13 +19,48 @@ export interface PackageData { id: number; name: string; max_photos: number; + max_guests?: number | null; + gallery_days?: number | null; +} + +export interface LimitUsageSummary { + limit: number | null; + used: number; + remaining: number | null; + percentage: number | null; + state: 'ok' | 'warning' | 'limit_reached' | 'unlimited'; + threshold_reached: number | null; + next_threshold: number | null; + thresholds: number[]; +} + +export interface GallerySummary { + state: 'ok' | 'warning' | 'expired' | 'unlimited'; + expires_at: string | null; + days_remaining: number | null; + warning_thresholds: number[]; + warning_triggered: number | null; + warning_sent_at: string | null; + expired_notified_at: string | null; +} + +export interface EventPackageLimits { + photos: LimitUsageSummary | null; + guests: LimitUsageSummary | null; + gallery: GallerySummary | null; + can_upload_photos: boolean; + can_add_guests: boolean; } export interface EventPackage { id: number; + event_id?: number; + package_id?: number; used_photos: number; - expires_at: string; - package: PackageData; + used_guests?: number; + expires_at: string | null; + package: PackageData | null; + limits: EventPackageLimits | null; } export interface EventStats { @@ -39,6 +74,8 @@ export type FetchEventErrorCode = | 'token_expired' | 'token_revoked' | 'token_rate_limited' + | 'access_rate_limited' + | 'gallery_expired' | 'event_not_public' | 'network_error' | 'server_error' @@ -195,5 +232,9 @@ export async function getEventPackage(eventToken: string): Promise = ({ packages }) => {
{t('home.hero_image_alt')} diff --git a/routes/api.php b/routes/api.php index d4b0e37..01657ec 100644 --- a/routes/api.php +++ b/routes/api.php @@ -33,6 +33,7 @@ Route::prefix('v1')->name('api.v1.')->group(function () { Route::middleware('throttle:100,1')->group(function () { Route::get('/events/{token}', [EventPublicController::class, 'event'])->name('events.show'); Route::get('/events/{token}/stats', [EventPublicController::class, 'stats'])->name('events.stats'); + Route::get('/events/{token}/package', [EventPublicController::class, 'package'])->name('events.package'); Route::get('/events/{token}/achievements', [EventPublicController::class, 'achievements'])->name('events.achievements'); Route::get('/events/{token}/emotions', [EventPublicController::class, 'emotions'])->name('events.emotions'); Route::get('/events/{token}/tasks', [EventPublicController::class, 'tasks'])->name('events.tasks'); @@ -138,6 +139,10 @@ Route::prefix('v1')->name('api.v1.')->group(function () { ->name('tenant.settings.reset'); Route::post('/validate-domain', [SettingsController::class, 'validateDomain']) ->name('tenant.settings.validate-domain'); + Route::get('/notifications', [SettingsController::class, 'notificationPreferences']) + ->name('tenant.settings.notifications.index'); + Route::post('/notifications', [SettingsController::class, 'updateNotificationPreferences']) + ->name('tenant.settings.notifications.update'); }); Route::prefix('credits')->group(function () { diff --git a/tests/Feature/Api/Event/PublicEventErrorResponseTest.php b/tests/Feature/Api/Event/PublicEventErrorResponseTest.php new file mode 100644 index 0000000..f34c153 --- /dev/null +++ b/tests/Feature/Api/Event/PublicEventErrorResponseTest.php @@ -0,0 +1,31 @@ +getJson('/api/v1/events/not-a-token/stats'); + + $response->assertStatus(404); + $response->assertJsonStructure([ + 'error' => ['code', 'title', 'message', 'meta'], + ]); + + $response->assertJson([ + 'error' => [ + 'code' => 'invalid_token', + 'title' => 'Invalid Join Token', + 'message' => 'The provided join token is invalid.', + ], + ]); + + $this->assertSame('not-a-token', $response->json('error.meta.token')); + } +} diff --git a/tests/Feature/Api/EventGuestUploadLimitTest.php b/tests/Feature/Api/EventGuestUploadLimitTest.php index 4781cc8..64799de 100644 --- a/tests/Feature/Api/EventGuestUploadLimitTest.php +++ b/tests/Feature/Api/EventGuestUploadLimitTest.php @@ -131,4 +131,40 @@ class EventGuestUploadLimitTest extends TestCase $this->assertGreaterThanOrEqual(2, $thresholdJobs->count()); Bus::assertDispatched(SendEventPackagePhotoLimitNotification::class); } + + public function test_guest_package_endpoint_returns_limits_summary(): void + { + $tenant = Tenant::factory()->create(); + $event = Event::factory()->for($tenant)->create([ + 'status' => 'published', + ]); + + $package = Package::factory()->endcustomer()->create([ + 'max_photos' => 10, + 'max_guests' => 20, + 'gallery_days' => 7, + ]); + + $eventPackage = EventPackage::create([ + 'event_id' => $event->id, + 'package_id' => $package->id, + 'purchased_price' => $package->price, + 'purchased_at' => now()->subDay(), + 'used_photos' => 8, + 'used_guests' => 5, + 'gallery_expires_at' => now()->addDays(3), + ]); + + $token = app(EventJoinTokenService::class)->createToken($event)->plain_token; + + $response = $this->getJson("/api/v1/events/{$token}/package"); + + $response->assertOk(); + $response->assertJsonPath('id', $eventPackage->id); + $response->assertJsonPath('limits.photos.limit', 10); + $response->assertJsonPath('limits.photos.used', 8); + $response->assertJsonPath('limits.photos.state', 'warning'); + $response->assertJsonPath('limits.gallery.state', 'warning'); + $response->assertJsonPath('limits.can_upload_photos', true); + } } diff --git a/tests/Feature/Api/Tenant/TenantTokenGuardTest.php b/tests/Feature/Api/Tenant/TenantTokenGuardTest.php new file mode 100644 index 0000000..39b1ccc --- /dev/null +++ b/tests/Feature/Api/Tenant/TenantTokenGuardTest.php @@ -0,0 +1,26 @@ +getJson('/api/v1/tenant/events'); + + $response->assertStatus(401); + $response->assertJson([ + 'error' => [ + 'code' => 'token_missing', + 'title' => 'Token Missing', + 'message' => 'Authentication token not provided.', + ], + ]); + + $error = $response->json('error'); + $this->assertIsArray($error); + $this->assertArrayNotHasKey('meta', $error); + } +} diff --git a/tests/Feature/EventControllerTest.php b/tests/Feature/EventControllerTest.php index d45d4fd..0ec01f2 100644 --- a/tests/Feature/EventControllerTest.php +++ b/tests/Feature/EventControllerTest.php @@ -132,6 +132,6 @@ class EventControllerTest extends TestCase ]); $response->assertStatus(402) - ->assertJson(['error' => 'Upload limit reached for this event']); + ->assertJsonPath('error.code', 'photo_limit_exceeded'); } } diff --git a/tests/Unit/Services/PackageLimitEvaluatorTest.php b/tests/Unit/Services/PackageLimitEvaluatorTest.php index ca45036..dc8d616 100644 --- a/tests/Unit/Services/PackageLimitEvaluatorTest.php +++ b/tests/Unit/Services/PackageLimitEvaluatorTest.php @@ -9,6 +9,7 @@ use App\Models\Tenant; use App\Models\TenantPackage; use App\Services\Packages\PackageLimitEvaluator; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Facades\Config; use Tests\TestCase; class PackageLimitEvaluatorTest extends TestCase @@ -125,4 +126,43 @@ class PackageLimitEvaluatorTest extends TestCase $this->assertNull($violation); } + + public function test_summarize_event_package_returns_expected_structure(): void + { + Config::set('package-limits.photo_thresholds', [0.5, 0.9]); + Config::set('package-limits.guest_thresholds', [0.5]); + Config::set('package-limits.gallery_warning_days', [7, 1]); + + $package = Package::factory()->endcustomer()->create([ + 'max_photos' => 10, + 'max_guests' => 20, + 'gallery_days' => 7, + ]); + + $tenant = Tenant::factory()->create(); + + $event = Event::factory()->for($tenant)->create(); + + $eventPackage = EventPackage::create([ + 'event_id' => $event->id, + 'package_id' => $package->id, + 'purchased_price' => $package->price, + 'purchased_at' => now()->subDay(), + 'used_photos' => 5, + 'used_guests' => 10, + 'gallery_expires_at' => now()->addDays(3), + ])->fresh(['package']); + + $summary = $this->evaluator->summarizeEventPackage($eventPackage); + + $this->assertSame(10, $summary['photos']['limit']); + $this->assertSame(5, $summary['photos']['used']); + $this->assertSame('warning', $summary['photos']['state']); + $this->assertSame(20, $summary['guests']['limit']); + $this->assertSame(10, $summary['guests']['used']); + $this->assertSame('warning', $summary['guests']['state']); + $this->assertSame('warning', $summary['gallery']['state']); + $this->assertTrue($summary['can_upload_photos']); + $this->assertTrue($summary['can_add_guests']); + } }