Limit-Status im Upload-Flow anzeigen (Warnbanner + Sperrzustände).
Upload-Fehlercodes auswerten und freundliche Dialoge zeigen.
This commit is contained in:
@@ -87,6 +87,15 @@ class CheckEventPackages extends Command
|
|||||||
|
|
||||||
$daysDiff = $now->diffInDays($expiresAt, false);
|
$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 ($daysDiff < 0) {
|
||||||
if (! $package->gallery_expired_notified_at) {
|
if (! $package->gallery_expired_notified_at) {
|
||||||
event(new EventPackageGalleryExpired($package));
|
event(new EventPackageGalleryExpired($package));
|
||||||
@@ -139,8 +148,15 @@ class CheckEventPackages extends Command
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($tenantPackage->expiry_warning_sent_at) {
|
if ($eventPackageExpiryDays->isNotEmpty() && $tenantPackage->expiry_warning_sent_at) {
|
||||||
continue;
|
$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) {
|
foreach ($eventPackageExpiryDays as $day) {
|
||||||
|
|||||||
@@ -2,10 +2,10 @@
|
|||||||
|
|
||||||
namespace App\Exceptions;
|
namespace App\Exceptions;
|
||||||
|
|
||||||
|
use App\Support\ApiError;
|
||||||
use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;
|
use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;
|
||||||
use Throwable;
|
|
||||||
use Illuminate\Validation\ValidationException;
|
use Illuminate\Validation\ValidationException;
|
||||||
use Inertia\Inertia;
|
use Throwable;
|
||||||
|
|
||||||
class Handler extends ExceptionHandler
|
class Handler extends ExceptionHandler
|
||||||
{
|
{
|
||||||
@@ -32,35 +32,46 @@ class Handler extends ExceptionHandler
|
|||||||
|
|
||||||
public function render($request, Throwable $e)
|
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) {
|
$status = $this->isHttpException($e)
|
||||||
\Illuminate\Support\Facades\Log::error('Route not found (404)', [
|
? $this->toHttpException($e)->getStatusCode()
|
||||||
'url' => $request->url(),
|
: 500;
|
||||||
'method' => $request->method(),
|
|
||||||
'referer' => $request->header('referer'),
|
$code = $status >= 500 ? 'server_error' : 'request_failed';
|
||||||
'user_agent' => $request->header('user-agent')
|
|
||||||
]);
|
return ApiError::response(
|
||||||
|
$code,
|
||||||
|
$status >= 500 ? 'Unexpected error' : 'Request could not be completed',
|
||||||
|
$this->buildGenericMessage($status),
|
||||||
|
$status,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($request->inertia()) {
|
if ($request->inertia()) {
|
||||||
if ($e instanceof ValidationException) {
|
if ($e instanceof ValidationException) {
|
||||||
\Illuminate\Support\Facades\Log::info('ValidationException in Inertia', ['errors' => $e->errors(), 'url' => $request->url()]);
|
return back()->withErrors($e->errors())->withInput($request->all());
|
||||||
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 parent::render($request, $e);
|
return parent::render($request, $e);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
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.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -150,12 +150,16 @@ class EventPublicController extends BaseController
|
|||||||
Response::HTTP_FORBIDDEN
|
Response::HTTP_FORBIDDEN
|
||||||
);
|
);
|
||||||
|
|
||||||
return response()->json([
|
return ApiError::response(
|
||||||
'error' => [
|
'event_not_public',
|
||||||
'code' => 'event_not_public',
|
'Event Not Public',
|
||||||
'message' => 'This event is not publicly accessible.',
|
'This event is not publicly accessible.',
|
||||||
],
|
Response::HTTP_FORBIDDEN,
|
||||||
], Response::HTTP_FORBIDDEN);
|
[
|
||||||
|
'token' => Str::limit($token, 12),
|
||||||
|
'event_id' => $event->id ?? null,
|
||||||
|
]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
RateLimiter::clear($rateLimiterKey);
|
RateLimiter::clear($rateLimiterKey);
|
||||||
@@ -199,12 +203,15 @@ class EventPublicController extends BaseController
|
|||||||
$event = Event::with(['tenant', 'eventPackage.package'])->find($eventRecord->id);
|
$event = Event::with(['tenant', 'eventPackage.package'])->find($eventRecord->id);
|
||||||
|
|
||||||
if (! $event) {
|
if (! $event) {
|
||||||
return response()->json([
|
return ApiError::response(
|
||||||
'error' => [
|
'event_not_found',
|
||||||
'code' => 'event_not_found',
|
'Event Not Found',
|
||||||
'message' => 'The event associated with this gallery could not be located.',
|
'The event associated with this gallery could not be located.',
|
||||||
],
|
Response::HTTP_NOT_FOUND,
|
||||||
], Response::HTTP_NOT_FOUND);
|
[
|
||||||
|
'token' => Str::limit($token, 12),
|
||||||
|
]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
$expiresAt = optional($event->eventPackage)->gallery_expires_at;
|
$expiresAt = optional($event->eventPackage)->gallery_expires_at;
|
||||||
@@ -222,13 +229,16 @@ class EventPublicController extends BaseController
|
|||||||
Response::HTTP_GONE
|
Response::HTTP_GONE
|
||||||
);
|
);
|
||||||
|
|
||||||
return response()->json([
|
return ApiError::response(
|
||||||
'error' => [
|
'gallery_expired',
|
||||||
'code' => 'gallery_expired',
|
'Gallery Expired',
|
||||||
'message' => 'The gallery is no longer available for this event.',
|
'The gallery is no longer available for this event.',
|
||||||
|
Response::HTTP_GONE,
|
||||||
|
[
|
||||||
|
'event_id' => $event->id,
|
||||||
'expired_at' => $expiresAt->toIso8601String(),
|
'expired_at' => $expiresAt->toIso8601String(),
|
||||||
],
|
]
|
||||||
], Response::HTTP_GONE);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->recordTokenEvent(
|
$this->recordTokenEvent(
|
||||||
@@ -271,12 +281,13 @@ class EventPublicController extends BaseController
|
|||||||
Response::HTTP_TOO_MANY_REQUESTS
|
Response::HTTP_TOO_MANY_REQUESTS
|
||||||
);
|
);
|
||||||
|
|
||||||
return response()->json([
|
return ApiError::response(
|
||||||
'error' => [
|
'token_rate_limited',
|
||||||
'code' => 'token_rate_limited',
|
'Too Many Attempts',
|
||||||
'message' => 'Too many invalid join token attempts. Try again later.',
|
'Too many invalid join token attempts. Try again later.',
|
||||||
],
|
Response::HTTP_TOO_MANY_REQUESTS,
|
||||||
], Response::HTTP_TOO_MANY_REQUESTS);
|
array_merge($context, ['rate_limiter_key' => $rateLimiterKey])
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
RateLimiter::hit($rateLimiterKey, $failureDecay * 60);
|
RateLimiter::hit($rateLimiterKey, $failureDecay * 60);
|
||||||
@@ -295,12 +306,13 @@ class EventPublicController extends BaseController
|
|||||||
$status
|
$status
|
||||||
);
|
);
|
||||||
|
|
||||||
return response()->json([
|
return ApiError::response(
|
||||||
'error' => [
|
$code,
|
||||||
'code' => $code,
|
$this->tokenErrorTitle($code),
|
||||||
'message' => $this->tokenErrorMessage($code),
|
$this->tokenErrorMessage($code),
|
||||||
],
|
$status,
|
||||||
], $status);
|
$context
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function tokenErrorMessage(string $code): string
|
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(
|
private function recordTokenEvent(
|
||||||
?EventJoinToken $joinToken,
|
?EventJoinToken $joinToken,
|
||||||
Request $request,
|
Request $request,
|
||||||
@@ -347,12 +370,16 @@ class EventPublicController extends BaseController
|
|||||||
Response::HTTP_TOO_MANY_REQUESTS
|
Response::HTTP_TOO_MANY_REQUESTS
|
||||||
);
|
);
|
||||||
|
|
||||||
return response()->json([
|
return ApiError::response(
|
||||||
'error' => [
|
'access_rate_limited',
|
||||||
'code' => 'access_rate_limited',
|
'Too Many Requests',
|
||||||
'message' => 'Too many requests. Please slow down.',
|
'Too many requests. Please slow down.',
|
||||||
],
|
Response::HTTP_TOO_MANY_REQUESTS,
|
||||||
], Response::HTTP_TOO_MANY_REQUESTS);
|
[
|
||||||
|
'limit' => $limit,
|
||||||
|
'decay_minutes' => $decay,
|
||||||
|
]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
RateLimiter::hit($key, $decay * 60);
|
RateLimiter::hit($key, $decay * 60);
|
||||||
@@ -383,12 +410,16 @@ class EventPublicController extends BaseController
|
|||||||
Response::HTTP_TOO_MANY_REQUESTS
|
Response::HTTP_TOO_MANY_REQUESTS
|
||||||
);
|
);
|
||||||
|
|
||||||
return response()->json([
|
return ApiError::response(
|
||||||
'error' => [
|
'download_rate_limited',
|
||||||
'code' => 'download_rate_limited',
|
'Download Rate Limited',
|
||||||
'message' => 'Download rate limit exceeded. Please wait a moment.',
|
'Download rate limit exceeded. Please wait a moment.',
|
||||||
],
|
Response::HTTP_TOO_MANY_REQUESTS,
|
||||||
], Response::HTTP_TOO_MANY_REQUESTS);
|
[
|
||||||
|
'limit' => $limit,
|
||||||
|
'decay_minutes' => $decay,
|
||||||
|
]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
RateLimiter::hit($key, $decay * 60);
|
RateLimiter::hit($key, $decay * 60);
|
||||||
@@ -664,12 +695,16 @@ class EventPublicController extends BaseController
|
|||||||
->first();
|
->first();
|
||||||
|
|
||||||
if (! $record) {
|
if (! $record) {
|
||||||
return response()->json([
|
return ApiError::response(
|
||||||
'error' => [
|
'photo_not_found',
|
||||||
'code' => 'photo_not_found',
|
'Photo Not Found',
|
||||||
'message' => 'The requested photo is no longer available.',
|
'The requested photo is no longer available.',
|
||||||
],
|
Response::HTTP_NOT_FOUND,
|
||||||
], Response::HTTP_NOT_FOUND);
|
[
|
||||||
|
'photo_id' => $photo,
|
||||||
|
'event_id' => $event->id,
|
||||||
|
]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
$variantPreference = $variant === 'thumbnail'
|
$variantPreference = $variant === 'thumbnail'
|
||||||
@@ -697,12 +732,16 @@ class EventPublicController extends BaseController
|
|||||||
->first();
|
->first();
|
||||||
|
|
||||||
if (! $record) {
|
if (! $record) {
|
||||||
return response()->json([
|
return ApiError::response(
|
||||||
'error' => [
|
'photo_not_found',
|
||||||
'code' => 'photo_not_found',
|
'Photo Not Found',
|
||||||
'message' => 'The requested photo is no longer available.',
|
'The requested photo is no longer available.',
|
||||||
],
|
Response::HTTP_NOT_FOUND,
|
||||||
], Response::HTTP_NOT_FOUND);
|
[
|
||||||
|
'photo_id' => $photo,
|
||||||
|
'event_id' => $event->id,
|
||||||
|
]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->streamGalleryPhoto($event, $record, ['original'], 'attachment');
|
return $this->streamGalleryPhoto($event, $record, ['original'], 'attachment');
|
||||||
@@ -763,6 +802,69 @@ class EventPublicController extends BaseController
|
|||||||
])->header('Cache-Control', 'no-store');
|
])->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)
|
private function streamGalleryPhoto(Event $event, Photo $record, array $variantPreference, string $disposition)
|
||||||
{
|
{
|
||||||
foreach ($variantPreference as $variant) {
|
foreach ($variantPreference as $variant) {
|
||||||
@@ -852,12 +954,16 @@ class EventPublicController extends BaseController
|
|||||||
return redirect()->away($fallbackUrl);
|
return redirect()->away($fallbackUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
return response()->json([
|
return ApiError::response(
|
||||||
'error' => [
|
'photo_unavailable',
|
||||||
'code' => 'photo_unavailable',
|
'Photo Unavailable',
|
||||||
'message' => 'The requested photo could not be loaded.',
|
'The requested photo could not be loaded.',
|
||||||
],
|
Response::HTTP_NOT_FOUND,
|
||||||
], Response::HTTP_NOT_FOUND);
|
[
|
||||||
|
'photo_id' => $record->id,
|
||||||
|
'event_id' => $event->id,
|
||||||
|
]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function resolvePhotoVariant(Photo $record, string $variant): array
|
private function resolvePhotoVariant(Photo $record, string $variant): array
|
||||||
@@ -1191,7 +1297,13 @@ class EventPublicController extends BaseController
|
|||||||
->where('events.status', 'published')
|
->where('events.status', 'published')
|
||||||
->first();
|
->first();
|
||||||
if (! $row) {
|
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->file_path = $this->toPublicUrl((string) ($row->file_path ?? ''));
|
||||||
$row->thumbnail_path = $this->toPublicUrl((string) ($row->thumbnail_path ?? ''));
|
$row->thumbnail_path = $this->toPublicUrl((string) ($row->thumbnail_path ?? ''));
|
||||||
@@ -1219,7 +1331,13 @@ class EventPublicController extends BaseController
|
|||||||
->where('events.status', 'published')
|
->where('events.status', 'published')
|
||||||
->first(['photos.id', 'photos.event_id']);
|
->first(['photos.id', 'photos.event_id']);
|
||||||
if (! $photo) {
|
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
|
// Idempotent like per device
|
||||||
|
|||||||
@@ -3,8 +3,10 @@
|
|||||||
namespace App\Http\Controllers\Api;
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
use App\Models\LegalPage;
|
use App\Models\LegalPage;
|
||||||
|
use App\Support\ApiError;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Routing\Controller as BaseController;
|
use Illuminate\Routing\Controller as BaseController;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
class LegalController extends BaseController
|
class LegalController extends BaseController
|
||||||
{
|
{
|
||||||
@@ -27,7 +29,13 @@ class LegalController extends BaseController
|
|||||||
->orderByDesc('version')
|
->orderByDesc('version')
|
||||||
->first();
|
->first();
|
||||||
if (! $page) {
|
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;
|
$title = $page->title[$locale] ?? $page->title[$page->locale_fallback] ?? $page->title['de'] ?? $page->title['en'] ?? $page->slug;
|
||||||
|
|||||||
@@ -16,9 +16,7 @@ use Stripe\Webhook;
|
|||||||
|
|
||||||
class StripeWebhookController extends Controller
|
class StripeWebhookController extends Controller
|
||||||
{
|
{
|
||||||
public function __construct(private CheckoutWebhookService $checkoutWebhooks)
|
public function __construct(private CheckoutWebhookService $checkoutWebhooks) {}
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
public function handleWebhook(Request $request)
|
public function handleWebhook(Request $request)
|
||||||
{
|
{
|
||||||
@@ -33,9 +31,19 @@ class StripeWebhookController extends Controller
|
|||||||
$endpointSecret
|
$endpointSecret
|
||||||
);
|
);
|
||||||
} catch (SignatureVerificationException $e) {
|
} 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) {
|
} 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;
|
$eventArray = method_exists($event, 'toArray') ? $event->toArray() : (array) $event;
|
||||||
@@ -78,6 +86,7 @@ class StripeWebhookController extends Controller
|
|||||||
|
|
||||||
if (! $packageId || ! $type) {
|
if (! $packageId || ! $type) {
|
||||||
Log::warning('Stripe intent missing metadata payload', ['metadata' => $metadata]);
|
Log::warning('Stripe intent missing metadata payload', ['metadata' => $metadata]);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -178,4 +187,3 @@ class StripeWebhookController extends Controller
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ use App\Models\Package;
|
|||||||
use App\Models\Photo;
|
use App\Models\Photo;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Services\EventJoinTokenService;
|
use App\Services\EventJoinTokenService;
|
||||||
|
use App\Support\ApiError;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||||
@@ -204,7 +205,13 @@ class EventController extends Controller
|
|||||||
$tenantId = $request->attributes->get('tenant_id');
|
$tenantId = $request->attributes->get('tenant_id');
|
||||||
|
|
||||||
if ($event->tenant_id !== $tenantId) {
|
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([
|
$event->load([
|
||||||
@@ -228,7 +235,13 @@ class EventController extends Controller
|
|||||||
$tenantId = $request->attributes->get('tenant_id');
|
$tenantId = $request->attributes->get('tenant_id');
|
||||||
|
|
||||||
if ($event->tenant_id !== $tenantId) {
|
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();
|
$validated = $request->validated();
|
||||||
@@ -264,7 +277,13 @@ class EventController extends Controller
|
|||||||
$tenantId = $request->attributes->get('tenant_id');
|
$tenantId = $request->attributes->get('tenant_id');
|
||||||
|
|
||||||
if ($event->tenant_id !== $tenantId) {
|
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();
|
$event->delete();
|
||||||
@@ -279,7 +298,13 @@ class EventController extends Controller
|
|||||||
$tenantId = $request->attributes->get('tenant_id');
|
$tenantId = $request->attributes->get('tenant_id');
|
||||||
|
|
||||||
if ($event->tenant_id !== $tenantId) {
|
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();
|
$totalPhotos = Photo::where('event_id', $event->id)->count();
|
||||||
@@ -304,7 +329,13 @@ class EventController extends Controller
|
|||||||
$tenantId = $request->attributes->get('tenant_id');
|
$tenantId = $request->attributes->get('tenant_id');
|
||||||
|
|
||||||
if ($event->tenant_id !== $tenantId) {
|
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']);
|
$event->load(['eventType', 'eventPackage.package']);
|
||||||
@@ -439,7 +470,13 @@ class EventController extends Controller
|
|||||||
$tenantId = $request->attributes->get('tenant_id');
|
$tenantId = $request->attributes->get('tenant_id');
|
||||||
|
|
||||||
if ($event->tenant_id !== $tenantId) {
|
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;
|
$activate = ! (bool) $event->is_active;
|
||||||
@@ -466,7 +503,13 @@ class EventController extends Controller
|
|||||||
$tenantId = $request->attributes->get('tenant_id');
|
$tenantId = $request->attributes->get('tenant_id');
|
||||||
|
|
||||||
if ($event->tenant_id !== $tenantId) {
|
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([
|
$validated = $request->validate([
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ use Illuminate\Support\Facades\Log;
|
|||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
use Illuminate\Validation\ValidationException;
|
use Illuminate\Validation\ValidationException;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
class PhotoController extends Controller
|
class PhotoController extends Controller
|
||||||
{
|
{
|
||||||
@@ -46,27 +47,16 @@ class PhotoController extends Controller
|
|||||||
->where('tenant_id', $tenantId)
|
->where('tenant_id', $tenantId)
|
||||||
->firstOrFail();
|
->firstOrFail();
|
||||||
|
|
||||||
|
$event->loadMissing(['tenant', 'eventPackage.package', 'eventPackages.package']);
|
||||||
$tenant = $event->tenant;
|
$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
|
$eventPackage = $tenant
|
||||||
? $this->packageLimitEvaluator->resolveEventPackageForPhotoUpload($tenant, $event->id, $event)
|
? $this->packageLimitEvaluator->resolveEventPackageForPhotoUpload($tenant, $event->id, $event)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
$previousUsedPhotos = $eventPackage?->used_photos ?? 0;
|
$limitSummary = $eventPackage
|
||||||
|
? $this->packageLimitEvaluator->summarizeEventPackage($eventPackage)
|
||||||
|
: null;
|
||||||
|
|
||||||
$query = Photo::where('event_id', $event->id)
|
$query = Photo::where('event_id', $event->id)
|
||||||
->with('event')->withCount('likes')
|
->with('event')->withCount('likes')
|
||||||
@@ -84,7 +74,9 @@ class PhotoController extends Controller
|
|||||||
$perPage = $request->get('per_page', 20);
|
$perPage = $request->get('per_page', 20);
|
||||||
$photos = $query->paginate($perPage);
|
$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)
|
->where('tenant_id', $tenantId)
|
||||||
->firstOrFail();
|
->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();
|
$validated = $request->validated();
|
||||||
$file = $request->file('photo');
|
$file = $request->file('photo');
|
||||||
|
|
||||||
@@ -197,12 +212,17 @@ class PhotoController extends Controller
|
|||||||
$this->packageUsageTracker->recordPhotoUsage($eventPackage, $previousUsedPhotos, 1);
|
$this->packageUsageTracker->recordPhotoUsage($eventPackage, $previousUsedPhotos, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$limitSummary = $eventPackage
|
||||||
|
? $this->packageLimitEvaluator->summarizeEventPackage($eventPackage)
|
||||||
|
: null;
|
||||||
|
|
||||||
$photo->load('event')->loadCount('likes');
|
$photo->load('event')->loadCount('likes');
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'message' => 'Photo uploaded successfully. Awaiting moderation.',
|
'message' => 'Photo uploaded successfully. Awaiting moderation.',
|
||||||
'data' => new PhotoResource($photo),
|
'data' => new PhotoResource($photo),
|
||||||
'moderation_notice' => 'Your photo has been uploaded and will be reviewed shortly.',
|
'moderation_notice' => 'Your photo has been uploaded and will be reviewed shortly.',
|
||||||
|
'limits' => $limitSummary,
|
||||||
], 201);
|
], 201);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -217,7 +237,13 @@ class PhotoController extends Controller
|
|||||||
->firstOrFail();
|
->firstOrFail();
|
||||||
|
|
||||||
if ($photo->event_id !== $event->id) {
|
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');
|
$photo->load('event')->loadCount('likes');
|
||||||
@@ -239,7 +265,13 @@ class PhotoController extends Controller
|
|||||||
->firstOrFail();
|
->firstOrFail();
|
||||||
|
|
||||||
if ($photo->event_id !== $event->id) {
|
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([
|
$validated = $request->validate([
|
||||||
@@ -251,7 +283,13 @@ class PhotoController extends Controller
|
|||||||
|
|
||||||
// Only tenant admins can moderate
|
// Only tenant admins can moderate
|
||||||
if (isset($validated['status']) && ! $this->tokenHasScope($request, 'tenant:write')) {
|
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);
|
$photo->update($validated);
|
||||||
@@ -279,7 +317,13 @@ class PhotoController extends Controller
|
|||||||
->firstOrFail();
|
->firstOrFail();
|
||||||
|
|
||||||
if ($photo->event_id !== $event->id) {
|
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();
|
$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]);
|
Storage::disk($fallbackDisk)->delete([$photo->path, $photo->thumbnail_path]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$eventPackage = $event->eventPackage;
|
||||||
|
$usageTracker = app(\App\Services\Packages\PackageUsageTracker::class);
|
||||||
|
|
||||||
// Delete record and likes
|
// Delete record and likes
|
||||||
DB::transaction(function () use ($photo, $assets) {
|
DB::transaction(function () use ($photo, $assets) {
|
||||||
$photo->likes()->delete();
|
$photo->likes()->delete();
|
||||||
@@ -312,6 +359,15 @@ class PhotoController extends Controller
|
|||||||
$photo->delete();
|
$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([
|
return response()->json([
|
||||||
'message' => 'Photo deleted successfully',
|
'message' => 'Photo deleted successfully',
|
||||||
]);
|
]);
|
||||||
@@ -328,7 +384,13 @@ class PhotoController extends Controller
|
|||||||
->firstOrFail();
|
->firstOrFail();
|
||||||
|
|
||||||
if ($photo->event_id !== $event->id) {
|
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]);
|
$photo->update(['is_featured' => true]);
|
||||||
@@ -345,7 +407,13 @@ class PhotoController extends Controller
|
|||||||
->firstOrFail();
|
->firstOrFail();
|
||||||
|
|
||||||
if ($photo->event_id !== $event->id) {
|
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]);
|
$photo->update(['is_featured' => false]);
|
||||||
@@ -569,7 +637,13 @@ class PhotoController extends Controller
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
if ($request->event_id !== $event->id) {
|
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');
|
$event->load('storageAssignments.storageTarget');
|
||||||
|
|||||||
@@ -3,10 +3,14 @@
|
|||||||
namespace App\Http\Controllers\Api\Tenant;
|
namespace App\Http\Controllers\Api\Tenant;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Requests\Tenant\NotificationPreferencesRequest;
|
||||||
use App\Http\Requests\Tenant\SettingsStoreRequest;
|
use App\Http\Requests\Tenant\SettingsStoreRequest;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
|
use App\Services\Packages\TenantNotificationPreferences;
|
||||||
|
use App\Support\ApiError;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
class SettingsController extends Controller
|
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.
|
* Update the tenant's settings.
|
||||||
*/
|
*/
|
||||||
@@ -98,7 +158,12 @@ class SettingsController extends Controller
|
|||||||
$domain = $request->input('domain');
|
$domain = $request->input('domain');
|
||||||
|
|
||||||
if (! $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)) {
|
if (! $this->isValidDomain($domain)) {
|
||||||
|
|||||||
@@ -6,20 +6,19 @@ use App\Http\Controllers\Controller;
|
|||||||
use App\Http\Requests\Tenant\TaskStoreRequest;
|
use App\Http\Requests\Tenant\TaskStoreRequest;
|
||||||
use App\Http\Requests\Tenant\TaskUpdateRequest;
|
use App\Http\Requests\Tenant\TaskUpdateRequest;
|
||||||
use App\Http\Resources\Tenant\TaskResource;
|
use App\Http\Resources\Tenant\TaskResource;
|
||||||
|
use App\Models\Event;
|
||||||
use App\Models\Task;
|
use App\Models\Task;
|
||||||
use App\Models\TaskCollection;
|
use App\Models\TaskCollection;
|
||||||
use App\Models\Event;
|
use App\Support\ApiError;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
class TaskController extends Controller
|
class TaskController extends Controller
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* Display a listing of the tenant's tasks.
|
* Display a listing of the tenant's tasks.
|
||||||
*
|
|
||||||
* @param Request $request
|
|
||||||
* @return AnonymousResourceCollection
|
|
||||||
*/
|
*/
|
||||||
public function index(Request $request): AnonymousResourceCollection
|
public function index(Request $request): AnonymousResourceCollection
|
||||||
{
|
{
|
||||||
@@ -38,7 +37,7 @@ class TaskController extends Controller
|
|||||||
// Search and filters
|
// Search and filters
|
||||||
if ($search = $request->get('search')) {
|
if ($search = $request->get('search')) {
|
||||||
$query->where(function ($inner) use ($search) {
|
$query->where(function ($inner) use ($search) {
|
||||||
$like = '%' . $search . '%';
|
$like = '%'.$search.'%';
|
||||||
$inner->where('title->de', 'like', $like)
|
$inner->where('title->de', 'like', $like)
|
||||||
->orWhere('title->en', 'like', $like)
|
->orWhere('title->en', 'like', $like)
|
||||||
->orWhere('description->de', 'like', $like)
|
->orWhere('description->de', 'like', $like)
|
||||||
@@ -47,11 +46,11 @@ class TaskController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ($collectionId = $request->get('collection_id')) {
|
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')) {
|
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);
|
$perPage = $request->get('per_page', 15);
|
||||||
@@ -62,9 +61,6 @@ class TaskController extends Controller
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Store a newly created task in storage.
|
* Store a newly created task in storage.
|
||||||
*
|
|
||||||
* @param TaskStoreRequest $request
|
|
||||||
* @return JsonResponse
|
|
||||||
*/
|
*/
|
||||||
public function store(TaskStoreRequest $request): JsonResponse
|
public function store(TaskStoreRequest $request): JsonResponse
|
||||||
{
|
{
|
||||||
@@ -91,10 +87,6 @@ class TaskController extends Controller
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Display the specified task.
|
* Display the specified task.
|
||||||
*
|
|
||||||
* @param Request $request
|
|
||||||
* @param Task $task
|
|
||||||
* @return JsonResponse
|
|
||||||
*/
|
*/
|
||||||
public function show(Request $request, Task $task): JsonResponse
|
public function show(Request $request, Task $task): JsonResponse
|
||||||
{
|
{
|
||||||
@@ -109,10 +101,6 @@ class TaskController extends Controller
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Update the specified task in storage.
|
* Update the specified task in storage.
|
||||||
*
|
|
||||||
* @param TaskUpdateRequest $request
|
|
||||||
* @param Task $task
|
|
||||||
* @return JsonResponse
|
|
||||||
*/
|
*/
|
||||||
public function update(TaskUpdateRequest $request, Task $task): JsonResponse
|
public function update(TaskUpdateRequest $request, Task $task): JsonResponse
|
||||||
{
|
{
|
||||||
@@ -142,10 +130,6 @@ class TaskController extends Controller
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Remove the specified task from storage.
|
* Remove the specified task from storage.
|
||||||
*
|
|
||||||
* @param Request $request
|
|
||||||
* @param Task $task
|
|
||||||
* @return JsonResponse
|
|
||||||
*/
|
*/
|
||||||
public function destroy(Request $request, Task $task): JsonResponse
|
public function destroy(Request $request, Task $task): JsonResponse
|
||||||
{
|
{
|
||||||
@@ -162,11 +146,6 @@ class TaskController extends Controller
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Assign task to an event.
|
* 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
|
public function assignToEvent(Request $request, Task $task, Event $event): JsonResponse
|
||||||
{
|
{
|
||||||
@@ -187,10 +166,6 @@ class TaskController extends Controller
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Bulk assign tasks to an event.
|
* Bulk assign tasks to an event.
|
||||||
*
|
|
||||||
* @param Request $request
|
|
||||||
* @param Event $event
|
|
||||||
* @return JsonResponse
|
|
||||||
*/
|
*/
|
||||||
public function bulkAssignToEvent(Request $request, Event $event): JsonResponse
|
public function bulkAssignToEvent(Request $request, Event $event): JsonResponse
|
||||||
{
|
{
|
||||||
@@ -200,7 +175,12 @@ class TaskController extends Controller
|
|||||||
|
|
||||||
$taskIds = $request->input('task_ids', []);
|
$taskIds = $request->input('task_ids', []);
|
||||||
if (empty($taskIds)) {
|
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)
|
$tasks = Task::whereIn('id', $taskIds)
|
||||||
@@ -209,7 +189,7 @@ class TaskController extends Controller
|
|||||||
|
|
||||||
$attached = 0;
|
$attached = 0;
|
||||||
foreach ($tasks as $task) {
|
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);
|
$task->assignedEvents()->attach($event->id);
|
||||||
$attached++;
|
$attached++;
|
||||||
}
|
}
|
||||||
@@ -222,10 +202,6 @@ class TaskController extends Controller
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Get tasks for a specific event.
|
* Get tasks for a specific event.
|
||||||
*
|
|
||||||
* @param Request $request
|
|
||||||
* @param Event $event
|
|
||||||
* @return AnonymousResourceCollection
|
|
||||||
*/
|
*/
|
||||||
public function forEvent(Request $request, Event $event): AnonymousResourceCollection
|
public function forEvent(Request $request, Event $event): AnonymousResourceCollection
|
||||||
{
|
{
|
||||||
@@ -233,7 +209,7 @@ class TaskController extends Controller
|
|||||||
abort(404);
|
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'])
|
->with(['taskCollection'])
|
||||||
->orderBy('created_at', 'desc')
|
->orderBy('created_at', 'desc')
|
||||||
->paginate($request->get('per_page', 15));
|
->paginate($request->get('per_page', 15));
|
||||||
@@ -243,10 +219,6 @@ class TaskController extends Controller
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Get tasks from a specific collection.
|
* Get tasks from a specific collection.
|
||||||
*
|
|
||||||
* @param Request $request
|
|
||||||
* @param TaskCollection $collection
|
|
||||||
* @return AnonymousResourceCollection
|
|
||||||
*/
|
*/
|
||||||
public function fromCollection(Request $request, TaskCollection $collection): AnonymousResourceCollection
|
public function fromCollection(Request $request, TaskCollection $collection): AnonymousResourceCollection
|
||||||
{
|
{
|
||||||
@@ -321,9 +293,7 @@ class TaskController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param mixed $value
|
* @param array<string, string>|null $fallback
|
||||||
* @param array<string, string>|null $fallback
|
|
||||||
*
|
|
||||||
* @return array<string, string>|null
|
* @return array<string, string>|null
|
||||||
*/
|
*/
|
||||||
protected function normalizeTranslations(mixed $value, ?array $fallback = null, bool $allowNull = false): ?array
|
protected function normalizeTranslations(mixed $value, ?array $fallback = null, bool $allowNull = false): ?array
|
||||||
|
|||||||
@@ -6,32 +6,40 @@ use App\Models\Event;
|
|||||||
use App\Models\Photo;
|
use App\Models\Photo;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\EventJoinTokenService;
|
use App\Services\EventJoinTokenService;
|
||||||
|
use App\Support\ApiError;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Routing\Controller as BaseController;
|
use Illuminate\Routing\Controller as BaseController;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
use Illuminate\Support\Facades\Cache;
|
use Illuminate\Support\Facades\Cache;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
class TenantController extends BaseController
|
class TenantController extends BaseController
|
||||||
{
|
{
|
||||||
public function __construct(private readonly EventJoinTokenService $joinTokenService)
|
public function __construct(private readonly EventJoinTokenService $joinTokenService) {}
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
public function login(Request $request)
|
public function login(Request $request)
|
||||||
{
|
{
|
||||||
$creds = $request->validate([
|
$creds = $request->validate([
|
||||||
'email' => ['required','email'],
|
'email' => ['required', 'email'],
|
||||||
'password' => ['required','string'],
|
'password' => ['required', 'string'],
|
||||||
]);
|
]);
|
||||||
if (! Auth::attempt($creds)) {
|
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 */
|
/** @var User $user */
|
||||||
$user = Auth::user();
|
$user = Auth::user();
|
||||||
// naive token (cache-based), expires in 8 hours
|
// naive token (cache-based), expires in 8 hours
|
||||||
$token = Str::random(80);
|
$token = Str::random(80);
|
||||||
Cache::put('api_token:'.$token, $user->id, now()->addHours(8));
|
Cache::put('api_token:'.$token, $user->id, now()->addHours(8));
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'token' => $token,
|
'token' => $token,
|
||||||
'user' => [
|
'user' => [
|
||||||
@@ -46,6 +54,7 @@ class TenantController extends BaseController
|
|||||||
public function me(Request $request)
|
public function me(Request $request)
|
||||||
{
|
{
|
||||||
$u = Auth::user();
|
$u = Auth::user();
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'id' => $u->id,
|
'id' => $u->id,
|
||||||
'name' => $u->name,
|
'name' => $u->name,
|
||||||
@@ -62,7 +71,8 @@ class TenantController extends BaseController
|
|||||||
if ($tenantId) {
|
if ($tenantId) {
|
||||||
$q->where('tenant_id', $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)
|
public function showEvent(int $id)
|
||||||
@@ -71,9 +81,10 @@ class TenantController extends BaseController
|
|||||||
$tenantId = $u->tenant_id ?? null;
|
$tenantId = $u->tenant_id ?? null;
|
||||||
$ev = Event::findOrFail($id);
|
$ev = Event::findOrFail($id);
|
||||||
if ($tenantId && $ev->tenant_id !== $tenantId) {
|
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)
|
public function storeEvent(Request $request)
|
||||||
@@ -81,19 +92,20 @@ class TenantController extends BaseController
|
|||||||
$u = Auth::user();
|
$u = Auth::user();
|
||||||
$tenantId = $u->tenant_id ?? null;
|
$tenantId = $u->tenant_id ?? null;
|
||||||
$data = $request->validate([
|
$data = $request->validate([
|
||||||
'name' => ['required','string','max:255'],
|
'name' => ['required', 'string', 'max:255'],
|
||||||
'slug' => ['required','string','max:255'],
|
'slug' => ['required', 'string', 'max:255'],
|
||||||
'date' => ['nullable','date'],
|
'date' => ['nullable', 'date'],
|
||||||
'is_active' => ['boolean'],
|
'is_active' => ['boolean'],
|
||||||
]);
|
]);
|
||||||
$ev = new Event();
|
$ev = new Event;
|
||||||
$ev->tenant_id = $tenantId ?? $ev->tenant_id;
|
$ev->tenant_id = $tenantId ?? $ev->tenant_id;
|
||||||
$ev->name = ['de' => $data['name'], 'en' => $data['name']];
|
$ev->name = ['de' => $data['name'], 'en' => $data['name']];
|
||||||
$ev->slug = $data['slug'];
|
$ev->slug = $data['slug'];
|
||||||
$ev->date = $data['date'] ?? null;
|
$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->default_locale = 'de';
|
||||||
$ev->save();
|
$ev->save();
|
||||||
|
|
||||||
return response()->json(['id' => $ev->id]);
|
return response()->json(['id' => $ev->id]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,19 +115,28 @@ class TenantController extends BaseController
|
|||||||
$tenantId = $u->tenant_id ?? null;
|
$tenantId = $u->tenant_id ?? null;
|
||||||
$ev = Event::findOrFail($id);
|
$ev = Event::findOrFail($id);
|
||||||
if ($tenantId && $ev->tenant_id !== $tenantId) {
|
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([
|
$data = $request->validate([
|
||||||
'name' => ['nullable','string','max:255'],
|
'name' => ['nullable', 'string', 'max:255'],
|
||||||
'slug' => ['nullable','string','max:255'],
|
'slug' => ['nullable', 'string', 'max:255'],
|
||||||
'date' => ['nullable','date'],
|
'date' => ['nullable', 'date'],
|
||||||
'is_active' => ['nullable','boolean'],
|
'is_active' => ['nullable', 'boolean'],
|
||||||
]);
|
]);
|
||||||
if (isset($data['name'])) $ev->name = ['de' => $data['name'], 'en' => $data['name']];
|
if (isset($data['name'])) {
|
||||||
if (isset($data['slug'])) $ev->slug = $data['slug'];
|
$ev->name = ['de' => $data['name'], 'en' => $data['name']];
|
||||||
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['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();
|
$ev->save();
|
||||||
|
|
||||||
return response()->json(['ok' => true]);
|
return response()->json(['ok' => true]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,11 +146,12 @@ class TenantController extends BaseController
|
|||||||
$tenantId = $u->tenant_id ?? null;
|
$tenantId = $u->tenant_id ?? null;
|
||||||
$ev = Event::findOrFail($id);
|
$ev = Event::findOrFail($id);
|
||||||
if ($tenantId && $ev->tenant_id !== $tenantId) {
|
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->is_active = ! (bool) $ev->is_active;
|
||||||
$ev->save();
|
$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)
|
public function eventStats(int $id)
|
||||||
@@ -138,15 +160,16 @@ class TenantController extends BaseController
|
|||||||
$tenantId = $u->tenant_id ?? null;
|
$tenantId = $u->tenant_id ?? null;
|
||||||
$ev = Event::findOrFail($id);
|
$ev = Event::findOrFail($id);
|
||||||
if ($tenantId && $ev->tenant_id !== $tenantId) {
|
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();
|
$total = Photo::where('event_id', $id)->count();
|
||||||
$featured = Photo::where('event_id', $id)->where('is_featured', 1)->count();
|
$featured = Photo::where('event_id', $id)->where('is_featured', 1)->count();
|
||||||
$likes = Photo::where('event_id', $id)->sum('likes_count');
|
$likes = Photo::where('event_id', $id)->sum('likes_count');
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'total' => (int)$total,
|
'total' => (int) $total,
|
||||||
'featured' => (int)$featured,
|
'featured' => (int) $featured,
|
||||||
'likes' => (int)$likes,
|
'likes' => (int) $likes,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,7 +179,7 @@ class TenantController extends BaseController
|
|||||||
$tenantId = $u->tenant_id ?? null;
|
$tenantId = $u->tenant_id ?? null;
|
||||||
$ev = Event::findOrFail($id);
|
$ev = Event::findOrFail($id);
|
||||||
if ($tenantId && $ev->tenant_id !== $tenantId) {
|
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, [
|
$joinToken = $this->joinTokenService->createToken($ev, [
|
||||||
@@ -176,9 +199,10 @@ class TenantController extends BaseController
|
|||||||
$tenantId = $u->tenant_id ?? null;
|
$tenantId = $u->tenant_id ?? null;
|
||||||
$ev = Event::findOrFail($id);
|
$ev = Event::findOrFail($id);
|
||||||
if ($tenantId && $ev->tenant_id !== $tenantId) {
|
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]);
|
return response()->json(['data' => $rows]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -186,7 +210,9 @@ class TenantController extends BaseController
|
|||||||
{
|
{
|
||||||
$p = Photo::findOrFail($photoId);
|
$p = Photo::findOrFail($photoId);
|
||||||
$this->authorizePhoto($p);
|
$this->authorizePhoto($p);
|
||||||
$p->is_featured = 1; $p->save();
|
$p->is_featured = 1;
|
||||||
|
$p->save();
|
||||||
|
|
||||||
return response()->json(['ok' => true]);
|
return response()->json(['ok' => true]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -194,7 +220,9 @@ class TenantController extends BaseController
|
|||||||
{
|
{
|
||||||
$p = Photo::findOrFail($photoId);
|
$p = Photo::findOrFail($photoId);
|
||||||
$this->authorizePhoto($p);
|
$this->authorizePhoto($p);
|
||||||
$p->is_featured = 0; $p->save();
|
$p->is_featured = 0;
|
||||||
|
$p->save();
|
||||||
|
|
||||||
return response()->json(['ok' => true]);
|
return response()->json(['ok' => true]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -203,9 +231,21 @@ class TenantController extends BaseController
|
|||||||
$p = Photo::findOrFail($photoId);
|
$p = Photo::findOrFail($photoId);
|
||||||
$this->authorizePhoto($p);
|
$this->authorizePhoto($p);
|
||||||
$p->delete();
|
$p->delete();
|
||||||
|
|
||||||
return response()->json(['ok' => true]);
|
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
|
protected function authorizePhoto(Photo $p): void
|
||||||
{
|
{
|
||||||
$u = Auth::user();
|
$u = Auth::user();
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ namespace App\Http\Controllers\Api;
|
|||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Models\TenantPackage;
|
use App\Models\TenantPackage;
|
||||||
use Illuminate\Http\Request;
|
use App\Support\ApiError;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
class TenantPackageController extends Controller
|
class TenantPackageController extends Controller
|
||||||
{
|
{
|
||||||
@@ -13,8 +15,13 @@ class TenantPackageController extends Controller
|
|||||||
{
|
{
|
||||||
$tenant = $request->attributes->get('tenant');
|
$tenant = $request->attributes->get('tenant');
|
||||||
|
|
||||||
if (!$tenant) {
|
if (! $tenant) {
|
||||||
return response()->json(['error' => 'Tenant not found.'], 404);
|
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)
|
$packages = TenantPackage::where('tenant_id', $tenant->id)
|
||||||
@@ -33,4 +40,4 @@ class TenantPackageController extends Controller
|
|||||||
'message' => 'Tenant packages loaded successfully.',
|
'message' => 'Tenant packages loaded successfully.',
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ use App\Models\OAuthCode;
|
|||||||
use App\Models\RefreshToken;
|
use App\Models\RefreshToken;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\TenantToken;
|
use App\Models\TenantToken;
|
||||||
|
use App\Support\ApiError;
|
||||||
use Firebase\JWT\JWT;
|
use Firebase\JWT\JWT;
|
||||||
use GuzzleHttp\Client;
|
use GuzzleHttp\Client;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
@@ -17,6 +18,7 @@ use Illuminate\Support\Facades\Hash;
|
|||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
use Illuminate\Support\Facades\Validator;
|
use Illuminate\Support\Facades\Validator;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
class OAuthController extends Controller
|
class OAuthController extends Controller
|
||||||
{
|
{
|
||||||
@@ -690,7 +692,12 @@ class OAuthController extends Controller
|
|||||||
{
|
{
|
||||||
$tenant = $request->user()->tenant ?? null;
|
$tenant = $request->user()->tenant ?? null;
|
||||||
if (! $tenant) {
|
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);
|
$state = Str::random(40);
|
||||||
|
|||||||
@@ -3,9 +3,11 @@
|
|||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
use App\Jobs\ProcessRevenueCatWebhook;
|
use App\Jobs\ProcessRevenueCatWebhook;
|
||||||
|
use App\Support\ApiError;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
class RevenueCatWebhookController extends Controller
|
class RevenueCatWebhookController extends Controller
|
||||||
{
|
{
|
||||||
@@ -15,17 +17,33 @@ class RevenueCatWebhookController extends Controller
|
|||||||
|
|
||||||
if ($secret === '') {
|
if ($secret === '') {
|
||||||
Log::error('RevenueCat webhook secret not configured');
|
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', ''));
|
$signature = trim((string) $request->header('X-Signature', ''));
|
||||||
if ($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();
|
$payload = $request->getContent();
|
||||||
if (! $this->signatureMatches($payload, $signature, $secret)) {
|
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);
|
$decoded = json_decode($payload, true);
|
||||||
@@ -33,7 +51,14 @@ class RevenueCatWebhookController extends Controller
|
|||||||
Log::warning('RevenueCat webhook received invalid JSON', [
|
Log::warning('RevenueCat webhook received invalid JSON', [
|
||||||
'error' => json_last_error_msg(),
|
'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(
|
ProcessRevenueCatWebhook::dispatch(
|
||||||
|
|||||||
@@ -2,14 +2,15 @@
|
|||||||
|
|
||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
use Illuminate\Http\Request;
|
use App\Models\Package;
|
||||||
|
use App\Support\ApiError;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
use Stripe\Stripe;
|
|
||||||
use Stripe\PaymentIntent;
|
use Stripe\PaymentIntent;
|
||||||
use App\Models\Package;
|
use Stripe\Stripe;
|
||||||
use App\Models\Tenant;
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
class StripePaymentController extends Controller
|
class StripePaymentController extends Controller
|
||||||
{
|
{
|
||||||
@@ -25,13 +26,23 @@ class StripePaymentController extends Controller
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
$user = Auth::user();
|
$user = Auth::user();
|
||||||
if (!$user) {
|
if (! $user) {
|
||||||
return response()->json(['error' => 'Nicht authentifiziert'], 401);
|
return ApiError::response(
|
||||||
|
'unauthenticated',
|
||||||
|
'Nicht authentifiziert',
|
||||||
|
'Bitte melde dich an, um einen Kauf zu starten.',
|
||||||
|
Response::HTTP_UNAUTHORIZED
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
$tenant = $user->tenant;
|
$tenant = $user->tenant;
|
||||||
if (!$tenant) {
|
if (! $tenant) {
|
||||||
return response()->json(['error' => 'Kein Tenant gefunden'], 403);
|
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);
|
$package = Package::findOrFail($request->package_id);
|
||||||
@@ -40,13 +51,13 @@ class StripePaymentController extends Controller
|
|||||||
if ($package->price <= 0) {
|
if ($package->price <= 0) {
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'type' => 'free',
|
'type' => 'free',
|
||||||
'message' => 'Kostenloses Paket - kein Payment Intent nötig'
|
'message' => 'Kostenloses Paket - kein Payment Intent nötig',
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$paymentIntent = PaymentIntent::create([
|
$paymentIntent = PaymentIntent::create([
|
||||||
'amount' => (int)($package->price * 100), // In Cent
|
'amount' => (int) ($package->price * 100), // In Cent
|
||||||
'currency' => 'eur',
|
'currency' => 'eur',
|
||||||
'metadata' => [
|
'metadata' => [
|
||||||
'package_id' => $package->id,
|
'package_id' => $package->id,
|
||||||
@@ -65,7 +76,7 @@ class StripePaymentController extends Controller
|
|||||||
'payment_intent_id' => $paymentIntent->id,
|
'payment_intent_id' => $paymentIntent->id,
|
||||||
'package_id' => $package->id,
|
'package_id' => $package->id,
|
||||||
'tenant_id' => $tenant->id,
|
'tenant_id' => $tenant->id,
|
||||||
'amount' => $package->price
|
'amount' => $package->price,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
@@ -76,10 +87,16 @@ class StripePaymentController extends Controller
|
|||||||
Log::error('Stripe Payment Intent Fehler', [
|
Log::error('Stripe Payment Intent Fehler', [
|
||||||
'error' => $e->getMessage(),
|
'error' => $e->getMessage(),
|
||||||
'package_id' => $request->package_id,
|
'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()]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,11 +2,14 @@
|
|||||||
|
|
||||||
namespace App\Http\Middleware;
|
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\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
|
class ApiTokenAuth
|
||||||
{
|
{
|
||||||
@@ -14,19 +17,30 @@ class ApiTokenAuth
|
|||||||
{
|
{
|
||||||
$header = $request->header('Authorization', '');
|
$header = $request->header('Authorization', '');
|
||||||
if (! str_starts_with($header, 'Bearer ')) {
|
if (! str_starts_with($header, 'Bearer ')) {
|
||||||
return response()->json(['error' => ['code' => 'unauthorized']], 401);
|
return $this->unauthorizedResponse('missing_bearer');
|
||||||
}
|
}
|
||||||
$token = substr($header, 7);
|
$token = substr($header, 7);
|
||||||
$userId = Cache::get('api_token:'.$token);
|
$userId = Cache::get('api_token:'.$token);
|
||||||
if (! $userId) {
|
if (! $userId) {
|
||||||
return response()->json(['error' => ['code' => 'unauthorized']], 401);
|
return $this->unauthorizedResponse('token_unknown');
|
||||||
}
|
}
|
||||||
$user = User::find($userId);
|
$user = User::find($userId);
|
||||||
if (! $user) {
|
if (! $user) {
|
||||||
return response()->json(['error' => ['code' => 'unauthorized']], 401);
|
return $this->unauthorizedResponse('user_missing');
|
||||||
}
|
}
|
||||||
Auth::login($user); // for policies if needed
|
Auth::login($user); // for policies if needed
|
||||||
|
|
||||||
return $next($request);
|
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]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,9 +2,12 @@
|
|||||||
|
|
||||||
namespace App\Http\Middleware;
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use App\Support\ApiError;
|
||||||
use Closure;
|
use Closure;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
class TenantIsolation
|
class TenantIsolation
|
||||||
{
|
{
|
||||||
@@ -15,15 +18,15 @@ class TenantIsolation
|
|||||||
{
|
{
|
||||||
$tenantId = $request->attributes->get('tenant_id');
|
$tenantId = $request->attributes->get('tenant_id');
|
||||||
|
|
||||||
if (!$tenantId) {
|
if (! $tenantId) {
|
||||||
return response()->json(['error' => 'Tenant ID not found in token'], 401);
|
return $this->missingTenantIdResponse();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the tenant from request (query param, route param, or header)
|
// Get the tenant from request (query param, route param, or header)
|
||||||
$requestTenantId = $this->getTenantIdFromRequest($request);
|
$requestTenantId = $this->getTenantIdFromRequest($request);
|
||||||
|
|
||||||
if ($requestTenantId && $requestTenantId != $tenantId) {
|
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
|
// Set tenant context for query scoping
|
||||||
@@ -32,7 +35,6 @@ class TenantIsolation
|
|||||||
$connection->statement('SET @tenant_id = ?', [$tenantId]);
|
$connection->statement('SET @tenant_id = ?', [$tenantId]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// Add tenant context to request for easy access in controllers
|
// Add tenant context to request for easy access in controllers
|
||||||
$request->attributes->set('current_tenant_id', $tenantId);
|
$request->attributes->set('current_tenant_id', $tenantId);
|
||||||
|
|
||||||
@@ -62,4 +64,28 @@ class TenantIsolation
|
|||||||
// 4. For tenant-specific resources, use token tenant_id
|
// 4. For tenant-specific resources, use token tenant_id
|
||||||
return null;
|
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,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,15 +4,18 @@ namespace App\Http\Middleware;
|
|||||||
|
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\TenantToken;
|
use App\Models\TenantToken;
|
||||||
|
use App\Support\ApiError;
|
||||||
use Closure;
|
use Closure;
|
||||||
use Firebase\JWT\JWT;
|
use Firebase\JWT\JWT;
|
||||||
use Firebase\JWT\Key;
|
use Firebase\JWT\Key;
|
||||||
use Illuminate\Support\Facades\File;
|
|
||||||
use Illuminate\Auth\GenericUser;
|
use Illuminate\Auth\GenericUser;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
use Illuminate\Support\Facades\Cache;
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use Illuminate\Support\Facades\File;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
class TenantTokenGuard
|
class TenantTokenGuard
|
||||||
{
|
{
|
||||||
@@ -26,36 +29,76 @@ class TenantTokenGuard
|
|||||||
$token = $this->getTokenFromRequest($request);
|
$token = $this->getTokenFromRequest($request);
|
||||||
|
|
||||||
if (! $token) {
|
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 {
|
try {
|
||||||
$decoded = $this->decodeToken($token);
|
$decoded = $this->decodeToken($token);
|
||||||
} catch (\Exception $e) {
|
} 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)) {
|
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)) {
|
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()) {
|
if (($decoded['exp'] ?? 0) < time()) {
|
||||||
$this->blacklistToken($decoded);
|
$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;
|
$tenantId = $decoded['tenant_id'] ?? $decoded['sub'] ?? null;
|
||||||
if (! $tenantId) {
|
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);
|
$tenant = Tenant::query()->find($tenantId);
|
||||||
if (! $tenant) {
|
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'] ?? []);
|
$scopesFromToken = $this->normaliseScopes($decoded['scopes'] ?? []);
|
||||||
@@ -127,6 +170,7 @@ class TenantTokenGuard
|
|||||||
}
|
}
|
||||||
|
|
||||||
$decodedHeader = json_decode(base64_decode($segments[0]), true);
|
$decodedHeader = json_decode(base64_decode($segments[0]), true);
|
||||||
|
|
||||||
return is_array($decodedHeader) ? ($decodedHeader['kid'] ?? null) : null;
|
return is_array($decodedHeader) ? ($decodedHeader['kid'] ?? null) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -170,12 +214,14 @@ class TenantTokenGuard
|
|||||||
|
|
||||||
if ($tokenRecord->revoked_at) {
|
if ($tokenRecord->revoked_at) {
|
||||||
Cache::put($cacheKey, true, $this->cacheTtlFromDecoded($decoded));
|
Cache::put($cacheKey, true, $this->cacheTtlFromDecoded($decoded));
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($tokenRecord->expires_at && $tokenRecord->expires_at->isPast()) {
|
if ($tokenRecord->expires_at && $tokenRecord->expires_at->isPast()) {
|
||||||
$tokenRecord->update(['revoked_at' => now()]);
|
$tokenRecord->update(['revoked_at' => now()]);
|
||||||
Cache::put($cacheKey, true, $this->cacheTtlFromDecoded($decoded));
|
Cache::put($cacheKey, true, $this->cacheTtlFromDecoded($decoded));
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -187,7 +233,7 @@ class TenantTokenGuard
|
|||||||
*/
|
*/
|
||||||
private function blacklistToken(array $decoded): void
|
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}";
|
$cacheKey = "blacklisted_token:{$jti}";
|
||||||
|
|
||||||
Cache::put($cacheKey, true, $this->cacheTtlFromDecoded($decoded));
|
Cache::put($cacheKey, true, $this->cacheTtlFromDecoded($decoded));
|
||||||
@@ -201,6 +247,7 @@ class TenantTokenGuard
|
|||||||
'revoked_at' => now(),
|
'revoked_at' => now(),
|
||||||
'expires_at' => $record->expires_at ?? now(),
|
'expires_at' => $record->expires_at ?? now(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -254,5 +301,9 @@ class TenantTokenGuard
|
|||||||
|
|
||||||
return $ttl;
|
return $ttl;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
|
private function errorResponse(string $code, string $title, string $message, int $status, array $meta = []): JsonResponse
|
||||||
|
{
|
||||||
|
return ApiError::response($code, $title, $message, $status, $meta);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
42
app/Http/Requests/Tenant/NotificationPreferencesRequest.php
Normal file
42
app/Http/Requests/Tenant/NotificationPreferencesRequest.php
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Tenant;
|
||||||
|
|
||||||
|
use App\Services\Packages\TenantNotificationPreferences;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class NotificationPreferencesRequest extends FormRequest
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Determine if the user is authorized to make this request.
|
||||||
|
*/
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the validation rules that apply to the request.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
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', []),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Http\Resources\Tenant;
|
namespace App\Http\Resources\Tenant;
|
||||||
|
|
||||||
|
use App\Services\Packages\PackageLimitEvaluator;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Http\Resources\Json\JsonResource;
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
use Illuminate\Http\Resources\MissingValue;
|
use Illuminate\Http\Resources\MissingValue;
|
||||||
@@ -29,6 +30,11 @@ class EventResource extends JsonResource
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$limitEvaluator = null;
|
||||||
|
if ($eventPackage) {
|
||||||
|
$limitEvaluator = app()->make(PackageLimitEvaluator::class);
|
||||||
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'id' => $this->id,
|
'id' => $this->id,
|
||||||
'name' => $this->name,
|
'name' => $this->name,
|
||||||
@@ -67,6 +73,9 @@ class EventResource extends JsonResource
|
|||||||
'purchased_at' => $eventPackage->purchased_at?->toIso8601String(),
|
'purchased_at' => $eventPackage->purchased_at?->toIso8601String(),
|
||||||
'expires_at' => $eventPackage->gallery_expires_at?->toIso8601String(),
|
'expires_at' => $eventPackage->gallery_expires_at?->toIso8601String(),
|
||||||
] : null,
|
] : null,
|
||||||
|
'limits' => $eventPackage && $limitEvaluator
|
||||||
|
? $limitEvaluator->summarizeEventPackage($eventPackage)
|
||||||
|
: null,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -113,6 +113,36 @@ class PackageLimitEvaluator
|
|||||||
return $eventPackage;
|
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}
|
* @return array{0: ?Event, 1: ?\App\Models\EventPackage}
|
||||||
*/
|
*/
|
||||||
@@ -148,4 +178,120 @@ class PackageLimitEvaluator
|
|||||||
|
|
||||||
return [$event, $eventPackage];
|
return [$event, $eventPackage];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int|float|string> $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<int|string> $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(),
|
||||||
|
];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,11 @@ class TenantNotificationPreferences
|
|||||||
'credits_low' => true,
|
'credits_low' => true,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
public static function defaults(): array
|
||||||
|
{
|
||||||
|
return self::DEFAULTS;
|
||||||
|
}
|
||||||
|
|
||||||
public function shouldNotify(Tenant $tenant, string $preferenceKey): bool
|
public function shouldNotify(Tenant $tenant, string $preferenceKey): bool
|
||||||
{
|
{
|
||||||
$preferences = $tenant->notification_preferences ?? [];
|
$preferences = $tenant->notification_preferences ?? [];
|
||||||
|
|||||||
@@ -21,9 +21,9 @@
|
|||||||
### 1. Backend Unification
|
### 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] 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)*
|
- [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)*
|
- [x] Konsistentes Fehler-Response-Schema (`code`, `title`, `message`, `meta`) implementieren.
|
||||||
- [ ] Domain-Events für Grenzwerte & Ablaufzustände emitten.
|
- [x] Domain-Events für Grenzwerte & Ablaufzustände emitten.
|
||||||
- [ ] Feature-/Unit-Tests für neue Services & Events.
|
- [x] Feature-/Unit-Tests für neue Services & Events.
|
||||||
|
|
||||||
### 2. Threshold Detection & Storage
|
### 2. Threshold Detection & Storage
|
||||||
- [x] Schwellenwerte konfigurieren (Fotos/Gäste, Gallery D-7/D-1).
|
- [x] Schwellenwerte konfigurieren (Fotos/Gäste, Gallery D-7/D-1).
|
||||||
@@ -31,9 +31,9 @@
|
|||||||
- [x] Persistenz für Galerie-Benachrichtigungen (warning/expired timestamps).
|
- [x] Persistenz für Galerie-Benachrichtigungen (warning/expired timestamps).
|
||||||
|
|
||||||
### 3. Guest PWA Improvements
|
### 3. Guest PWA Improvements
|
||||||
- [ ] Limit-Status im Upload-Flow anzeigen (Warnbanner + Sperrzustände).
|
- [x] Limit-Status im Upload-Flow anzeigen (Warnbanner + Sperrzustände).
|
||||||
- [ ] Upload-Fehlercodes auswerten und freundliche Dialoge zeigen.
|
- [x] Upload-Fehlercodes auswerten und freundliche Dialoge zeigen.
|
||||||
- [ ] Galerie-Countdown/Badge für Ablaufdatum + Call-to-Action.
|
- [x] Galerie-Countdown/Badge für Ablaufdatum + Call-to-Action.
|
||||||
- [ ] E2E-Tests für Limitwarnungen & abgelaufene Galerie aktualisieren.
|
- [ ] E2E-Tests für Limitwarnungen & abgelaufene Galerie aktualisieren.
|
||||||
|
|
||||||
### 4. Tenant Admin PWA Improvements
|
### 4. Tenant Admin PWA Improvements
|
||||||
|
|||||||
BIN
public/joyous_wedding_guests_posing.jpg
Normal file
BIN
public/joyous_wedding_guests_posing.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.3 MiB |
@@ -1,5 +1,6 @@
|
|||||||
import { authorizedFetch } from './auth/tokens';
|
import { authorizedFetch } from './auth/tokens';
|
||||||
import { ApiError } from './lib/apiError';
|
import { ApiError } from './lib/apiError';
|
||||||
|
import type { EventLimitSummary } from './lib/limitWarnings';
|
||||||
import i18n from './i18n';
|
import i18n from './i18n';
|
||||||
|
|
||||||
type JsonValue = Record<string, unknown>;
|
type JsonValue = Record<string, unknown>;
|
||||||
@@ -62,6 +63,7 @@ export type TenantEvent = {
|
|||||||
purchased_at: string | null;
|
purchased_at: string | null;
|
||||||
expires_at: string | null;
|
expires_at: string | null;
|
||||||
} | null;
|
} | null;
|
||||||
|
limits?: EventLimitSummary | null;
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -128,6 +130,13 @@ export type TenantPackageSummary = {
|
|||||||
package_limits: Record<string, unknown> | null;
|
package_limits: Record<string, unknown> | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type NotificationPreferences = Record<string, boolean>;
|
||||||
|
|
||||||
|
export type NotificationPreferencesMeta = {
|
||||||
|
credit_warning_sent_at?: string | null;
|
||||||
|
credit_warning_threshold?: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
export type CreditBalance = {
|
export type CreditBalance = {
|
||||||
balance: number;
|
balance: number;
|
||||||
free_event_granted_at?: string | null;
|
free_event_granted_at?: string | null;
|
||||||
@@ -490,6 +499,7 @@ function normalizeEvent(event: JsonValue): TenantEvent {
|
|||||||
engagement_mode: engagementMode,
|
engagement_mode: engagementMode,
|
||||||
settings,
|
settings,
|
||||||
package: event.package ?? null,
|
package: event.package ?? null,
|
||||||
|
limits: (event.limits ?? null) as EventLimitSummary | null,
|
||||||
};
|
};
|
||||||
|
|
||||||
return normalized;
|
return normalized;
|
||||||
@@ -779,10 +789,17 @@ export async function getEventTypes(): Promise<TenantEventType[]> {
|
|||||||
.filter((row): row is TenantEventType => Boolean(row));
|
.filter((row): row is TenantEventType => Boolean(row));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getEventPhotos(slug: string): Promise<TenantPhoto[]> {
|
export async function getEventPhotos(slug: string): Promise<{ photos: TenantPhoto[]; limits: EventLimitSummary | null }> {
|
||||||
const response = await authorizedFetch(`${eventEndpoint(slug)}/photos`);
|
const response = await authorizedFetch(`${eventEndpoint(slug)}/photos`);
|
||||||
const data = await jsonOrThrow<{ data?: TenantPhoto[] }>(response, 'Failed to load photos');
|
const data = await jsonOrThrow<{ data?: TenantPhoto[]; limits?: EventLimitSummary | null }>(
|
||||||
return (data.data ?? []).map(normalizePhoto);
|
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<TenantPhoto> {
|
export async function featurePhoto(slug: string, id: number): Promise<TenantPhoto> {
|
||||||
@@ -1049,6 +1066,56 @@ export async function getTenantPackagesOverview(): Promise<{
|
|||||||
return { packages, activePackage };
|
return { packages, activePackage };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type NotificationPreferenceResponse = {
|
||||||
|
defaults: NotificationPreferences;
|
||||||
|
preferences: NotificationPreferences;
|
||||||
|
overrides: NotificationPreferences | null;
|
||||||
|
meta: NotificationPreferencesMeta | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function getNotificationPreferences(): Promise<NotificationPreferenceResponse> {
|
||||||
|
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<NotificationPreferenceResponse> {
|
||||||
|
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<{
|
export async function getTenantPaddleTransactions(cursor?: string): Promise<{
|
||||||
data: PaddleTransactionSummary[];
|
data: PaddleTransactionSummary[];
|
||||||
nextCursor: string | null;
|
nextCursor: string | null;
|
||||||
|
|||||||
@@ -28,5 +28,18 @@
|
|||||||
"creditsExhausted": "Keine Event-Credits mehr verfügbar. Bitte buche Credits oder upgrade dein Paket.",
|
"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.",
|
"photoLimit": "Für dieses Event ist das Foto-Upload-Limit erreicht.",
|
||||||
"goToBilling": "Zur Paketverwaltung"
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,6 +86,18 @@
|
|||||||
"description": "Aktives Paket und Historie einsehen."
|
"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": {
|
"upcoming": {
|
||||||
"title": "Kommende Events",
|
"title": "Kommende Events",
|
||||||
"description": "Die nächsten Termine inklusive Status & Zugriff.",
|
"description": "Die nächsten Termine inklusive Status & Zugriff.",
|
||||||
@@ -158,6 +170,18 @@
|
|||||||
"description": "Aktives Paket und Historie einsehen."
|
"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": {
|
"upcoming": {
|
||||||
"title": "Kommende Events",
|
"title": "Kommende Events",
|
||||||
"description": "Die nächsten Termine inklusive Status & Zugriff.",
|
"description": "Die nächsten Termine inklusive Status & Zugriff.",
|
||||||
|
|||||||
@@ -32,6 +32,12 @@
|
|||||||
"label": "Läuft ab",
|
"label": "Läuft ab",
|
||||||
"helper": "Automatische Verlängerung, falls aktiv"
|
"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": {
|
"packages": {
|
||||||
@@ -43,7 +49,13 @@
|
|||||||
"statusInactive": "Inaktiv",
|
"statusInactive": "Inaktiv",
|
||||||
"used": "Genutzte Events",
|
"used": "Genutzte Events",
|
||||||
"available": "Verfügbar",
|
"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": {
|
"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": {
|
"members": {
|
||||||
"title": "Event-Mitglieder",
|
"title": "Event-Mitglieder",
|
||||||
"subtitle": "Verwalte Moderatoren, Admins und Helfer für dieses Event.",
|
"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."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,5 +28,18 @@
|
|||||||
"creditsExhausted": "You have no event credits remaining. Purchase credits or upgrade your package.",
|
"creditsExhausted": "You have no event credits remaining. Purchase credits or upgrade your package.",
|
||||||
"photoLimit": "This event reached its photo upload limit.",
|
"photoLimit": "This event reached its photo upload limit.",
|
||||||
"goToBilling": "Manage subscription"
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,6 +86,18 @@
|
|||||||
"description": "View your active package and history."
|
"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": {
|
"upcoming": {
|
||||||
"title": "Upcoming events",
|
"title": "Upcoming events",
|
||||||
"description": "The next dates including status and quick access.",
|
"description": "The next dates including status and quick access.",
|
||||||
@@ -158,6 +170,18 @@
|
|||||||
"description": "View your active package and history."
|
"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": {
|
"upcoming": {
|
||||||
"title": "Upcoming events",
|
"title": "Upcoming events",
|
||||||
"description": "The next dates including status and quick access.",
|
"description": "The next dates including status and quick access.",
|
||||||
|
|||||||
@@ -32,6 +32,12 @@
|
|||||||
"label": "Expires",
|
"label": "Expires",
|
||||||
"helper": "Auto-renews if enabled"
|
"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": {
|
"packages": {
|
||||||
@@ -43,7 +49,13 @@
|
|||||||
"statusInactive": "Inactive",
|
"statusInactive": "Inactive",
|
||||||
"used": "Events used",
|
"used": "Events used",
|
||||||
"available": "Remaining",
|
"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": {
|
"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": {
|
"members": {
|
||||||
"title": "Event members",
|
"title": "Event members",
|
||||||
"subtitle": "Manage moderators, admins, and helpers for this event.",
|
"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."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,3 +13,23 @@ export class ApiError extends Error {
|
|||||||
export function isApiError(value: unknown): value is ApiError {
|
export function isApiError(value: unknown): value is ApiError {
|
||||||
return value instanceof 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;
|
||||||
|
}
|
||||||
|
|||||||
118
resources/js/admin/lib/limitWarnings.ts
Normal file
118
resources/js/admin/lib/limitWarnings.ts
Normal file
@@ -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, unknown>) => 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;
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import React from 'react';
|
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 { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
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 { getTenantPackagesOverview, getTenantPaddleTransactions, PaddleTransactionSummary, TenantPackageSummary } from '../api';
|
||||||
import { isAuthError } from '../auth/tokens';
|
import { isAuthError } from '../auth/tokens';
|
||||||
|
|
||||||
|
type PackageWarning = { id: string; tone: 'warning' | 'danger'; message: string };
|
||||||
|
|
||||||
export default function BillingPage() {
|
export default function BillingPage() {
|
||||||
const { t, i18n } = useTranslation(['management', 'dashboard']);
|
const { t, i18n } = useTranslation(['management', 'dashboard']);
|
||||||
const locale = React.useMemo(
|
const locale = React.useMemo(
|
||||||
@@ -112,6 +114,11 @@ export default function BillingPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const activeWarnings = React.useMemo(
|
||||||
|
() => buildPackageWarnings(activePackage, t, formatDate, 'billing.sections.overview.warnings'),
|
||||||
|
[activePackage, t, formatDate],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AdminLayout
|
<AdminLayout
|
||||||
title={t('billing.title')}
|
title={t('billing.title')}
|
||||||
@@ -146,33 +153,52 @@ export default function BillingPage() {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{activePackage ? (
|
{activePackage ? (
|
||||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
<div className="space-y-4">
|
||||||
<InfoCard
|
{activeWarnings.length > 0 && (
|
||||||
label={t('billing.sections.overview.cards.package.label')}
|
<div className="space-y-2">
|
||||||
value={activePackage.package_name}
|
{activeWarnings.map((warning) => (
|
||||||
tone="pink"
|
<Alert
|
||||||
helper={t('billing.sections.overview.cards.package.helper')}
|
key={warning.id}
|
||||||
/>
|
variant={warning.tone === 'danger' ? 'destructive' : 'default'}
|
||||||
<InfoCard
|
className={warning.tone === 'warning' ? 'border-amber-400/40 bg-amber-50 text-amber-900' : undefined}
|
||||||
label={t('billing.sections.overview.cards.used.label')}
|
>
|
||||||
value={activePackage.used_events ?? 0}
|
<AlertDescription className="flex items-center gap-2 text-sm">
|
||||||
tone="amber"
|
<AlertTriangle className="h-4 w-4" />
|
||||||
helper={t('billing.sections.overview.cards.used.helper', {
|
{warning.message}
|
||||||
count: activePackage.remaining_events ?? 0,
|
</AlertDescription>
|
||||||
})}
|
</Alert>
|
||||||
/>
|
))}
|
||||||
<InfoCard
|
</div>
|
||||||
label={t('billing.sections.overview.cards.price.label')}
|
)}
|
||||||
value={formatCurrency(activePackage.price ?? null, activePackage.currency ?? 'EUR')}
|
|
||||||
tone="sky"
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
helper={activePackage.currency ?? 'EUR'}
|
<InfoCard
|
||||||
/>
|
label={t('billing.sections.overview.cards.package.label')}
|
||||||
<InfoCard
|
value={activePackage.package_name}
|
||||||
label={t('billing.sections.overview.cards.expires.label')}
|
tone="pink"
|
||||||
value={formatDate(activePackage.expires_at)}
|
helper={t('billing.sections.overview.cards.package.helper')}
|
||||||
tone="emerald"
|
/>
|
||||||
helper={t('billing.sections.overview.cards.expires.helper')}
|
<InfoCard
|
||||||
/>
|
label={t('billing.sections.overview.cards.used.label')}
|
||||||
|
value={activePackage.used_events ?? 0}
|
||||||
|
tone="amber"
|
||||||
|
helper={t('billing.sections.overview.cards.used.helper', {
|
||||||
|
count: activePackage.remaining_events ?? 0,
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
<InfoCard
|
||||||
|
label={t('billing.sections.overview.cards.price.label')}
|
||||||
|
value={formatCurrency(activePackage.price ?? null, activePackage.currency ?? 'EUR')}
|
||||||
|
tone="sky"
|
||||||
|
helper={activePackage.currency ?? 'EUR'}
|
||||||
|
/>
|
||||||
|
<InfoCard
|
||||||
|
label={t('billing.sections.overview.cards.expires.label')}
|
||||||
|
value={formatDate(activePackage.expires_at)}
|
||||||
|
tone="emerald"
|
||||||
|
helper={t('billing.sections.overview.cards.expires.helper')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<EmptyState message={t('billing.sections.overview.empty')} />
|
<EmptyState message={t('billing.sections.overview.empty')} />
|
||||||
@@ -194,16 +220,20 @@ export default function BillingPage() {
|
|||||||
{packages.length === 0 ? (
|
{packages.length === 0 ? (
|
||||||
<EmptyState message={t('billing.sections.packages.empty')} />
|
<EmptyState message={t('billing.sections.packages.empty')} />
|
||||||
) : (
|
) : (
|
||||||
packages.map((pkg) => (
|
packages.map((pkg) => {
|
||||||
<PackageCard
|
const warnings = buildPackageWarnings(pkg, t, formatDate, 'billing.sections.packages.card.warnings');
|
||||||
key={pkg.id}
|
return (
|
||||||
pkg={pkg}
|
<PackageCard
|
||||||
isActive={Boolean(pkg.active)}
|
key={pkg.id}
|
||||||
labels={packageLabels}
|
pkg={pkg}
|
||||||
formatDate={formatDate}
|
isActive={Boolean(pkg.active)}
|
||||||
formatCurrency={formatCurrency}
|
labels={packageLabels}
|
||||||
/>
|
formatDate={formatDate}
|
||||||
))
|
formatCurrency={formatCurrency}
|
||||||
|
warnings={warnings}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -369,6 +399,7 @@ function PackageCard({
|
|||||||
labels,
|
labels,
|
||||||
formatDate,
|
formatDate,
|
||||||
formatCurrency,
|
formatCurrency,
|
||||||
|
warnings = [],
|
||||||
}: {
|
}: {
|
||||||
pkg: TenantPackageSummary;
|
pkg: TenantPackageSummary;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
@@ -381,9 +412,26 @@ function PackageCard({
|
|||||||
};
|
};
|
||||||
formatDate: (value: string | null | undefined) => string;
|
formatDate: (value: string | null | undefined) => string;
|
||||||
formatCurrency: (value: number | null | undefined, currency?: string) => string;
|
formatCurrency: (value: number | null | undefined, currency?: string) => string;
|
||||||
|
warnings?: PackageWarning[];
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-2xl border border-amber-100 bg-white/90 p-4 shadow-sm">
|
<div className="rounded-2xl border border-amber-100 bg-white/90 p-4 shadow-sm">
|
||||||
|
{warnings.length > 0 && (
|
||||||
|
<div className="mb-3 space-y-2">
|
||||||
|
{warnings.map((warning) => (
|
||||||
|
<Alert
|
||||||
|
key={warning.id}
|
||||||
|
variant={warning.tone === 'danger' ? 'destructive' : 'default'}
|
||||||
|
className={warning.tone === 'warning' ? 'border-amber-400/40 bg-amber-50 text-amber-900' : undefined}
|
||||||
|
>
|
||||||
|
<AlertDescription className="flex items-center gap-2 text-xs">
|
||||||
|
<AlertTriangle className="h-4 w-4" />
|
||||||
|
{warning.message}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-sm font-semibold text-slate-900">{pkg.package_name}</h3>
|
<h3 className="text-sm font-semibold text-slate-900">{pkg.package_name}</h3>
|
||||||
@@ -422,6 +470,60 @@ function EmptyState({ message }: { message: string }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildPackageWarnings(
|
||||||
|
pkg: TenantPackageSummary | null | undefined,
|
||||||
|
translate: (key: string, options?: Record<string, unknown>) => 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() {
|
function BillingSkeleton() {
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-6">
|
<div className="grid gap-6">
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import {
|
import {
|
||||||
CalendarDays,
|
CalendarDays,
|
||||||
Camera,
|
Camera,
|
||||||
|
AlertTriangle,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
Users,
|
Users,
|
||||||
Plus,
|
Plus,
|
||||||
@@ -44,6 +45,7 @@ import {
|
|||||||
buildEngagementTabPath,
|
buildEngagementTabPath,
|
||||||
} from '../constants';
|
} from '../constants';
|
||||||
import { useOnboardingProgress } from '../onboarding';
|
import { useOnboardingProgress } from '../onboarding';
|
||||||
|
import { buildLimitWarnings } from '../lib/limitWarnings';
|
||||||
|
|
||||||
interface DashboardState {
|
interface DashboardState {
|
||||||
summary: DashboardSummary | null;
|
summary: DashboardSummary | null;
|
||||||
@@ -189,6 +191,28 @@ export default function DashboardPage() {
|
|||||||
|
|
||||||
const upcomingEvents = getUpcomingEvents(events);
|
const upcomingEvents = getUpcomingEvents(events);
|
||||||
const publishedEvents = events.filter((event) => event.status === 'published');
|
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<string, unknown>) => 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 = (
|
const actions = (
|
||||||
<>
|
<>
|
||||||
@@ -295,6 +319,76 @@ export default function DashboardPage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{primaryEventLimits ? (
|
||||||
|
<Card className="border-0 bg-brand-card shadow-brand-primary">
|
||||||
|
<CardHeader className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-xl text-slate-900">
|
||||||
|
<PackageIcon className="h-5 w-5 text-brand-rose" />
|
||||||
|
{translate('limitsCard.title')}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="text-sm text-slate-600">
|
||||||
|
{primaryEventName
|
||||||
|
? translate('limitsCard.description', { name: primaryEventName })
|
||||||
|
: translate('limitsCard.descriptionFallback')}
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Badge className="bg-brand-rose-soft text-brand-rose">
|
||||||
|
{primaryEventName ?? translate('limitsCard.descriptionFallback')}
|
||||||
|
</Badge>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{limitWarnings.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{limitWarnings.map((warning) => (
|
||||||
|
<Alert
|
||||||
|
key={warning.id}
|
||||||
|
variant={warning.tone === 'danger' ? 'destructive' : 'default'}
|
||||||
|
className={warning.tone === 'warning' ? 'border-amber-400/50 bg-amber-50 text-amber-900' : undefined}
|
||||||
|
>
|
||||||
|
<AlertTitle className="flex items-center gap-2 text-sm font-semibold">
|
||||||
|
<AlertTriangle className="h-4 w-4" />
|
||||||
|
{limitScopeLabels[warning.scope]}
|
||||||
|
</AlertTitle>
|
||||||
|
<AlertDescription className="text-sm">
|
||||||
|
{warning.message}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<LimitUsageRow
|
||||||
|
label={translate('limitsCard.photosLabel')}
|
||||||
|
summary={primaryEventLimits.photos}
|
||||||
|
unlimitedLabel={tc('limits.unlimited')}
|
||||||
|
usageLabel={translate('limitsCard.usageLabel')}
|
||||||
|
remainingLabel={translate('limitsCard.remainingLabel')}
|
||||||
|
/>
|
||||||
|
<LimitUsageRow
|
||||||
|
label={translate('limitsCard.guestsLabel')}
|
||||||
|
summary={primaryEventLimits.guests}
|
||||||
|
unlimitedLabel={tc('limits.unlimited')}
|
||||||
|
usageLabel={translate('limitsCard.usageLabel')}
|
||||||
|
remainingLabel={translate('limitsCard.remainingLabel')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<GalleryStatusRow
|
||||||
|
label={translate('limitsCard.galleryLabel')}
|
||||||
|
summary={primaryEventLimits.gallery}
|
||||||
|
locale={dateLocale}
|
||||||
|
messages={{
|
||||||
|
expired: tc('limits.galleryExpired'),
|
||||||
|
noExpiry: translate('limitsCard.galleryNoExpiry'),
|
||||||
|
expires: translate('limitsCard.galleryExpires'),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<Card className="border-0 bg-brand-card shadow-brand-primary">
|
<Card className="border-0 bg-brand-card shadow-brand-primary">
|
||||||
<CardHeader className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
<CardHeader className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
||||||
<div>
|
<div>
|
||||||
@@ -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 {
|
function resolveEventName(name: TenantEvent['name'], fallbackSlug: string): string {
|
||||||
if (typeof name === 'string' && name.trim().length > 0) {
|
if (typeof name === 'string' && name.trim().length > 0) {
|
||||||
return name;
|
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 (
|
||||||
|
<div className="rounded-2xl border border-slate-200 bg-white/70 p-4">
|
||||||
|
<div className="flex items-center justify-between text-sm font-medium text-slate-700">
|
||||||
|
<span>{label}</span>
|
||||||
|
<span className="text-xs text-slate-500">{unlimitedLabel}</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-xs text-slate-500">{unlimitedLabel}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="rounded-2xl border border-slate-200 bg-white/70 p-4">
|
||||||
|
<div className="flex items-center justify-between text-sm font-medium text-slate-700">
|
||||||
|
<span>{label}</span>
|
||||||
|
<span className="text-xs text-slate-500">
|
||||||
|
{limit ? usageLabel.replace('{{used}}', `${summary.used}`).replace('{{limit}}', `${limit}`) : unlimitedLabel}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{limit ? (
|
||||||
|
<>
|
||||||
|
<div className="mt-3 h-2 rounded-full bg-slate-200">
|
||||||
|
<div
|
||||||
|
className={`h-2 rounded-full transition-all ${barClass}`}
|
||||||
|
style={{ width: `${Math.max(6, percent)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{remaining !== null ? (
|
||||||
|
<p className="mt-2 text-xs text-slate-500">
|
||||||
|
{remainingLabel
|
||||||
|
.replace('{{remaining}}', `${Math.max(0, remaining)}`)
|
||||||
|
.replace('{{limit}}', `${limit}`)}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="mt-2 text-xs text-slate-500">{unlimitedLabel}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="rounded-2xl border border-slate-200 bg-white/70 p-4">
|
||||||
|
<div className="flex items-center justify-between text-sm font-medium text-slate-700">
|
||||||
|
<span>{label}</span>
|
||||||
|
<span className={`rounded-full px-3 py-1 text-xs font-semibold ${badgeClass}`}>{statusLabel}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function ReadinessCard({
|
function ReadinessCard({
|
||||||
readiness,
|
readiness,
|
||||||
labels,
|
labels,
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ import {
|
|||||||
toggleEvent,
|
toggleEvent,
|
||||||
submitTenantFeedback,
|
submitTenantFeedback,
|
||||||
} from '../api';
|
} from '../api';
|
||||||
|
import { buildLimitWarnings } from '../lib/limitWarnings';
|
||||||
|
import { getApiErrorMessage } from '../lib/apiError';
|
||||||
import { isAuthError } from '../auth/tokens';
|
import { isAuthError } from '../auth/tokens';
|
||||||
import {
|
import {
|
||||||
ADMIN_EVENTS_PATH,
|
ADMIN_EVENTS_PATH,
|
||||||
@@ -69,6 +71,7 @@ export default function EventDetailPage({ mode = 'detail' }: EventDetailPageProp
|
|||||||
const { slug: slugParam } = useParams<{ slug?: string }>();
|
const { slug: slugParam } = useParams<{ slug?: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { t } = useTranslation('management');
|
const { t } = useTranslation('management');
|
||||||
|
const { t: tCommon } = useTranslation('common');
|
||||||
|
|
||||||
const slug = slugParam ?? null;
|
const slug = slugParam ?? null;
|
||||||
|
|
||||||
@@ -97,7 +100,11 @@ export default function EventDetailPage({ mode = 'detail' }: EventDetailPageProp
|
|||||||
setState((prev) => ({ ...prev, event: eventData, stats: statsData, loading: false }));
|
setState((prev) => ({ ...prev, event: eventData, stats: statsData, loading: false }));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!isAuthError(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 });
|
setToolkit({ data: toolkitData, loading: false, error: null });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!isAuthError(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]);
|
}, [slug, t]);
|
||||||
@@ -138,7 +149,11 @@ export default function EventDetailPage({ mode = 'detail' }: EventDetailPageProp
|
|||||||
}));
|
}));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!isAuthError(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 {
|
} else {
|
||||||
setState((prev) => ({ ...prev, busy: false }));
|
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 (
|
return (
|
||||||
<AdminLayout title={eventName} subtitle={subtitle} actions={actions}>
|
<AdminLayout title={eventName} subtitle={subtitle} actions={actions}>
|
||||||
{error && (
|
{error && (
|
||||||
@@ -205,6 +225,23 @@ export default function EventDetailPage({ mode = 'detail' }: EventDetailPageProp
|
|||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{limitWarnings.length > 0 && (
|
||||||
|
<div className="mb-6 space-y-2">
|
||||||
|
{limitWarnings.map((warning) => (
|
||||||
|
<Alert
|
||||||
|
key={warning.id}
|
||||||
|
variant={warning.tone === 'danger' ? 'destructive' : 'default'}
|
||||||
|
className={warning.tone === 'warning' ? 'border-amber-400/40 bg-amber-50 text-amber-900' : undefined}
|
||||||
|
>
|
||||||
|
<AlertDescription className="flex items-center gap-2 text-sm">
|
||||||
|
<AlertTriangle className="h-4 w-4" />
|
||||||
|
{warning.message}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{toolkit.error && (
|
{toolkit.error && (
|
||||||
<Alert variant="default">
|
<Alert variant="default">
|
||||||
<AlertTitle>{toolkit.error}</AlertTitle>
|
<AlertTitle>{toolkit.error}</AlertTitle>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
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 { useQuery } from '@tanstack/react-query';
|
||||||
|
|
||||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
} from '../api';
|
} from '../api';
|
||||||
import { isAuthError } from '../auth/tokens';
|
import { isAuthError } from '../auth/tokens';
|
||||||
import { isApiError } from '../lib/apiError';
|
import { isApiError } from '../lib/apiError';
|
||||||
|
import { buildLimitWarnings } from '../lib/limitWarnings';
|
||||||
import { ADMIN_BILLING_PATH, ADMIN_EVENT_VIEW_PATH, ADMIN_EVENTS_PATH } from '../constants';
|
import { ADMIN_BILLING_PATH, ADMIN_EVENT_VIEW_PATH, ADMIN_EVENTS_PATH } from '../constants';
|
||||||
|
|
||||||
interface EventFormState {
|
interface EventFormState {
|
||||||
@@ -66,6 +67,7 @@ export default function EventFormPage() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const { t: tCommon } = useTranslation('common', { keyPrefix: 'errors' });
|
const { t: tCommon } = useTranslation('common', { keyPrefix: 'errors' });
|
||||||
|
const { t: tLimits } = useTranslation('common', { keyPrefix: 'limits' });
|
||||||
|
|
||||||
const [form, setForm] = React.useState<EventFormState>({
|
const [form, setForm] = React.useState<EventFormState>({
|
||||||
name: '',
|
name: '',
|
||||||
@@ -193,6 +195,20 @@ export default function EventFormPage() {
|
|||||||
|
|
||||||
const loading = isEdit ? eventLoading : false;
|
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 {
|
function ensureSlugSuffix(): string {
|
||||||
if (!slugSuffixRef.current) {
|
if (!slugSuffixRef.current) {
|
||||||
slugSuffixRef.current = Math.random().toString(36).slice(2, 7);
|
slugSuffixRef.current = Math.random().toString(36).slice(2, 7);
|
||||||
@@ -394,11 +410,11 @@ export default function EventFormPage() {
|
|||||||
actions={actions}
|
actions={actions}
|
||||||
>
|
>
|
||||||
{error && (
|
{error && (
|
||||||
<Alert variant="destructive">
|
<Alert variant="destructive">
|
||||||
<AlertTitle>Hinweis</AlertTitle>
|
<AlertTitle>Hinweis</AlertTitle>
|
||||||
<AlertDescription className="flex flex-col gap-2">
|
<AlertDescription className="flex flex-col gap-2">
|
||||||
{error.split('\n').map((line, index) => (
|
{error.split('\n').map((line, index) => (
|
||||||
<span key={index}>{line}</span>
|
<span key={index}>{line}</span>
|
||||||
))}
|
))}
|
||||||
{showUpgradeHint && (
|
{showUpgradeHint && (
|
||||||
<div>
|
<div>
|
||||||
@@ -411,6 +427,26 @@ export default function EventFormPage() {
|
|||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{limitWarnings.length > 0 && (
|
||||||
|
<div className="mb-6 space-y-2">
|
||||||
|
{limitWarnings.map((warning) => (
|
||||||
|
<Alert
|
||||||
|
key={warning.id}
|
||||||
|
variant={warning.tone === 'danger' ? 'destructive' : 'default'}
|
||||||
|
className={warning.tone === 'warning' ? 'border-amber-400/50 bg-amber-50 text-amber-900' : undefined}
|
||||||
|
>
|
||||||
|
<AlertTitle className="flex items-center gap-2 text-sm font-semibold">
|
||||||
|
<AlertTriangle className="h-4 w-4" />
|
||||||
|
{limitScopeLabels[warning.scope]}
|
||||||
|
</AlertTitle>
|
||||||
|
<AlertDescription className="text-sm">
|
||||||
|
{warning.message}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<Card className="border-0 bg-white/85 shadow-xl shadow-fuchsia-100/60">
|
<Card className="border-0 bg-white/85 shadow-xl shadow-fuchsia-100/60">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2 text-xl text-slate-900">
|
<CardTitle className="flex items-center gap-2 text-xl text-slate-900">
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
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 { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
@@ -28,6 +28,7 @@ import {
|
|||||||
ADMIN_EVENT_TOOLKIT_PATH,
|
ADMIN_EVENT_TOOLKIT_PATH,
|
||||||
ADMIN_EVENT_PHOTOS_PATH,
|
ADMIN_EVENT_PHOTOS_PATH,
|
||||||
} from '../constants';
|
} from '../constants';
|
||||||
|
import { buildLimitWarnings } from '../lib/limitWarnings';
|
||||||
import { InviteLayoutCustomizerPanel, QrLayoutCustomization } from './components/InviteLayoutCustomizerPanel';
|
import { InviteLayoutCustomizerPanel, QrLayoutCustomization } from './components/InviteLayoutCustomizerPanel';
|
||||||
import { DesignerCanvas } from './components/invite-layout/DesignerCanvas';
|
import { DesignerCanvas } from './components/invite-layout/DesignerCanvas';
|
||||||
import {
|
import {
|
||||||
@@ -159,6 +160,7 @@ export default function EventInvitesPage(): JSX.Element {
|
|||||||
const { slug } = useParams<{ slug?: string }>();
|
const { slug } = useParams<{ slug?: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { t } = useTranslation('management');
|
const { t } = useTranslation('management');
|
||||||
|
const { t: tLimits } = useTranslation('common', { keyPrefix: 'limits' });
|
||||||
|
|
||||||
const [state, setState] = React.useState<PageState>({ event: null, invites: [], loading: true, error: null });
|
const [state, setState] = React.useState<PageState>({ event: null, invites: [], loading: true, error: null });
|
||||||
const [creatingInvite, setCreatingInvite] = React.useState(false);
|
const [creatingInvite, setCreatingInvite] = React.useState(false);
|
||||||
@@ -711,12 +713,46 @@ export default function EventInvitesPage(): JSX.Element {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
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 (
|
return (
|
||||||
<AdminLayout
|
<AdminLayout
|
||||||
title={eventName}
|
title={eventName}
|
||||||
subtitle={t('invites.subtitle', 'Manage QR-Einladungen, Drucklayouts und Branding für deine Gäste.')}
|
subtitle={t('invites.subtitle', 'Manage QR-Einladungen, Drucklayouts und Branding für deine Gäste.')}
|
||||||
actions={actions}
|
actions={actions}
|
||||||
>
|
>
|
||||||
|
{limitWarnings.length > 0 && (
|
||||||
|
<div className="mb-6 space-y-2">
|
||||||
|
{limitWarnings.map((warning) => (
|
||||||
|
<Alert
|
||||||
|
key={warning.id}
|
||||||
|
variant={warning.tone === 'danger' ? 'destructive' : 'default'}
|
||||||
|
className={warning.tone === 'warning' ? 'border-amber-400/50 bg-amber-50 text-amber-900' : undefined}
|
||||||
|
>
|
||||||
|
<AlertTitle className="flex items-center gap-2 text-sm font-semibold">
|
||||||
|
<AlertTriangle className="h-4 w-4" />
|
||||||
|
{limitScopeLabels[warning.scope]}
|
||||||
|
</AlertTitle>
|
||||||
|
<AlertDescription className="text-sm">
|
||||||
|
{warning.message}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<Tabs value={activeTab} onValueChange={handleTabChange} className="space-y-6">
|
<Tabs value={activeTab} onValueChange={handleTabChange} className="space-y-6">
|
||||||
<TabsList className="grid w-full max-w-2xl grid-cols-3 gap-1 rounded-full border border-[var(--tenant-border-strong)] bg-[var(--tenant-surface-strong)] p-1 text-sm">
|
<TabsList className="grid w-full max-w-2xl grid-cols-3 gap-1 rounded-full border border-[var(--tenant-border-strong)] bg-[var(--tenant-surface-strong)] p-1 text-sm">
|
||||||
<TabsTrigger value="layout" className="rounded-full px-4 py-1.5 data-[state=active]:bg-primary data-[state=active]:text-primary-foreground">
|
<TabsTrigger value="layout" className="rounded-full px-4 py-1.5 data-[state=active]:bg-primary data-[state=active]:text-primary-foreground">
|
||||||
@@ -1075,12 +1111,17 @@ export default function EventInvitesPage(): JSX.Element {
|
|||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={handleCreateInvite}
|
onClick={handleCreateInvite}
|
||||||
disabled={creatingInvite}
|
disabled={creatingInvite || state.event?.limits?.can_add_guests === false}
|
||||||
className="bg-primary text-primary-foreground shadow-lg shadow-primary/20 hover:bg-primary/90"
|
className="bg-primary text-primary-foreground shadow-lg shadow-primary/20 hover:bg-primary/90"
|
||||||
>
|
>
|
||||||
{creatingInvite ? <Loader2 className="mr-1 h-4 w-4 animate-spin" /> : <Share2 className="mr-1 h-4 w-4" />}
|
{creatingInvite ? <Loader2 className="mr-1 h-4 w-4 animate-spin" /> : <Share2 className="mr-1 h-4 w-4" />}
|
||||||
{t('invites.actions.create', 'Neue Einladung erstellen')}
|
{t('invites.actions.create', 'Neue Einladung erstellen')}
|
||||||
</Button>
|
</Button>
|
||||||
|
{!state.loading && state.event?.limits?.can_add_guests === false && (
|
||||||
|
<p className="w-full text-xs text-amber-600">
|
||||||
|
{tLimits('guestsBlocked')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
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 { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -9,6 +9,9 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
|
|||||||
import { AdminLayout } from '../components/AdminLayout';
|
import { AdminLayout } from '../components/AdminLayout';
|
||||||
import { deletePhoto, featurePhoto, getEventPhotos, TenantPhoto, unfeaturePhoto } from '../api';
|
import { deletePhoto, featurePhoto, getEventPhotos, TenantPhoto, unfeaturePhoto } from '../api';
|
||||||
import { isAuthError } from '../auth/tokens';
|
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';
|
import { ADMIN_EVENTS_PATH, ADMIN_EVENT_VIEW_PATH } from '../constants';
|
||||||
|
|
||||||
export default function EventPhotosPage() {
|
export default function EventPhotosPage() {
|
||||||
@@ -16,11 +19,18 @@ export default function EventPhotosPage() {
|
|||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const slug = params.slug ?? searchParams.get('slug') ?? null;
|
const slug = params.slug ?? searchParams.get('slug') ?? null;
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { t } = useTranslation('management');
|
||||||
|
const { t: tCommon } = useTranslation('common');
|
||||||
|
const translateLimits = React.useCallback(
|
||||||
|
(key: string, options?: Record<string, unknown>) => tCommon(`limits.${key}`, options),
|
||||||
|
[tCommon]
|
||||||
|
);
|
||||||
|
|
||||||
const [photos, setPhotos] = React.useState<TenantPhoto[]>([]);
|
const [photos, setPhotos] = React.useState<TenantPhoto[]>([]);
|
||||||
const [loading, setLoading] = React.useState(true);
|
const [loading, setLoading] = React.useState(true);
|
||||||
const [error, setError] = React.useState<string | null>(null);
|
const [error, setError] = React.useState<string | null>(null);
|
||||||
const [busyId, setBusyId] = React.useState<number | null>(null);
|
const [busyId, setBusyId] = React.useState<number | null>(null);
|
||||||
|
const [limits, setLimits] = React.useState<EventLimitSummary | null>(null);
|
||||||
|
|
||||||
const load = React.useCallback(async () => {
|
const load = React.useCallback(async () => {
|
||||||
if (!slug) {
|
if (!slug) {
|
||||||
@@ -30,11 +40,12 @@ export default function EventPhotosPage() {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const data = await getEventPhotos(slug);
|
const result = await getEventPhotos(slug);
|
||||||
setPhotos(data);
|
setPhotos(result.photos);
|
||||||
|
setLimits(result.limits ?? null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!isAuthError(err)) {
|
if (!isAuthError(err)) {
|
||||||
setError('Fotos konnten nicht geladen werden.');
|
setError(getApiErrorMessage(err, 'Fotos konnten nicht geladen werden.'));
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -55,7 +66,7 @@ export default function EventPhotosPage() {
|
|||||||
setPhotos((prev) => prev.map((entry) => (entry.id === photo.id ? updated : entry)));
|
setPhotos((prev) => prev.map((entry) => (entry.id === photo.id ? updated : entry)));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!isAuthError(err)) {
|
if (!isAuthError(err)) {
|
||||||
setError('Feature-Aktion fehlgeschlagen.');
|
setError(getApiErrorMessage(err, 'Feature-Aktion fehlgeschlagen.'));
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setBusyId(null);
|
setBusyId(null);
|
||||||
@@ -70,7 +81,7 @@ export default function EventPhotosPage() {
|
|||||||
setPhotos((prev) => prev.filter((entry) => entry.id !== photo.id));
|
setPhotos((prev) => prev.filter((entry) => entry.id !== photo.id));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!isAuthError(err)) {
|
if (!isAuthError(err)) {
|
||||||
setError('Foto konnte nicht entfernt werden.');
|
setError(getApiErrorMessage(err, 'Foto konnte nicht entfernt werden.'));
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setBusyId(null);
|
setBusyId(null);
|
||||||
@@ -104,31 +115,36 @@ export default function EventPhotosPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<AdminLayout
|
<AdminLayout
|
||||||
title="Fotos moderieren"
|
title={t('photos.moderation.title', 'Fotos moderieren')}
|
||||||
subtitle="Setze Highlights oder entferne unpassende Uploads."
|
subtitle={t('photos.moderation.subtitle', 'Setze Highlights oder entferne unpassende Uploads.')}
|
||||||
actions={actions}
|
actions={actions}
|
||||||
>
|
>
|
||||||
{error && (
|
{error && (
|
||||||
<Alert variant="destructive">
|
<Alert variant="destructive">
|
||||||
<AlertTitle>Aktion fehlgeschlagen</AlertTitle>
|
<AlertTitle>{t('photos.alerts.errorTitle', 'Aktion fehlgeschlagen')}</AlertTitle>
|
||||||
<AlertDescription>{error}</AlertDescription>
|
<AlertDescription>{error}</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<LimitWarningsBanner limits={limits} translate={translateLimits} />
|
||||||
|
|
||||||
<Card className="border-0 bg-white/85 shadow-xl shadow-sky-100/60">
|
<Card className="border-0 bg-white/85 shadow-xl shadow-sky-100/60">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2 text-xl text-slate-900">
|
<CardTitle className="flex items-center gap-2 text-xl text-slate-900">
|
||||||
<Camera className="h-5 w-5 text-sky-500" /> Galerie
|
<Camera className="h-5 w-5 text-sky-500" /> {t('photos.gallery.title', 'Galerie')}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription className="text-sm text-slate-600">
|
<CardDescription className="text-sm text-slate-600">
|
||||||
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.')}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<GallerySkeleton />
|
<GallerySkeleton />
|
||||||
) : photos.length === 0 ? (
|
) : photos.length === 0 ? (
|
||||||
<EmptyGallery />
|
<EmptyGallery
|
||||||
|
title={t('photos.gallery.emptyTitle', 'Noch keine Fotos vorhanden')}
|
||||||
|
description={t('photos.gallery.emptyDescription', 'Motiviere deine Gäste zum Hochladen - hier erscheint anschließend die Galerie.')}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3">
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3">
|
||||||
{photos.map((photo) => (
|
{photos.map((photo) => (
|
||||||
@@ -178,6 +194,37 @@ export default function EventPhotosPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function LimitWarningsBanner({
|
||||||
|
limits,
|
||||||
|
translate,
|
||||||
|
}: {
|
||||||
|
limits: EventLimitSummary | null;
|
||||||
|
translate: (key: string, options?: Record<string, unknown>) => string;
|
||||||
|
}) {
|
||||||
|
const warnings = React.useMemo(() => buildLimitWarnings(limits, translate), [limits, translate]);
|
||||||
|
|
||||||
|
if (!warnings.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mb-6 space-y-2">
|
||||||
|
{warnings.map((warning) => (
|
||||||
|
<Alert
|
||||||
|
key={warning.id}
|
||||||
|
variant={warning.tone === 'danger' ? 'destructive' : 'default'}
|
||||||
|
className={warning.tone === 'warning' ? 'border-amber-400/40 bg-amber-50 text-amber-900' : undefined}
|
||||||
|
>
|
||||||
|
<AlertDescription className="flex items-center gap-2 text-sm">
|
||||||
|
<AlertTriangle className="h-4 w-4" />
|
||||||
|
{warning.message}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function GallerySkeleton() {
|
function GallerySkeleton() {
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3">
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3">
|
||||||
@@ -188,15 +235,14 @@ function GallerySkeleton() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function EmptyGallery() {
|
function EmptyGallery({ title, description }: { title: string; description: string }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center gap-3 rounded-2xl border border-dashed border-sky-200 bg-white/70 p-10 text-center">
|
<div className="flex flex-col items-center justify-center gap-3 rounded-2xl border border-dashed border-sky-200 bg-white/70 p-10 text-center">
|
||||||
<div className="rounded-full bg-sky-100 p-3 text-sky-600 shadow-inner shadow-sky-200/80">
|
<div className="rounded-full bg-sky-100 p-3 text-sky-600 shadow-inner shadow-sky-200/80">
|
||||||
<Camera className="h-5 w-5" />
|
<Camera className="h-5 w-5" />
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-lg font-semibold text-slate-900">Noch keine Fotos vorhanden</h3>
|
<h3 className="text-lg font-semibold text-slate-900">{title}</h3>
|
||||||
<p className="text-sm text-slate-600">Motiviere deine Gäste zum Hochladen - hier erscheint anschließend die Galerie.</p>
|
<p className="text-sm text-slate-600">{description}</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Link, useNavigate } from 'react-router-dom';
|
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 { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
@@ -10,6 +10,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
|
|||||||
import { AdminLayout } from '../components/AdminLayout';
|
import { AdminLayout } from '../components/AdminLayout';
|
||||||
import { getEvents, TenantEvent } from '../api';
|
import { getEvents, TenantEvent } from '../api';
|
||||||
import { isAuthError } from '../auth/tokens';
|
import { isAuthError } from '../auth/tokens';
|
||||||
|
import { getApiErrorMessage } from '../lib/apiError';
|
||||||
import {
|
import {
|
||||||
adminPath,
|
adminPath,
|
||||||
ADMIN_SETTINGS_PATH,
|
ADMIN_SETTINGS_PATH,
|
||||||
@@ -21,8 +22,12 @@ import {
|
|||||||
ADMIN_EVENT_INVITES_PATH,
|
ADMIN_EVENT_INVITES_PATH,
|
||||||
ADMIN_EVENT_TOOLKIT_PATH,
|
ADMIN_EVENT_TOOLKIT_PATH,
|
||||||
} from '../constants';
|
} from '../constants';
|
||||||
|
import { buildLimitWarnings, type EventLimitSummary } from '../lib/limitWarnings';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
export default function EventsPage() {
|
export default function EventsPage() {
|
||||||
|
const { t } = useTranslation('management');
|
||||||
|
const { t: tCommon } = useTranslation('common');
|
||||||
const [rows, setRows] = React.useState<TenantEvent[]>([]);
|
const [rows, setRows] = React.useState<TenantEvent[]>([]);
|
||||||
const [loading, setLoading] = React.useState(true);
|
const [loading, setLoading] = React.useState(true);
|
||||||
const [error, setError] = React.useState<string | null>(null);
|
const [error, setError] = React.useState<string | null>(null);
|
||||||
@@ -34,7 +39,7 @@ export default function EventsPage() {
|
|||||||
setRows(await getEvents());
|
setRows(await getEvents());
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!isAuthError(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 {
|
} finally {
|
||||||
setLoading(false);
|
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"
|
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'))}
|
onClick={() => navigate(adminPath('/events/new'))}
|
||||||
>
|
>
|
||||||
<Plus className="h-4 w-4" /> Neues Event
|
<Plus className="h-4 w-4" /> {t('events.list.actions.create', 'Neues Event')}
|
||||||
</Button>
|
</Button>
|
||||||
<Link to={ADMIN_SETTINGS_PATH}>
|
<Link to={ADMIN_SETTINGS_PATH}>
|
||||||
<Button variant="outline" className="border-pink-200 text-pink-600 hover:bg-pink-50">
|
<Button variant="outline" className="border-pink-200 text-pink-600 hover:bg-pink-50">
|
||||||
<Settings className="h-4 w-4" /> Einstellungen
|
<Settings className="h-4 w-4" /> {t('events.list.actions.settings', 'Einstellungen')}
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
</>
|
</>
|
||||||
@@ -60,8 +65,8 @@ export default function EventsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<AdminLayout
|
<AdminLayout
|
||||||
title="Deine Events"
|
title={t('events.list.title', 'Deine Events')}
|
||||||
subtitle="Plane Momente, die in Erinnerung bleiben. Hier verwaltest du alles rund um deine Veranstaltungen."
|
subtitle={t('events.list.subtitle', 'Plane Momente, die in Erinnerung bleiben. Hier verwaltest du alles rund um deine Veranstaltungen.')}
|
||||||
actions={actions}
|
actions={actions}
|
||||||
>
|
>
|
||||||
{error && (
|
{error && (
|
||||||
@@ -74,15 +79,15 @@ export default function EventsPage() {
|
|||||||
<Card className="border-0 bg-white/80 shadow-xl shadow-pink-100/60">
|
<Card className="border-0 bg-white/80 shadow-xl shadow-pink-100/60">
|
||||||
<CardHeader className="flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between">
|
<CardHeader className="flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<CardTitle className="text-xl font-semibold text-slate-900">Übersicht</CardTitle>
|
<CardTitle className="text-xl font-semibold text-slate-900">{t('events.list.overview.title', 'Übersicht')}</CardTitle>
|
||||||
<CardDescription className="text-slate-600">
|
<CardDescription className="text-slate-600">
|
||||||
{rows.length === 0
|
{rows.length === 0
|
||||||
? 'Noch keine Events - starte jetzt und lege dein erstes Event an.'
|
? t('events.list.overview.empty', 'Noch keine Events - starte jetzt und lege dein erstes Event an.')
|
||||||
: `${rows.length} ${rows.length === 1 ? 'Event' : 'Events'} aktiv verwaltet.`}
|
: t('events.list.overview.count', '{{count}} Events aktiv verwaltet.', { count: rows.length })}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 text-sm text-pink-600">
|
<div className="flex items-center gap-2 text-sm text-pink-600">
|
||||||
<Sparkles className="h-4 w-4" /> Tenant Dashboard
|
<Sparkles className="h-4 w-4" /> {t('events.list.badge.dashboard', 'Tenant Dashboard')}
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
@@ -93,7 +98,7 @@ export default function EventsPage() {
|
|||||||
) : (
|
) : (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{rows.map((event) => (
|
{rows.map((event) => (
|
||||||
<EventCard key={event.id} event={event} />
|
<EventCard key={event.id} event={event} translateCommon={tCommon} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -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, unknown>) => string;
|
||||||
|
}) {
|
||||||
const slug = event.slug;
|
const slug = event.slug;
|
||||||
const isPublished = event.status === 'published';
|
const isPublished = event.status === 'published';
|
||||||
const photoCount = event.photo_count ?? 0;
|
const photoCount = event.photo_count ?? 0;
|
||||||
const likeCount = event.like_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 (
|
return (
|
||||||
<div className="rounded-2xl border border-white/80 bg-white/90 p-5 shadow-md shadow-pink-200/40 transition-transform hover:-translate-y-0.5 hover:shadow-lg">
|
<div className="rounded-2xl border border-white/80 bg-white/90 p-5 shadow-md shadow-pink-200/40 transition-transform hover:-translate-y-0.5 hover:shadow-lg">
|
||||||
|
{limitWarnings.length > 0 && (
|
||||||
|
<div className="mb-3 space-y-1">
|
||||||
|
{limitWarnings.map((warning) => (
|
||||||
|
<Alert
|
||||||
|
key={warning.id}
|
||||||
|
variant={warning.tone === 'danger' ? 'destructive' : 'default'}
|
||||||
|
className={warning.tone === 'warning' ? 'border-amber-400/40 bg-amber-50 text-amber-900' : undefined}
|
||||||
|
>
|
||||||
|
<AlertDescription className="flex items-center gap-2 text-xs">
|
||||||
|
<AlertTriangle className="h-4 w-4" />
|
||||||
|
{warning.message}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<h3 className="text-lg font-semibold text-slate-900">{renderName(event.name)}</h3>
|
<h3 className="text-lg font-semibold text-slate-900">{renderName(event.name)}</h3>
|
||||||
|
|||||||
@@ -1,23 +1,56 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { LogOut, Palette } from 'lucide-react';
|
import { AlertTriangle, LogOut, Palette } from 'lucide-react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
import AppearanceToggleDropdown from '@/components/appearance-dropdown';
|
import AppearanceToggleDropdown from '@/components/appearance-dropdown';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
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 { AdminLayout } from '../components/AdminLayout';
|
||||||
import { useAuth } from '../auth/context';
|
import { useAuth } from '../auth/context';
|
||||||
import { ADMIN_EVENTS_PATH, ADMIN_LOGIN_PATH } from '../constants';
|
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() {
|
export default function SettingsPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { user, logout } = useAuth();
|
const { user, logout } = useAuth();
|
||||||
|
const { t } = useTranslation('management');
|
||||||
|
|
||||||
|
const [preferences, setPreferences] = React.useState<NotificationPreferences | null>(null);
|
||||||
|
const [defaults, setDefaults] = React.useState<NotificationPreferences>({});
|
||||||
|
const [loadingNotifications, setLoadingNotifications] = React.useState(true);
|
||||||
|
const [savingNotifications, setSavingNotifications] = React.useState(false);
|
||||||
|
const [notificationError, setNotificationError] = React.useState<string | null>(null);
|
||||||
|
const [notificationMeta, setNotificationMeta] = React.useState<NotificationPreferencesMeta | null>(null);
|
||||||
|
|
||||||
function handleLogout() {
|
function handleLogout() {
|
||||||
logout({ redirect: ADMIN_LOGIN_PATH });
|
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 = (
|
const actions = (
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -75,7 +108,225 @@ export default function SettingsPage() {
|
|||||||
</section>
|
</section>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<Card className="mt-8 max-w-3xl border-0 bg-white/85 shadow-xl shadow-pink-100/60">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-xl text-slate-900">
|
||||||
|
<AlertTriangle className="h-5 w-5 text-pink-500" />
|
||||||
|
{t('settings.notifications.title', 'Benachrichtigungen')}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="text-sm text-slate-600">
|
||||||
|
{t('settings.notifications.description', 'Lege fest, für welche Ereignisse wir dich per E-Mail informieren.')}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
{notificationError && (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertDescription>{notificationError}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loadingNotifications ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{Array.from({ length: 5 }).map((_, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="h-12 animate-pulse rounded-xl bg-gradient-to-r from-white/30 via-white/60 to-white/30"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : preferences ? (
|
||||||
|
<NotificationPreferencesForm
|
||||||
|
preferences={preferences}
|
||||||
|
defaults={defaults}
|
||||||
|
meta={notificationMeta}
|
||||||
|
onChange={(next) => setPreferences(next)}
|
||||||
|
onReset={() => setPreferences(defaults)}
|
||||||
|
onSave={async () => {
|
||||||
|
if (!preferences) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
setSavingNotifications(true);
|
||||||
|
const updated = await updateNotificationPreferences(preferences);
|
||||||
|
setPreferences(updated.preferences);
|
||||||
|
if (updated.defaults && Object.keys(updated.defaults).length > 0) {
|
||||||
|
setDefaults(updated.defaults);
|
||||||
|
}
|
||||||
|
if (updated.meta) {
|
||||||
|
setNotificationMeta(updated.meta);
|
||||||
|
}
|
||||||
|
setNotificationError(null);
|
||||||
|
} catch (error) {
|
||||||
|
setNotificationError(
|
||||||
|
getApiErrorMessage(error, t('settings.notifications.errorSave', 'Speichern fehlgeschlagen. Bitte versuche es erneut.')),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setSavingNotifications(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
saving={savingNotifications}
|
||||||
|
translate={t}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</AdminLayout>
|
</AdminLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function NotificationPreferencesForm({
|
||||||
|
preferences,
|
||||||
|
defaults,
|
||||||
|
meta,
|
||||||
|
onChange,
|
||||||
|
onReset,
|
||||||
|
onSave,
|
||||||
|
saving,
|
||||||
|
translate,
|
||||||
|
}: {
|
||||||
|
preferences: NotificationPreferences;
|
||||||
|
defaults: NotificationPreferences;
|
||||||
|
meta: NotificationPreferencesMeta | null;
|
||||||
|
onChange: (next: NotificationPreferences) => void;
|
||||||
|
onReset: () => void;
|
||||||
|
onSave: () => Promise<void>;
|
||||||
|
saving: boolean;
|
||||||
|
translate: (key: string, options?: Record<string, unknown>) => string;
|
||||||
|
}) {
|
||||||
|
const items = React.useMemo(() => buildPreferenceMeta(translate), [translate]);
|
||||||
|
const locale = typeof window !== 'undefined' ? window.navigator.language : 'de-DE';
|
||||||
|
const creditText = React.useMemo(() => {
|
||||||
|
if (!meta) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (meta.credit_warning_sent_at) {
|
||||||
|
const date = formatDateTime(meta.credit_warning_sent_at, locale);
|
||||||
|
|
||||||
|
return translate('settings.notifications.meta.creditLast', 'Letzte Credit-Warnung: {{date}}', {
|
||||||
|
date,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return translate('settings.notifications.meta.creditNever', 'Noch keine Credit-Warnung versendet.');
|
||||||
|
}, [meta, translate, locale]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-3">
|
||||||
|
{items.map((item) => {
|
||||||
|
const checked = preferences[item.key] ?? defaults[item.key] ?? true;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={item.key} className="flex items-start justify-between gap-4 rounded-xl border border-pink-100 bg-white/70 p-4 shadow-sm">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h3 className="text-sm font-semibold text-slate-900">{item.label}</h3>
|
||||||
|
<p className="text-sm text-slate-600">{item.description}</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={checked}
|
||||||
|
onCheckedChange={(value) => onChange({ ...preferences, [item.key]: Boolean(value) })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<Button onClick={onSave} disabled={saving} className="bg-gradient-to-r from-pink-500 via-fuchsia-500 to-purple-500 text-white">
|
||||||
|
{saving ? 'Speichern...' : translate('settings.notifications.actions.save', 'Speichern')}
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" onClick={onReset} disabled={saving}>
|
||||||
|
{translate('settings.notifications.actions.reset', 'Auf Standard setzen')}
|
||||||
|
</Button>
|
||||||
|
<span className="text-xs text-slate-500">
|
||||||
|
{translate('settings.notifications.hint', 'Du kannst Benachrichtigungen jederzeit wieder aktivieren.')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{creditText && <p className="text-xs text-slate-500">{creditText}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPreferenceMeta(
|
||||||
|
translate: (key: string, options?: Record<string, unknown>) => 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -188,6 +188,14 @@ export const messages: Record<LocaleCode, NestedMessages> = {
|
|||||||
title: 'Nicht gefunden',
|
title: 'Nicht gefunden',
|
||||||
description: 'Die Seite konnte nicht gefunden werden.',
|
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: {
|
galleryPublic: {
|
||||||
title: 'Galerie',
|
title: 'Galerie',
|
||||||
loading: 'Galerie wird geladen ...',
|
loading: 'Galerie wird geladen ...',
|
||||||
@@ -298,6 +306,63 @@ export const messages: Record<LocaleCode, NestedMessages> = {
|
|||||||
limitReached: 'Upload-Limit erreicht ({used} / {max} Fotos). Bitte kontaktiere die Veranstalter für ein Upgrade.',
|
limitReached: 'Upload-Limit erreicht ({used} / {max} Fotos). Bitte kontaktiere die Veranstalter für ein Upgrade.',
|
||||||
limitUnlimited: 'unbegrenzt',
|
limitUnlimited: 'unbegrenzt',
|
||||||
limitWarning: 'Nur noch {remaining} von {max} Fotos möglich. Bitte kontaktiere die Veranstalter für ein Upgrade.',
|
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: {
|
errors: {
|
||||||
photoLimit: 'Upload-Limit erreicht. Bitte kontaktiere die Veranstalter für ein Upgrade.',
|
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.',
|
deviceLimit: 'Dieses Gerät hat das Upload-Limit erreicht. Bitte wende dich an die Veranstalter.',
|
||||||
@@ -551,6 +616,14 @@ export const messages: Record<LocaleCode, NestedMessages> = {
|
|||||||
title: 'Not found',
|
title: 'Not found',
|
||||||
description: 'We could not find the page you requested.',
|
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: {
|
galleryPublic: {
|
||||||
title: 'Gallery',
|
title: 'Gallery',
|
||||||
loading: 'Loading gallery ...',
|
loading: 'Loading gallery ...',
|
||||||
@@ -661,6 +734,63 @@ export const messages: Record<LocaleCode, NestedMessages> = {
|
|||||||
limitReached: 'Upload limit reached ({used} / {max} photos). Contact the organizers for an upgrade.',
|
limitReached: 'Upload limit reached ({used} / {max} photos). Contact the organizers for an upgrade.',
|
||||||
limitUnlimited: 'unlimited',
|
limitUnlimited: 'unlimited',
|
||||||
limitWarning: 'Only {remaining} of {max} photos left. Please contact the organizers for an upgrade.',
|
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: {
|
errors: {
|
||||||
photoLimit: 'Upload limit reached. Contact the organizers for an upgrade.',
|
photoLimit: 'Upload limit reached. Contact the organizers for an upgrade.',
|
||||||
deviceLimit: 'This device reached its upload limit. Please contact the organizers.',
|
deviceLimit: 'This device reached its upload limit. Please contact the organizers.',
|
||||||
|
|||||||
86
resources/js/guest/lib/__tests__/limitSummaries.test.ts
Normal file
86
resources/js/guest/lib/__tests__/limitSummaries.test.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import type { EventPackageLimits } from '../../services/eventApi';
|
||||||
|
import { buildLimitSummaries } from '../limitSummaries';
|
||||||
|
|
||||||
|
const translations = new Map<string, string>([
|
||||||
|
['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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
49
resources/js/guest/lib/__tests__/uploadErrorDialog.test.ts
Normal file
49
resources/js/guest/lib/__tests__/uploadErrorDialog.test.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { resolveUploadErrorDialog } from '../uploadErrorDialog';
|
||||||
|
|
||||||
|
const translations = new Map<string, string>([
|
||||||
|
['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.');
|
||||||
|
});
|
||||||
|
});
|
||||||
107
resources/js/guest/lib/limitSummaries.ts
Normal file
107
resources/js/guest/lib/limitSummaries.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
94
resources/js/guest/lib/uploadErrorDialog.ts
Normal file
94
resources/js/guest/lib/uploadErrorDialog.ts
Normal file
@@ -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, number | string | undefined>): 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<string, unknown> | 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'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,18 +1,21 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { Page } from './_util';
|
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 { 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 { Alert, AlertDescription } from '@/components/ui/alert';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
import FiltersBar, { type GalleryFilter } from '../components/FiltersBar';
|
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 { likePhoto } from '../services/photosApi';
|
||||||
import PhotoLightbox from './PhotoLightbox';
|
import PhotoLightbox from './PhotoLightbox';
|
||||||
import { fetchEvent, getEventPackage, fetchStats, type EventData, type EventPackage, type EventStats } from '../services/eventApi';
|
import { fetchEvent, getEventPackage, fetchStats, type EventData, type EventPackage, type EventStats } from '../services/eventApi';
|
||||||
|
import { useTranslation } from '../i18n/useTranslation';
|
||||||
|
|
||||||
export default function GalleryPage() {
|
export default function GalleryPage() {
|
||||||
const { token } = useParams<{ token?: string }>();
|
const { token } = useParams<{ token?: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
const { photos, loading, newCount, acknowledgeNew } = usePollGalleryDelta(token ?? '');
|
const { photos, loading, newCount, acknowledgeNew } = usePollGalleryDelta(token ?? '');
|
||||||
const [filter, setFilter] = React.useState<GalleryFilter>('latest');
|
const [filter, setFilter] = React.useState<GalleryFilter>('latest');
|
||||||
const [currentPhotoIndex, setCurrentPhotoIndex] = React.useState<number | null>(null);
|
const [currentPhotoIndex, setCurrentPhotoIndex] = React.useState<number | null>(null);
|
||||||
@@ -22,6 +25,8 @@ export default function GalleryPage() {
|
|||||||
const [eventPackage, setEventPackage] = useState<EventPackage | null>(null);
|
const [eventPackage, setEventPackage] = useState<EventPackage | null>(null);
|
||||||
const [stats, setStats] = useState<EventStats | null>(null);
|
const [stats, setStats] = useState<EventStats | null>(null);
|
||||||
const [eventLoading, setEventLoading] = useState(true);
|
const [eventLoading, setEventLoading] = useState(true);
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const locale = typeof window !== 'undefined' ? window.navigator.language : 'de-DE';
|
||||||
|
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const photoIdParam = searchParams.get('photoId');
|
const photoIdParam = searchParams.get('photoId');
|
||||||
@@ -82,6 +87,109 @@ export default function GalleryPage() {
|
|||||||
const [liked, setLiked] = React.useState<Set<number>>(new Set());
|
const [liked, setLiked] = React.useState<Set<number>>(new Set());
|
||||||
const [counts, setCounts] = React.useState<Record<number, number>>({});
|
const [counts, setCounts] = React.useState<Record<number, number>>({});
|
||||||
|
|
||||||
|
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) {
|
async function onLike(id: number) {
|
||||||
if (liked.has(id)) return;
|
if (liked.has(id)) return;
|
||||||
setLiked(new Set(liked).add(id));
|
setLiked(new Set(liked).add(id));
|
||||||
@@ -111,17 +219,62 @@ export default function GalleryPage() {
|
|||||||
<Page title="Galerie">
|
<Page title="Galerie">
|
||||||
<Card className="mx-4 mb-4">
|
<Card className="mx-4 mb-4">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<ImageIcon className="h-6 w-6" />
|
<CardTitle className="flex flex-wrap items-center gap-2">
|
||||||
Galerie: {event?.name || 'Event'}
|
<ImageIcon className="h-6 w-6" />
|
||||||
</CardTitle>
|
<span>Galerie: {event?.name || 'Event'}</span>
|
||||||
</CardHeader>
|
{galleryCountdown && (
|
||||||
<CardContent className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
<Badge
|
||||||
<div className="text-center">
|
variant="secondary"
|
||||||
<Users className="h-8 w-8 mx-auto mb-2 text-blue-500" />
|
className={galleryCountdown.tone === 'danger'
|
||||||
<p className="font-semibold">Online Gäste</p>
|
? 'border-rose-200 bg-rose-100 text-rose-700'
|
||||||
<p className="text-2xl">{stats?.onlineGuests || 0}</p>
|
: 'border-amber-200 bg-amber-100 text-amber-700'}
|
||||||
|
>
|
||||||
|
{galleryCountdown.label}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</CardTitle>
|
||||||
|
{galleryCountdown?.cta && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant={galleryCountdown.tone === 'danger' ? 'destructive' : 'outline'}
|
||||||
|
onClick={handleCountdownCta}
|
||||||
|
disabled={!token}
|
||||||
|
>
|
||||||
|
{galleryCountdown.cta.label}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{galleryCountdown && (
|
||||||
|
<CardDescription className={galleryCountdown.tone === 'danger' ? 'text-rose-600' : 'text-amber-600'}>
|
||||||
|
{galleryCountdown.description}
|
||||||
|
</CardDescription>
|
||||||
|
)}
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{packageWarnings.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{packageWarnings.map((warning) => (
|
||||||
|
<Alert
|
||||||
|
key={warning.id}
|
||||||
|
variant={warning.tone === 'danger' ? 'destructive' : 'default'}
|
||||||
|
className={warning.tone === 'warning' ? 'border-amber-400/40 bg-amber-50 text-amber-900' : undefined}
|
||||||
|
>
|
||||||
|
<AlertDescription className="flex items-center gap-2 text-sm">
|
||||||
|
<AlertTriangle className="h-4 w-4" />
|
||||||
|
{warning.message}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
|
||||||
|
<div className="text-center">
|
||||||
|
<Users className="h-8 w-8 mx-auto mb-2 text-blue-500" />
|
||||||
|
<p className="font-semibold">Online Gäste</p>
|
||||||
|
<p className="text-2xl">{stats?.onlineGuests || 0}</p>
|
||||||
|
</div>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<Heart className="h-8 w-8 mx-auto mb-2 text-red-500" />
|
<Heart className="h-8 w-8 mx-auto mb-2 text-red-500" />
|
||||||
<p className="font-semibold">Gesamt Likes</p>
|
<p className="font-semibold">Gesamt Likes</p>
|
||||||
@@ -133,24 +286,38 @@ export default function GalleryPage() {
|
|||||||
<p className="text-2xl">{photos.length}</p>
|
<p className="text-2xl">{photos.length}</p>
|
||||||
</div>
|
</div>
|
||||||
{eventPackage && (
|
{eventPackage && (
|
||||||
<div className="text-center">
|
<div className="rounded-2xl border border-gray-200 bg-white/70 p-4 text-center">
|
||||||
<PackageIcon className="h-8 w-8 mx-auto mb-2 text-purple-500" />
|
<PackageIcon className="mx-auto mb-2 h-8 w-8 text-purple-500" />
|
||||||
<p className="font-semibold">Package</p>
|
<p className="font-semibold">Package</p>
|
||||||
<p className="text-sm">{eventPackage.package.name}</p>
|
<p className="text-sm text-gray-600">{eventPackage.package?.name ?? '—'}</p>
|
||||||
<div className="w-full bg-gray-200 rounded-full h-2 mt-1">
|
{photoLimits?.limit ? (
|
||||||
<div
|
<>
|
||||||
className="bg-blue-600 h-2 rounded-full"
|
<div className="mt-3 h-2 w-full overflow-hidden rounded-full bg-gray-200">
|
||||||
style={{ width: `${(eventPackage.used_photos / eventPackage.package.max_photos) * 100}%` }}
|
<div
|
||||||
></div>
|
className={`h-2 rounded-full ${photoLimits.state === 'limit_reached' ? 'bg-red-500' : photoLimits.state === 'warning' ? 'bg-amber-500' : 'bg-blue-600'}`}
|
||||||
</div>
|
style={{ width: `${Math.min(100, Math.max(6, Math.round((photoLimits.used / photoLimits.limit) * 100))) }%` }}
|
||||||
<p className="text-xs text-gray-600 mt-1">
|
/>
|
||||||
{eventPackage.used_photos} / {eventPackage.package.max_photos} Fotos
|
</div>
|
||||||
</p>
|
<p className="mt-2 text-xs text-gray-600">
|
||||||
{new Date(eventPackage.expires_at) < new Date() && (
|
{photoLimits.used} / {photoLimits.limit} Fotos
|
||||||
<p className="text-red-600 text-xs mt-1">Abgelaufen: {new Date(eventPackage.expires_at).toLocaleDateString()}</p>
|
</p>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="mt-2 text-xs text-gray-600">{t('upload.limitUnlimited')}</p>
|
||||||
)}
|
)}
|
||||||
|
{guestLimits?.limit ? (
|
||||||
|
<p className="mt-2 text-xs text-gray-500">
|
||||||
|
Gäste: {guestLimits.used} / {guestLimits.limit}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{galleryLimits?.expires_at ? (
|
||||||
|
<p className="mt-2 text-xs text-gray-500">
|
||||||
|
Galerie bis {formatDate(galleryLimits.expires_at)}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,14 @@ import BottomNav from '../components/BottomNav';
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
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 { uploadPhoto, type UploadError } from '../services/photosApi';
|
||||||
import { useGuestTaskProgress } from '../hooks/useGuestTaskProgress';
|
import { useGuestTaskProgress } from '../hooks/useGuestTaskProgress';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
@@ -22,6 +30,8 @@ import {
|
|||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { getEventPackage, type EventPackage } from '../services/eventApi';
|
import { getEventPackage, type EventPackage } from '../services/eventApi';
|
||||||
import { useTranslation } from '../i18n/useTranslation';
|
import { useTranslation } from '../i18n/useTranslation';
|
||||||
|
import { buildLimitSummaries, type LimitSummaryCard } from '../lib/limitSummaries';
|
||||||
|
import { resolveUploadErrorDialog, type UploadErrorDialog } from '../lib/uploadErrorDialog';
|
||||||
|
|
||||||
interface Task {
|
interface Task {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -65,19 +75,6 @@ function getErrorName(error: unknown): string | undefined {
|
|||||||
return 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 = {
|
const DEFAULT_PREFS: CameraPreferences = {
|
||||||
facingMode: 'environment',
|
facingMode: 'environment',
|
||||||
countdownSeconds: 3,
|
countdownSeconds: 3,
|
||||||
@@ -87,6 +84,24 @@ const DEFAULT_PREFS: CameraPreferences = {
|
|||||||
flashPreferred: false,
|
flashPreferred: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const LIMIT_CARD_STYLES: Record<LimitSummaryCard['tone'], { card: string; badge: string; bar: string }> = {
|
||||||
|
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() {
|
export default function UploadPage() {
|
||||||
const { token } = useParams<{ token: string }>();
|
const { token } = useParams<{ token: string }>();
|
||||||
const eventKey = token ?? '';
|
const eventKey = token ?? '';
|
||||||
@@ -115,12 +130,19 @@ export default function UploadPage() {
|
|||||||
const [statusMessage, setStatusMessage] = useState<string>('');
|
const [statusMessage, setStatusMessage] = useState<string>('');
|
||||||
|
|
||||||
const [reviewPhoto, setReviewPhoto] = useState<{ dataUrl: string; file: File } | null>(null);
|
const [reviewPhoto, setReviewPhoto] = useState<{ dataUrl: string; file: File } | null>(null);
|
||||||
const [uploadProgress, setUploadProgress] = useState(0);
|
const [uploadProgress, setUploadProgress] = useState(0);
|
||||||
const [uploadError, setUploadError] = useState<string | null>(null);
|
const [uploadError, setUploadError] = useState<string | null>(null);
|
||||||
const [uploadWarning, setUploadWarning] = useState<string | null>(null);
|
const [uploadWarning, setUploadWarning] = useState<string | null>(null);
|
||||||
|
|
||||||
const [eventPackage, setEventPackage] = useState<EventPackage | null>(null);
|
const [errorDialog, setErrorDialog] = useState<UploadErrorDialog | null>(null);
|
||||||
const [canUpload, setCanUpload] = useState(true);
|
|
||||||
|
const [eventPackage, setEventPackage] = useState<EventPackage | null>(null);
|
||||||
|
const [canUpload, setCanUpload] = useState(true);
|
||||||
|
|
||||||
|
const limitCards = useMemo<LimitSummaryCard[]>(
|
||||||
|
() => buildLimitSummaries(eventPackage?.limits ?? null, t),
|
||||||
|
[eventPackage?.limits, t]
|
||||||
|
);
|
||||||
|
|
||||||
const [showPrimer, setShowPrimer] = useState<boolean>(() => {
|
const [showPrimer, setShowPrimer] = useState<boolean>(() => {
|
||||||
if (typeof window === 'undefined') return false;
|
if (typeof window === 'undefined') return false;
|
||||||
@@ -249,38 +271,55 @@ export default function UploadPage() {
|
|||||||
try {
|
try {
|
||||||
const pkg = await getEventPackage(eventKey);
|
const pkg = await getEventPackage(eventKey);
|
||||||
setEventPackage(pkg);
|
setEventPackage(pkg);
|
||||||
if (pkg && pkg.used_photos >= pkg.package.max_photos) {
|
if (!pkg) {
|
||||||
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 {
|
|
||||||
setCanUpload(true);
|
setCanUpload(true);
|
||||||
setUploadError(null);
|
setUploadError(null);
|
||||||
|
setUploadWarning(null);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pkg?.package?.max_photos) {
|
const photoLimits = pkg.limits?.photos ?? null;
|
||||||
const max = Number(pkg.package.max_photos);
|
const galleryLimits = pkg.limits?.gallery ?? null;
|
||||||
const used = Number(pkg.used_photos ?? 0);
|
|
||||||
const ratio = max > 0 ? used / max : 0;
|
let canUploadCurrent = pkg.limits?.can_upload_photos ?? true;
|
||||||
if (ratio >= 0.8 && ratio < 1) {
|
let errorMessage: string | null = null;
|
||||||
const remaining = Math.max(0, max - used);
|
const warnings: string[] = [];
|
||||||
setUploadWarning(
|
|
||||||
t('upload.limitWarning')
|
if (photoLimits?.state === 'limit_reached') {
|
||||||
.replace('{remaining}', `${remaining}`)
|
canUploadCurrent = false;
|
||||||
.replace('{max}', `${max}`)
|
if (typeof photoLimits.limit === 'number') {
|
||||||
);
|
errorMessage = t('upload.limitReached')
|
||||||
|
.replace('{used}', `${photoLimits.used}`)
|
||||||
|
.replace('{max}', `${photoLimits.limit}`);
|
||||||
} else {
|
} else {
|
||||||
setUploadWarning(null);
|
errorMessage = t('upload.errors.photoLimit');
|
||||||
}
|
}
|
||||||
} else {
|
} else if (
|
||||||
setUploadWarning(null);
|
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) {
|
} catch (err) {
|
||||||
console.error('Failed to check package limits', err);
|
console.error('Failed to check package limits', err);
|
||||||
setCanUpload(false);
|
setCanUpload(false);
|
||||||
@@ -543,39 +582,20 @@ export default function UploadPage() {
|
|||||||
const uploadErr = error as UploadError;
|
const uploadErr = error as UploadError;
|
||||||
setUploadWarning(null);
|
setUploadWarning(null);
|
||||||
const meta = uploadErr.meta as Record<string, unknown> | undefined;
|
const meta = uploadErr.meta as Record<string, unknown> | undefined;
|
||||||
switch (uploadErr.code) {
|
const dialog = resolveUploadErrorDialog(uploadErr.code, meta, t);
|
||||||
case 'photo_limit_exceeded': {
|
setErrorDialog(dialog);
|
||||||
if (meta && typeof meta.used === 'number' && typeof meta.limit === 'number') {
|
setUploadError(dialog.description);
|
||||||
const limitText = t('upload.limitReached')
|
|
||||||
.replace('{used}', `${meta.used}`)
|
if (
|
||||||
.replace('{max}', `${meta.limit}`);
|
uploadErr.code === 'photo_limit_exceeded'
|
||||||
setUploadError(limitText);
|
|| uploadErr.code === 'upload_device_limit'
|
||||||
} else {
|
|| uploadErr.code === 'event_package_missing'
|
||||||
setUploadError(t('upload.errors.photoLimit'));
|
|| uploadErr.code === 'event_not_found'
|
||||||
}
|
|| uploadErr.code === 'gallery_expired'
|
||||||
setCanUpload(false);
|
) {
|
||||||
break;
|
setCanUpload(false);
|
||||||
}
|
|
||||||
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'));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setMode('review');
|
setMode('review');
|
||||||
} finally {
|
} finally {
|
||||||
if (uploadProgressTimerRef.current) {
|
if (uploadProgressTimerRef.current) {
|
||||||
@@ -625,6 +645,54 @@ export default function UploadPage() {
|
|||||||
}
|
}
|
||||||
}, [resetCountdownTimer]);
|
}, [resetCountdownTimer]);
|
||||||
|
|
||||||
|
const limitStatusSection = limitCards.length > 0 ? (
|
||||||
|
<section className="mx-4 mb-6 space-y-3">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-sm font-semibold uppercase tracking-wide text-slate-900 dark:text-white">
|
||||||
|
{t('upload.status.title')}
|
||||||
|
</h2>
|
||||||
|
<p className="text-xs text-slate-600 dark:text-white/70">
|
||||||
|
{t('upload.status.subtitle')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
{limitCards.map((card) => {
|
||||||
|
const styles = LIMIT_CARD_STYLES[card.tone];
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={card.id}
|
||||||
|
className={cn(
|
||||||
|
'rounded-xl border p-4 shadow-sm backdrop-blur transition-colors',
|
||||||
|
styles.card
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wide opacity-70">
|
||||||
|
{card.label}
|
||||||
|
</p>
|
||||||
|
<p className="text-lg font-semibold">{card.valueLabel}</p>
|
||||||
|
</div>
|
||||||
|
<Badge className={cn('text-[10px] font-semibold uppercase tracking-wide', styles.badge)}>
|
||||||
|
{card.badgeLabel}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
{card.progress !== null && (
|
||||||
|
<div className="mt-3 h-2 w-full overflow-hidden rounded-full bg-white/60 dark:bg-white/10">
|
||||||
|
<div
|
||||||
|
className={cn('h-full rounded-full transition-all', styles.bar)}
|
||||||
|
style={{ width: `${card.progress}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<p className="mt-3 text-sm opacity-80">{card.description}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
) : null;
|
||||||
|
|
||||||
const renderPage = (content: ReactNode, mainClassName = 'px-4 py-6') => (
|
const renderPage = (content: ReactNode, mainClassName = 'px-4 py-6') => (
|
||||||
<div className="pb-16">
|
<div className="pb-16">
|
||||||
<Header eventToken={eventKey} title={t('upload.cameraTitle')} />
|
<Header eventToken={eventKey} title={t('upload.cameraTitle')} />
|
||||||
@@ -633,16 +701,56 @@ export default function UploadPage() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const dialogToneIconClass: Record<Exclude<UploadErrorDialog['tone'], undefined>, string> = {
|
||||||
|
danger: 'text-rose-500',
|
||||||
|
warning: 'text-amber-500',
|
||||||
|
info: 'text-sky-500',
|
||||||
|
};
|
||||||
|
|
||||||
|
const errorDialogNode = (
|
||||||
|
<Dialog open={Boolean(errorDialog)} onOpenChange={(open) => { if (!open) setErrorDialog(null); }}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader className="space-y-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{errorDialog?.tone === 'info' ? (
|
||||||
|
<Info className={cn('h-5 w-5', dialogToneIconClass.info)} />
|
||||||
|
) : (
|
||||||
|
<AlertTriangle className={cn('h-5 w-5', dialogToneIconClass[errorDialog?.tone ?? 'danger'])} />
|
||||||
|
)}
|
||||||
|
<DialogTitle>{errorDialog?.title ?? ''}</DialogTitle>
|
||||||
|
</div>
|
||||||
|
<DialogDescription>{errorDialog?.description ?? ''}</DialogDescription>
|
||||||
|
{errorDialog?.hint ? (
|
||||||
|
<p className="text-sm text-muted-foreground">{errorDialog.hint}</p>
|
||||||
|
) : null}
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button onClick={() => setErrorDialog(null)}>{t('upload.dialogs.close')}</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderWithDialog = (content: ReactNode, mainClassName = 'px-4 py-6') => (
|
||||||
|
<>
|
||||||
|
{renderPage(content, mainClassName)}
|
||||||
|
{errorDialogNode}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
if (!supportsCamera && !task) {
|
if (!supportsCamera && !task) {
|
||||||
return renderPage(
|
return renderWithDialog(
|
||||||
<Alert>
|
<>
|
||||||
<AlertDescription>{t('upload.cameraUnsupported.message')}</AlertDescription>
|
{limitStatusSection}
|
||||||
</Alert>
|
<Alert>
|
||||||
|
<AlertDescription>{t('upload.cameraUnsupported.message')}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (loadingTask) {
|
if (loadingTask) {
|
||||||
return renderPage(
|
return renderWithDialog(
|
||||||
<div className="flex flex-col items-center justify-center gap-4 text-center">
|
<div className="flex flex-col items-center justify-center gap-4 text-center">
|
||||||
<Loader2 className="h-10 w-10 animate-spin text-pink-500" />
|
<Loader2 className="h-10 w-10 animate-spin text-pink-500" />
|
||||||
<p className="text-sm text-muted-foreground">{t('upload.preparing')}</p>
|
<p className="text-sm text-muted-foreground">{t('upload.preparing')}</p>
|
||||||
@@ -651,15 +759,18 @@ export default function UploadPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!canUpload) {
|
if (!canUpload) {
|
||||||
return renderPage(
|
return renderWithDialog(
|
||||||
<Alert variant="destructive">
|
<>
|
||||||
<AlertTriangle className="h-4 w-4" />
|
{limitStatusSection}
|
||||||
<AlertDescription>
|
<Alert variant="destructive">
|
||||||
{t('upload.limitReached')
|
<AlertTriangle className="h-4 w-4" />
|
||||||
.replace('{used}', `${eventPackage?.used_photos || 0}`)
|
<AlertDescription>
|
||||||
.replace('{max}', `${eventPackage?.package.max_photos || 0}`)}
|
{t('upload.limitReached')
|
||||||
</AlertDescription>
|
.replace('{used}', `${eventPackage?.used_photos || 0}`)
|
||||||
</Alert>
|
.replace('{max}', `${eventPackage?.package.max_photos || 0}`)}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -711,13 +822,14 @@ export default function UploadPage() {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
return renderPage(
|
return renderWithDialog(
|
||||||
<>
|
<>
|
||||||
<div className="absolute left-0 right-0 top-0" aria-hidden="true">
|
<div className="absolute left-0 right-0 top-0" aria-hidden="true">
|
||||||
{renderPrimer()}
|
{renderPrimer()}
|
||||||
</div>
|
</div>
|
||||||
<div className="pt-32" />
|
<div className="pt-32" />
|
||||||
{permissionState !== 'granted' && renderPermissionNotice()}
|
{permissionState !== 'granted' && renderPermissionNotice()}
|
||||||
|
{limitStatusSection}
|
||||||
|
|
||||||
<section className="relative mx-4 overflow-hidden rounded-2xl border border-white/10 bg-black text-white shadow-xl">
|
<section className="relative mx-4 overflow-hidden rounded-2xl border border-white/10 bg-black text-white shadow-xl">
|
||||||
<div className="relative aspect-[3/4] sm:aspect-video">
|
<div className="relative aspect-[3/4] sm:aspect-video">
|
||||||
|
|||||||
@@ -19,13 +19,48 @@ export interface PackageData {
|
|||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
max_photos: number;
|
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 {
|
export interface EventPackage {
|
||||||
id: number;
|
id: number;
|
||||||
|
event_id?: number;
|
||||||
|
package_id?: number;
|
||||||
used_photos: number;
|
used_photos: number;
|
||||||
expires_at: string;
|
used_guests?: number;
|
||||||
package: PackageData;
|
expires_at: string | null;
|
||||||
|
package: PackageData | null;
|
||||||
|
limits: EventPackageLimits | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EventStats {
|
export interface EventStats {
|
||||||
@@ -39,6 +74,8 @@ export type FetchEventErrorCode =
|
|||||||
| 'token_expired'
|
| 'token_expired'
|
||||||
| 'token_revoked'
|
| 'token_revoked'
|
||||||
| 'token_rate_limited'
|
| 'token_rate_limited'
|
||||||
|
| 'access_rate_limited'
|
||||||
|
| 'gallery_expired'
|
||||||
| 'event_not_public'
|
| 'event_not_public'
|
||||||
| 'network_error'
|
| 'network_error'
|
||||||
| 'server_error'
|
| 'server_error'
|
||||||
@@ -195,5 +232,9 @@ export async function getEventPackage(eventToken: string): Promise<EventPackage
|
|||||||
if (res.status === 404) return null;
|
if (res.status === 404) return null;
|
||||||
throw new Error('Failed to load event package');
|
throw new Error('Failed to load event package');
|
||||||
}
|
}
|
||||||
return await res.json();
|
const payload = await res.json();
|
||||||
|
return {
|
||||||
|
...payload,
|
||||||
|
limits: payload?.limits ?? null,
|
||||||
|
} as EventPackage;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ const Home: React.FC<Props> = ({ packages }) => {
|
|||||||
</div>
|
</div>
|
||||||
<div className="md:w-1/2">
|
<div className="md:w-1/2">
|
||||||
<img
|
<img
|
||||||
src="https://via.placeholder.com/600x400/FFB6C1/FFFFFF?text=Fotospiel+Hero"
|
src="/joyous_wedding_guests_posing.jpg"
|
||||||
alt={t('home.hero_image_alt')}
|
alt={t('home.hero_image_alt')}
|
||||||
className="w-full h-auto rounded-lg shadow-lg"
|
className="w-full h-auto rounded-lg shadow-lg"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ Route::prefix('v1')->name('api.v1.')->group(function () {
|
|||||||
Route::middleware('throttle:100,1')->group(function () {
|
Route::middleware('throttle:100,1')->group(function () {
|
||||||
Route::get('/events/{token}', [EventPublicController::class, 'event'])->name('events.show');
|
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}/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}/achievements', [EventPublicController::class, 'achievements'])->name('events.achievements');
|
||||||
Route::get('/events/{token}/emotions', [EventPublicController::class, 'emotions'])->name('events.emotions');
|
Route::get('/events/{token}/emotions', [EventPublicController::class, 'emotions'])->name('events.emotions');
|
||||||
Route::get('/events/{token}/tasks', [EventPublicController::class, 'tasks'])->name('events.tasks');
|
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');
|
->name('tenant.settings.reset');
|
||||||
Route::post('/validate-domain', [SettingsController::class, 'validateDomain'])
|
Route::post('/validate-domain', [SettingsController::class, 'validateDomain'])
|
||||||
->name('tenant.settings.validate-domain');
|
->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 () {
|
Route::prefix('credits')->group(function () {
|
||||||
|
|||||||
31
tests/Feature/Api/Event/PublicEventErrorResponseTest.php
Normal file
31
tests/Feature/Api/Event/PublicEventErrorResponseTest.php
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature\Api\Event;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class PublicEventErrorResponseTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
public function test_invalid_join_token_returns_structured_error(): void
|
||||||
|
{
|
||||||
|
$response = $this->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'));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -131,4 +131,40 @@ class EventGuestUploadLimitTest extends TestCase
|
|||||||
$this->assertGreaterThanOrEqual(2, $thresholdJobs->count());
|
$this->assertGreaterThanOrEqual(2, $thresholdJobs->count());
|
||||||
Bus::assertDispatched(SendEventPackagePhotoLimitNotification::class);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
26
tests/Feature/Api/Tenant/TenantTokenGuardTest.php
Normal file
26
tests/Feature/Api/Tenant/TenantTokenGuardTest.php
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature\Api\Tenant;
|
||||||
|
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class TenantTokenGuardTest extends TestCase
|
||||||
|
{
|
||||||
|
public function test_missing_token_returns_structured_error(): void
|
||||||
|
{
|
||||||
|
$response = $this->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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -132,6 +132,6 @@ class EventControllerTest extends TestCase
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
$response->assertStatus(402)
|
$response->assertStatus(402)
|
||||||
->assertJson(['error' => 'Upload limit reached for this event']);
|
->assertJsonPath('error.code', 'photo_limit_exceeded');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ use App\Models\Tenant;
|
|||||||
use App\Models\TenantPackage;
|
use App\Models\TenantPackage;
|
||||||
use App\Services\Packages\PackageLimitEvaluator;
|
use App\Services\Packages\PackageLimitEvaluator;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\Config;
|
||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
|
|
||||||
class PackageLimitEvaluatorTest extends TestCase
|
class PackageLimitEvaluatorTest extends TestCase
|
||||||
@@ -125,4 +126,43 @@ class PackageLimitEvaluatorTest extends TestCase
|
|||||||
|
|
||||||
$this->assertNull($violation);
|
$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']);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user