fixed notification system and added a new tenant notifications receipt table to track read status and filter messages by scope.
This commit is contained in:
@@ -2018,6 +2018,8 @@ class EventPublicController extends BaseController
|
|||||||
[$event] = $result;
|
[$event] = $result;
|
||||||
$guestIdentifier = $this->resolveNotificationIdentifier($request);
|
$guestIdentifier = $this->resolveNotificationIdentifier($request);
|
||||||
$limit = max(1, min(50, (int) $request->integer('limit', 35)));
|
$limit = max(1, min(50, (int) $request->integer('limit', 35)));
|
||||||
|
$statusFilter = $request->string('status')->lower()->value();
|
||||||
|
$scopeFilter = $request->string('scope')->lower()->value();
|
||||||
|
|
||||||
if (! Schema::hasTable('guest_notifications')) {
|
if (! Schema::hasTable('guest_notifications')) {
|
||||||
return $this->emptyNotificationsResponse($request, $event->id, 'disabled');
|
return $this->emptyNotificationsResponse($request, $event->id, 'disabled');
|
||||||
@@ -2029,6 +2031,31 @@ class EventPublicController extends BaseController
|
|||||||
->notExpired()
|
->notExpired()
|
||||||
->visibleToGuest($guestIdentifier);
|
->visibleToGuest($guestIdentifier);
|
||||||
|
|
||||||
|
if ($statusFilter === 'unread') {
|
||||||
|
$baseQuery->where(function ($query) use ($guestIdentifier) {
|
||||||
|
$query->whereDoesntHave('receipts', fn ($receipt) => $receipt->where('guest_identifier', $guestIdentifier))
|
||||||
|
->orWhereHas('receipts', fn ($receipt) => $receipt
|
||||||
|
->where('guest_identifier', $guestIdentifier)
|
||||||
|
->where('status', GuestNotificationDeliveryStatus::NEW->value));
|
||||||
|
});
|
||||||
|
} elseif ($statusFilter === 'read') {
|
||||||
|
$baseQuery->whereHas('receipts', fn ($receipt) => $receipt
|
||||||
|
->where('guest_identifier', $guestIdentifier)
|
||||||
|
->where('status', GuestNotificationDeliveryStatus::READ->value));
|
||||||
|
} elseif ($statusFilter === 'dismissed') {
|
||||||
|
$baseQuery->whereHas('receipts', fn ($receipt) => $receipt
|
||||||
|
->where('guest_identifier', $guestIdentifier)
|
||||||
|
->where('status', GuestNotificationDeliveryStatus::DISMISSED->value));
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($scopeFilter === 'uploads') {
|
||||||
|
$baseQuery->whereIn('type', [GuestNotificationType::UPLOAD_ALERT->value, GuestNotificationType::PHOTO_ACTIVITY->value]);
|
||||||
|
} elseif ($scopeFilter === 'tips') {
|
||||||
|
$baseQuery->whereIn('type', [GuestNotificationType::SUPPORT_TIP->value, GuestNotificationType::ACHIEVEMENT_MAJOR->value]);
|
||||||
|
} elseif ($scopeFilter === 'general') {
|
||||||
|
$baseQuery->whereIn('type', [GuestNotificationType::BROADCAST->value, GuestNotificationType::FEEDBACK_REQUEST->value]);
|
||||||
|
}
|
||||||
|
|
||||||
$notifications = (clone $baseQuery)
|
$notifications = (clone $baseQuery)
|
||||||
->with(['receipts' => fn ($query) => $query->where('guest_identifier', $guestIdentifier)])
|
->with(['receipts' => fn ($query) => $query->where('guest_identifier', $guestIdentifier)])
|
||||||
->orderByDesc('priority')
|
->orderByDesc('priority')
|
||||||
|
|||||||
@@ -3,7 +3,10 @@
|
|||||||
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\NotificationMarkRequest;
|
||||||
use App\Models\TenantNotificationLog;
|
use App\Models\TenantNotificationLog;
|
||||||
|
use App\Models\TenantNotificationReceipt;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
@@ -23,8 +26,11 @@ class NotificationLogController extends Controller
|
|||||||
], 403);
|
], 403);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$user = $request->user();
|
||||||
|
|
||||||
$query = TenantNotificationLog::query()
|
$query = TenantNotificationLog::query()
|
||||||
->where('tenant_id', $tenant->id)
|
->where('tenant_id', $tenant->id)
|
||||||
|
->with('receipts')
|
||||||
->latest();
|
->latest();
|
||||||
|
|
||||||
if ($type = $request->query('type')) {
|
if ($type = $request->query('type')) {
|
||||||
@@ -35,19 +41,85 @@ class NotificationLogController extends Controller
|
|||||||
$query->where('status', $status);
|
$query->where('status', $status);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($scope = $request->query('scope')) {
|
||||||
|
$query->where(function (Builder $inner) use ($scope) {
|
||||||
|
$inner->where('type', $scope)
|
||||||
|
->orWhere(function (Builder $ctx) use ($scope) {
|
||||||
|
$ctx->whereJsonContains('context->scope', $scope);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($eventId = $request->query('event_id')) {
|
||||||
|
$query->where(function (Builder $inner) use ($eventId) {
|
||||||
|
$inner->where('context->event_id', (int) $eventId)
|
||||||
|
->orWhere('context->eventId', (int) $eventId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
$perPage = (int) $request->query('per_page', 20);
|
$perPage = (int) $request->query('per_page', 20);
|
||||||
$perPage = max(1, min($perPage, 100));
|
$perPage = max(1, min($perPage, 100));
|
||||||
|
|
||||||
$logs = $query->paginate($perPage);
|
$logs = $query->paginate($perPage);
|
||||||
|
|
||||||
|
$receipts = collect($logs->items())
|
||||||
|
->map(fn ($log) => $log->receipts ?? collect())
|
||||||
|
->flatten();
|
||||||
|
|
||||||
|
$unreadCount = $receipts
|
||||||
|
->filter(fn ($receipt) => $user && $receipt->user_id === $user->id && $receipt->status !== 'read')
|
||||||
|
->count();
|
||||||
|
|
||||||
|
$data = collect($logs->items())->map(function (TenantNotificationLog $log) use ($user) {
|
||||||
|
$receipt = $user
|
||||||
|
? $log->receipts->firstWhere('user_id', $user->id)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return array_merge($log->toArray(), [
|
||||||
|
'is_read' => $receipt ? $receipt->status === 'read' : false,
|
||||||
|
]);
|
||||||
|
})->all();
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'data' => $logs->items(),
|
'data' => $data,
|
||||||
'meta' => [
|
'meta' => [
|
||||||
'current_page' => $logs->currentPage(),
|
'current_page' => $logs->currentPage(),
|
||||||
'last_page' => $logs->lastPage(),
|
'last_page' => $logs->lastPage(),
|
||||||
'per_page' => $logs->perPage(),
|
'per_page' => $logs->perPage(),
|
||||||
'total' => $logs->total(),
|
'total' => $logs->total(),
|
||||||
|
'unread_count' => $unreadCount,
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function mark(NotificationMarkRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$tenant = $request->attributes->get('tenant') ?? $request->user()?->tenant;
|
||||||
|
|
||||||
|
if (! $tenant) {
|
||||||
|
return response()->json([
|
||||||
|
'error' => [
|
||||||
|
'code' => 'tenant_context_missing',
|
||||||
|
'title' => 'Tenant context missing',
|
||||||
|
'message' => 'Unable to resolve tenant for notification logs.',
|
||||||
|
],
|
||||||
|
], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$userId = $request->user()?->id;
|
||||||
|
$status = $request->validated('status');
|
||||||
|
$ids = $request->validated('ids');
|
||||||
|
|
||||||
|
TenantNotificationReceipt::query()
|
||||||
|
->where('tenant_id', $tenant->id)
|
||||||
|
->whereIn('notification_log_id', $ids)
|
||||||
|
->when($userId, fn ($q) => $q->where(function ($inner) use ($userId) {
|
||||||
|
$inner->whereNull('user_id')->orWhere('user_id', $userId);
|
||||||
|
}))
|
||||||
|
->update(['status' => $status]);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Notifications updated.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
22
app/Http/Requests/Tenant/NotificationMarkRequest.php
Normal file
22
app/Http/Requests/Tenant/NotificationMarkRequest.php
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Tenant;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class NotificationMarkRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'ids' => ['required', 'array'],
|
||||||
|
'ids.*' => ['integer', 'min:1'],
|
||||||
|
'status' => ['required', 'in:read,dismissed'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,9 +2,12 @@
|
|||||||
|
|
||||||
namespace App\Jobs\Concerns;
|
namespace App\Jobs\Concerns;
|
||||||
|
|
||||||
|
use App\Models\TenantNotificationLog;
|
||||||
|
use App\Models\TenantNotificationReceipt;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Services\Packages\TenantNotificationLogger;
|
use App\Services\Packages\TenantNotificationLogger;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
|
||||||
trait LogsTenantNotifications
|
trait LogsTenantNotifications
|
||||||
{
|
{
|
||||||
@@ -18,6 +21,21 @@ trait LogsTenantNotifications
|
|||||||
$this->notificationLogger()->log($tenant, $attributes);
|
$this->notificationLogger()->log($tenant, $attributes);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected function createNotificationReceipt(
|
||||||
|
Tenant $tenant,
|
||||||
|
TenantNotificationLog $log,
|
||||||
|
?int $userId,
|
||||||
|
?string $recipient
|
||||||
|
): TenantNotificationReceipt {
|
||||||
|
return TenantNotificationReceipt::query()->create([
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'notification_log_id' => $log->id,
|
||||||
|
'user_id' => $userId,
|
||||||
|
'recipient' => $recipient,
|
||||||
|
'status' => 'delivered',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
protected function dispatchToRecipients(
|
protected function dispatchToRecipients(
|
||||||
Tenant $tenant,
|
Tenant $tenant,
|
||||||
iterable $recipients,
|
iterable $recipients,
|
||||||
@@ -29,7 +47,7 @@ trait LogsTenantNotifications
|
|||||||
try {
|
try {
|
||||||
$callback($recipient);
|
$callback($recipient);
|
||||||
|
|
||||||
$this->logNotification($tenant, [
|
$log = $this->notificationLogger()->log($tenant, [
|
||||||
'type' => $type,
|
'type' => $type,
|
||||||
'channel' => 'mail',
|
'channel' => 'mail',
|
||||||
'recipient' => $recipient,
|
'recipient' => $recipient,
|
||||||
@@ -37,6 +55,13 @@ trait LogsTenantNotifications
|
|||||||
'context' => $context,
|
'context' => $context,
|
||||||
'sent_at' => now(),
|
'sent_at' => now(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
$this->createNotificationReceipt(
|
||||||
|
$tenant,
|
||||||
|
$log,
|
||||||
|
$tenant->user?->id,
|
||||||
|
$recipient
|
||||||
|
);
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
Log::error('Tenant notification failed', [
|
Log::error('Tenant notification failed', [
|
||||||
'tenant_id' => $tenant->id,
|
'tenant_id' => $tenant->id,
|
||||||
@@ -57,4 +82,51 @@ trait LogsTenantNotifications
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simple idempotency guard to avoid duplicate notifications within a cooldown window.
|
||||||
|
*
|
||||||
|
* @param string[] $dedupeKeys
|
||||||
|
*/
|
||||||
|
protected function isDuplicateNotification(
|
||||||
|
Tenant $tenant,
|
||||||
|
string $type,
|
||||||
|
array $context,
|
||||||
|
array $dedupeKeys,
|
||||||
|
int $cooldownMinutes = 1440
|
||||||
|
): bool {
|
||||||
|
$window = Carbon::now()->subMinutes($cooldownMinutes);
|
||||||
|
|
||||||
|
$logs = TenantNotificationLog::query()
|
||||||
|
->where('tenant_id', $tenant->id)
|
||||||
|
->where('type', $type)
|
||||||
|
->whereIn('status', ['sent', 'queued'])
|
||||||
|
->where(function ($query) use ($window) {
|
||||||
|
$query->whereNull('created_at')
|
||||||
|
->orWhere('created_at', '>=', $window)
|
||||||
|
->orWhere('sent_at', '>=', $window);
|
||||||
|
})
|
||||||
|
->get();
|
||||||
|
|
||||||
|
foreach ($logs as $log) {
|
||||||
|
$existing = is_array($log->context) ? $log->context : [];
|
||||||
|
$matches = true;
|
||||||
|
|
||||||
|
foreach ($dedupeKeys as $key) {
|
||||||
|
$currentValue = $context[$key] ?? null;
|
||||||
|
$existingValue = $existing[$key] ?? null;
|
||||||
|
|
||||||
|
if ($currentValue != $existingValue) {
|
||||||
|
$matches = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($matches) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,11 +41,24 @@ class SendEventPackageGalleryExpired implements ShouldQueue
|
|||||||
}
|
}
|
||||||
|
|
||||||
$preferences = app(\App\Services\Packages\TenantNotificationPreferences::class);
|
$preferences = app(\App\Services\Packages\TenantNotificationPreferences::class);
|
||||||
|
|
||||||
|
$context = $this->context($eventPackage);
|
||||||
|
|
||||||
|
if ($this->isDuplicateNotification($tenant, 'gallery_expired', $context, ['event_package_id'])) {
|
||||||
|
$this->logNotification($tenant, [
|
||||||
|
'type' => 'gallery_expired',
|
||||||
|
'status' => 'skipped',
|
||||||
|
'context' => array_merge($context, ['reason' => 'duplicate']),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (! $preferences->shouldNotify($tenant, 'gallery_expired')) {
|
if (! $preferences->shouldNotify($tenant, 'gallery_expired')) {
|
||||||
$this->logNotification($tenant, [
|
$this->logNotification($tenant, [
|
||||||
'type' => 'gallery_expired',
|
'type' => 'gallery_expired',
|
||||||
'status' => 'skipped',
|
'status' => 'skipped',
|
||||||
'context' => $this->context($eventPackage),
|
'context' => $context,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
@@ -65,14 +78,12 @@ class SendEventPackageGalleryExpired implements ShouldQueue
|
|||||||
$this->logNotification($tenant, [
|
$this->logNotification($tenant, [
|
||||||
'type' => 'gallery_expired',
|
'type' => 'gallery_expired',
|
||||||
'status' => 'skipped',
|
'status' => 'skipped',
|
||||||
'context' => array_merge($this->context($eventPackage), ['reason' => 'no_recipient']),
|
'context' => array_merge($context, ['reason' => 'no_recipient']),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$context = $this->context($eventPackage);
|
|
||||||
|
|
||||||
$this->dispatchToRecipients(
|
$this->dispatchToRecipients(
|
||||||
$tenant,
|
$tenant,
|
||||||
$emails,
|
$emails,
|
||||||
|
|||||||
@@ -44,11 +44,24 @@ class SendEventPackageGalleryWarning implements ShouldQueue
|
|||||||
}
|
}
|
||||||
|
|
||||||
$preferences = app(\App\Services\Packages\TenantNotificationPreferences::class);
|
$preferences = app(\App\Services\Packages\TenantNotificationPreferences::class);
|
||||||
|
|
||||||
|
$context = $this->context($eventPackage);
|
||||||
|
|
||||||
|
if ($this->isDuplicateNotification($tenant, 'gallery_warning', $context, ['event_package_id', 'days_remaining'])) {
|
||||||
|
$this->logNotification($tenant, [
|
||||||
|
'type' => 'gallery_warning',
|
||||||
|
'status' => 'skipped',
|
||||||
|
'context' => array_merge($context, ['reason' => 'duplicate']),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (! $preferences->shouldNotify($tenant, 'gallery_warnings')) {
|
if (! $preferences->shouldNotify($tenant, 'gallery_warnings')) {
|
||||||
$this->logNotification($tenant, [
|
$this->logNotification($tenant, [
|
||||||
'type' => 'gallery_warning',
|
'type' => 'gallery_warning',
|
||||||
'status' => 'skipped',
|
'status' => 'skipped',
|
||||||
'context' => $this->context($eventPackage),
|
'context' => $context,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
@@ -69,14 +82,12 @@ class SendEventPackageGalleryWarning implements ShouldQueue
|
|||||||
$this->logNotification($tenant, [
|
$this->logNotification($tenant, [
|
||||||
'type' => 'gallery_warning',
|
'type' => 'gallery_warning',
|
||||||
'status' => 'skipped',
|
'status' => 'skipped',
|
||||||
'context' => array_merge($this->context($eventPackage), ['reason' => 'no_recipient']),
|
'context' => array_merge($context, ['reason' => 'no_recipient']),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$context = $this->context($eventPackage);
|
|
||||||
|
|
||||||
$this->dispatchToRecipients(
|
$this->dispatchToRecipients(
|
||||||
$tenant,
|
$tenant,
|
||||||
$emails,
|
$emails,
|
||||||
|
|||||||
@@ -43,12 +43,24 @@ class SendEventPackageGuestLimitNotification implements ShouldQueue
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$context = $this->context($eventPackage);
|
||||||
|
|
||||||
|
if ($this->isDuplicateNotification($tenant, 'guest_limit', $context, ['event_package_id', 'limit'])) {
|
||||||
|
$this->logNotification($tenant, [
|
||||||
|
'type' => 'guest_limit',
|
||||||
|
'status' => 'skipped',
|
||||||
|
'context' => array_merge($context, ['reason' => 'duplicate']),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
$preferences = app(\App\Services\Packages\TenantNotificationPreferences::class);
|
$preferences = app(\App\Services\Packages\TenantNotificationPreferences::class);
|
||||||
if (! $preferences->shouldNotify($tenant, 'guest_limits')) {
|
if (! $preferences->shouldNotify($tenant, 'guest_limits')) {
|
||||||
$this->logNotification($tenant, [
|
$this->logNotification($tenant, [
|
||||||
'type' => 'guest_limit',
|
'type' => 'guest_limit',
|
||||||
'status' => 'skipped',
|
'status' => 'skipped',
|
||||||
'context' => $this->context($eventPackage),
|
'context' => $context,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
@@ -68,14 +80,12 @@ class SendEventPackageGuestLimitNotification implements ShouldQueue
|
|||||||
$this->logNotification($tenant, [
|
$this->logNotification($tenant, [
|
||||||
'type' => 'guest_limit',
|
'type' => 'guest_limit',
|
||||||
'status' => 'skipped',
|
'status' => 'skipped',
|
||||||
'context' => array_merge($this->context($eventPackage), ['reason' => 'no_recipient']),
|
'context' => array_merge($context, ['reason' => 'no_recipient']),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$context = $this->context($eventPackage);
|
|
||||||
|
|
||||||
$this->dispatchToRecipients(
|
$this->dispatchToRecipients(
|
||||||
$tenant,
|
$tenant,
|
||||||
$emails,
|
$emails,
|
||||||
|
|||||||
@@ -45,12 +45,24 @@ class SendEventPackageGuestThresholdWarning implements ShouldQueue
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$context = $this->context($eventPackage);
|
||||||
|
|
||||||
|
if ($this->isDuplicateNotification($tenant, 'guest_threshold', $context, ['event_package_id', 'threshold', 'limit'])) {
|
||||||
|
$this->logNotification($tenant, [
|
||||||
|
'type' => 'guest_threshold',
|
||||||
|
'status' => 'skipped',
|
||||||
|
'context' => array_merge($context, ['reason' => 'duplicate']),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
$preferences = app(\App\Services\Packages\TenantNotificationPreferences::class);
|
$preferences = app(\App\Services\Packages\TenantNotificationPreferences::class);
|
||||||
if (! $preferences->shouldNotify($tenant, 'guest_thresholds')) {
|
if (! $preferences->shouldNotify($tenant, 'guest_thresholds')) {
|
||||||
$this->logNotification($tenant, [
|
$this->logNotification($tenant, [
|
||||||
'type' => 'guest_threshold',
|
'type' => 'guest_threshold',
|
||||||
'status' => 'skipped',
|
'status' => 'skipped',
|
||||||
'context' => $this->context($eventPackage),
|
'context' => $context,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
@@ -71,14 +83,12 @@ class SendEventPackageGuestThresholdWarning implements ShouldQueue
|
|||||||
$this->logNotification($tenant, [
|
$this->logNotification($tenant, [
|
||||||
'type' => 'guest_threshold',
|
'type' => 'guest_threshold',
|
||||||
'status' => 'skipped',
|
'status' => 'skipped',
|
||||||
'context' => array_merge($this->context($eventPackage), ['reason' => 'no_recipient']),
|
'context' => array_merge($context, ['reason' => 'no_recipient']),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$context = $this->context($eventPackage);
|
|
||||||
|
|
||||||
$this->dispatchToRecipients(
|
$this->dispatchToRecipients(
|
||||||
$tenant,
|
$tenant,
|
||||||
$emails,
|
$emails,
|
||||||
|
|||||||
@@ -43,12 +43,24 @@ class SendEventPackagePhotoLimitNotification implements ShouldQueue
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$context = $this->context($eventPackage);
|
||||||
|
|
||||||
|
if ($this->isDuplicateNotification($tenant, 'photo_limit', $context, ['event_package_id', 'limit'])) {
|
||||||
|
$this->logNotification($tenant, [
|
||||||
|
'type' => 'photo_limit',
|
||||||
|
'status' => 'skipped',
|
||||||
|
'context' => array_merge($context, ['reason' => 'duplicate']),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
$preferences = app(\App\Services\Packages\TenantNotificationPreferences::class);
|
$preferences = app(\App\Services\Packages\TenantNotificationPreferences::class);
|
||||||
if (! $preferences->shouldNotify($tenant, 'photo_limits')) {
|
if (! $preferences->shouldNotify($tenant, 'photo_limits')) {
|
||||||
$this->logNotification($tenant, [
|
$this->logNotification($tenant, [
|
||||||
'type' => 'photo_limit',
|
'type' => 'photo_limit',
|
||||||
'status' => 'skipped',
|
'status' => 'skipped',
|
||||||
'context' => $this->context($eventPackage),
|
'context' => $context,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
@@ -69,14 +81,12 @@ class SendEventPackagePhotoLimitNotification implements ShouldQueue
|
|||||||
$this->logNotification($tenant, [
|
$this->logNotification($tenant, [
|
||||||
'type' => 'photo_limit',
|
'type' => 'photo_limit',
|
||||||
'status' => 'skipped',
|
'status' => 'skipped',
|
||||||
'context' => array_merge($this->context($eventPackage), ['reason' => 'no_recipient']),
|
'context' => array_merge($context, ['reason' => 'no_recipient']),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$context = $this->context($eventPackage);
|
|
||||||
|
|
||||||
$this->dispatchToRecipients(
|
$this->dispatchToRecipients(
|
||||||
$tenant,
|
$tenant,
|
||||||
$emails,
|
$emails,
|
||||||
|
|||||||
@@ -45,12 +45,24 @@ class SendEventPackagePhotoThresholdWarning implements ShouldQueue
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$context = $this->context($eventPackage);
|
||||||
|
|
||||||
|
if ($this->isDuplicateNotification($tenant, 'photo_threshold', $context, ['event_package_id', 'threshold', 'limit'])) {
|
||||||
|
$this->logNotification($tenant, [
|
||||||
|
'type' => 'photo_threshold',
|
||||||
|
'status' => 'skipped',
|
||||||
|
'context' => array_merge($context, ['reason' => 'duplicate']),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
$preferences = app(\App\Services\Packages\TenantNotificationPreferences::class);
|
$preferences = app(\App\Services\Packages\TenantNotificationPreferences::class);
|
||||||
if (! $preferences->shouldNotify($tenant, 'photo_thresholds')) {
|
if (! $preferences->shouldNotify($tenant, 'photo_thresholds')) {
|
||||||
$this->logNotification($tenant, [
|
$this->logNotification($tenant, [
|
||||||
'type' => 'photo_threshold',
|
'type' => 'photo_threshold',
|
||||||
'status' => 'skipped',
|
'status' => 'skipped',
|
||||||
'context' => $this->context($eventPackage),
|
'context' => $context,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
@@ -71,14 +83,12 @@ class SendEventPackagePhotoThresholdWarning implements ShouldQueue
|
|||||||
$this->logNotification($tenant, [
|
$this->logNotification($tenant, [
|
||||||
'type' => 'photo_threshold',
|
'type' => 'photo_threshold',
|
||||||
'status' => 'skipped',
|
'status' => 'skipped',
|
||||||
'context' => array_merge($this->context($eventPackage), ['reason' => 'no_recipient']),
|
'context' => array_merge($context, ['reason' => 'no_recipient']),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$context = $this->context($eventPackage);
|
|
||||||
|
|
||||||
$this->dispatchToRecipients(
|
$this->dispatchToRecipients(
|
||||||
$tenant,
|
$tenant,
|
||||||
$emails,
|
$emails,
|
||||||
|
|||||||
@@ -40,12 +40,24 @@ class SendTenantPackageEventLimitNotification implements ShouldQueue
|
|||||||
|
|
||||||
$tenant = $tenantPackage->tenant;
|
$tenant = $tenantPackage->tenant;
|
||||||
|
|
||||||
|
$context = $this->context($tenantPackage);
|
||||||
|
|
||||||
|
if ($this->isDuplicateNotification($tenant, 'event_limit', $context, ['tenant_package_id', 'limit'])) {
|
||||||
|
$this->logNotification($tenant, [
|
||||||
|
'type' => 'event_limit',
|
||||||
|
'status' => 'skipped',
|
||||||
|
'context' => array_merge($context, ['reason' => 'duplicate']),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
$preferences = app(\App\Services\Packages\TenantNotificationPreferences::class);
|
$preferences = app(\App\Services\Packages\TenantNotificationPreferences::class);
|
||||||
if (! $preferences->shouldNotify($tenant, 'event_limits')) {
|
if (! $preferences->shouldNotify($tenant, 'event_limits')) {
|
||||||
$this->logNotification($tenant, [
|
$this->logNotification($tenant, [
|
||||||
'type' => 'event_limit',
|
'type' => 'event_limit',
|
||||||
'status' => 'skipped',
|
'status' => 'skipped',
|
||||||
'context' => $this->context($tenantPackage),
|
'context' => $context,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
@@ -65,14 +77,12 @@ class SendTenantPackageEventLimitNotification implements ShouldQueue
|
|||||||
$this->logNotification($tenant, [
|
$this->logNotification($tenant, [
|
||||||
'type' => 'event_limit',
|
'type' => 'event_limit',
|
||||||
'status' => 'skipped',
|
'status' => 'skipped',
|
||||||
'context' => array_merge($this->context($tenantPackage), ['reason' => 'no_recipient']),
|
'context' => array_merge($context, ['reason' => 'no_recipient']),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$context = $this->context($tenantPackage);
|
|
||||||
|
|
||||||
$this->dispatchToRecipients(
|
$this->dispatchToRecipients(
|
||||||
$tenant,
|
$tenant,
|
||||||
$emails,
|
$emails,
|
||||||
|
|||||||
@@ -42,12 +42,24 @@ class SendTenantPackageEventThresholdWarning implements ShouldQueue
|
|||||||
|
|
||||||
$tenant = $tenantPackage->tenant;
|
$tenant = $tenantPackage->tenant;
|
||||||
|
|
||||||
|
$context = $this->context($tenantPackage);
|
||||||
|
|
||||||
|
if ($this->isDuplicateNotification($tenant, 'event_threshold', $context, ['tenant_package_id', 'threshold', 'limit'])) {
|
||||||
|
$this->logNotification($tenant, [
|
||||||
|
'type' => 'event_threshold',
|
||||||
|
'status' => 'skipped',
|
||||||
|
'context' => array_merge($context, ['reason' => 'duplicate']),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
$preferences = app(\App\Services\Packages\TenantNotificationPreferences::class);
|
$preferences = app(\App\Services\Packages\TenantNotificationPreferences::class);
|
||||||
if (! $preferences->shouldNotify($tenant, 'event_thresholds')) {
|
if (! $preferences->shouldNotify($tenant, 'event_thresholds')) {
|
||||||
$this->logNotification($tenant, [
|
$this->logNotification($tenant, [
|
||||||
'type' => 'event_threshold',
|
'type' => 'event_threshold',
|
||||||
'status' => 'skipped',
|
'status' => 'skipped',
|
||||||
'context' => $this->context($tenantPackage),
|
'context' => $context,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
@@ -68,14 +80,12 @@ class SendTenantPackageEventThresholdWarning implements ShouldQueue
|
|||||||
$this->logNotification($tenant, [
|
$this->logNotification($tenant, [
|
||||||
'type' => 'event_threshold',
|
'type' => 'event_threshold',
|
||||||
'status' => 'skipped',
|
'status' => 'skipped',
|
||||||
'context' => array_merge($this->context($tenantPackage), ['reason' => 'no_recipient']),
|
'context' => array_merge($context, ['reason' => 'no_recipient']),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$context = $this->context($tenantPackage);
|
|
||||||
|
|
||||||
$this->dispatchToRecipients(
|
$this->dispatchToRecipients(
|
||||||
$tenant,
|
$tenant,
|
||||||
$emails,
|
$emails,
|
||||||
|
|||||||
@@ -37,15 +37,26 @@ class SendTenantPackageExpiredNotification implements ShouldQueue
|
|||||||
|
|
||||||
$tenant = $tenantPackage->tenant;
|
$tenant = $tenantPackage->tenant;
|
||||||
|
|
||||||
|
$context = [
|
||||||
|
'tenant_package_id' => $tenantPackage->id,
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($this->isDuplicateNotification($tenant, 'package_expired', $context, ['tenant_package_id'])) {
|
||||||
|
$this->logNotification($tenant, [
|
||||||
|
'type' => 'package_expired',
|
||||||
|
'status' => 'skipped',
|
||||||
|
'context' => array_merge($context, ['reason' => 'duplicate']),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
$preferences = app(\App\Services\Packages\TenantNotificationPreferences::class);
|
$preferences = app(\App\Services\Packages\TenantNotificationPreferences::class);
|
||||||
if (! $preferences->shouldNotify($tenant, 'package_expired')) {
|
if (! $preferences->shouldNotify($tenant, 'package_expired')) {
|
||||||
$this->logNotification($tenant, [
|
$this->logNotification($tenant, [
|
||||||
'type' => 'package_expired',
|
'type' => 'package_expired',
|
||||||
'status' => 'skipped',
|
'status' => 'skipped',
|
||||||
'context' => [
|
'context' => array_merge($context, ['reason' => 'opt_out']),
|
||||||
'tenant_package_id' => $tenantPackage->id,
|
|
||||||
'reason' => 'opt_out',
|
|
||||||
],
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
@@ -65,19 +76,12 @@ class SendTenantPackageExpiredNotification implements ShouldQueue
|
|||||||
$this->logNotification($tenant, [
|
$this->logNotification($tenant, [
|
||||||
'type' => 'package_expired',
|
'type' => 'package_expired',
|
||||||
'status' => 'skipped',
|
'status' => 'skipped',
|
||||||
'context' => [
|
'context' => array_merge($context, ['reason' => 'no_recipient']),
|
||||||
'tenant_package_id' => $tenantPackage->id,
|
|
||||||
'reason' => 'no_recipient',
|
|
||||||
],
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$context = [
|
|
||||||
'tenant_package_id' => $tenantPackage->id,
|
|
||||||
];
|
|
||||||
|
|
||||||
$this->dispatchToRecipients(
|
$this->dispatchToRecipients(
|
||||||
$tenant,
|
$tenant,
|
||||||
$emails,
|
$emails,
|
||||||
|
|||||||
@@ -40,16 +40,27 @@ class SendTenantPackageExpiringNotification implements ShouldQueue
|
|||||||
|
|
||||||
$tenant = $tenantPackage->tenant;
|
$tenant = $tenantPackage->tenant;
|
||||||
|
|
||||||
|
$context = [
|
||||||
|
'tenant_package_id' => $tenantPackage->id,
|
||||||
|
'days_remaining' => $this->daysRemaining,
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($this->isDuplicateNotification($tenant, 'package_expiring', $context, ['tenant_package_id', 'days_remaining'])) {
|
||||||
|
$this->logNotification($tenant, [
|
||||||
|
'type' => 'package_expiring',
|
||||||
|
'status' => 'skipped',
|
||||||
|
'context' => array_merge($context, ['reason' => 'duplicate']),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
$preferences = app(\App\Services\Packages\TenantNotificationPreferences::class);
|
$preferences = app(\App\Services\Packages\TenantNotificationPreferences::class);
|
||||||
if (! $preferences->shouldNotify($tenant, 'package_expiring')) {
|
if (! $preferences->shouldNotify($tenant, 'package_expiring')) {
|
||||||
$this->logNotification($tenant, [
|
$this->logNotification($tenant, [
|
||||||
'type' => 'package_expiring',
|
'type' => 'package_expiring',
|
||||||
'status' => 'skipped',
|
'status' => 'skipped',
|
||||||
'context' => [
|
'context' => array_merge($context, ['reason' => 'opt_out']),
|
||||||
'tenant_package_id' => $tenantPackage->id,
|
|
||||||
'days_remaining' => $this->daysRemaining,
|
|
||||||
'reason' => 'opt_out',
|
|
||||||
],
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
@@ -70,21 +81,12 @@ class SendTenantPackageExpiringNotification implements ShouldQueue
|
|||||||
$this->logNotification($tenant, [
|
$this->logNotification($tenant, [
|
||||||
'type' => 'package_expiring',
|
'type' => 'package_expiring',
|
||||||
'status' => 'skipped',
|
'status' => 'skipped',
|
||||||
'context' => [
|
'context' => array_merge($context, ['reason' => 'no_recipient']),
|
||||||
'tenant_package_id' => $tenantPackage->id,
|
|
||||||
'days_remaining' => $this->daysRemaining,
|
|
||||||
'reason' => 'no_recipient',
|
|
||||||
],
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$context = [
|
|
||||||
'tenant_package_id' => $tenantPackage->id,
|
|
||||||
'days_remaining' => $this->daysRemaining,
|
|
||||||
];
|
|
||||||
|
|
||||||
$this->dispatchToRecipients(
|
$this->dispatchToRecipients(
|
||||||
$tenant,
|
$tenant,
|
||||||
$emails,
|
$emails,
|
||||||
|
|||||||
@@ -28,4 +28,9 @@ class TenantNotificationLog extends Model
|
|||||||
{
|
{
|
||||||
return $this->belongsTo(Tenant::class);
|
return $this->belongsTo(Tenant::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function receipts()
|
||||||
|
{
|
||||||
|
return $this->hasMany(TenantNotificationReceipt::class, 'notification_log_id');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
36
app/Models/TenantNotificationReceipt.php
Normal file
36
app/Models/TenantNotificationReceipt.php
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
class TenantNotificationReceipt extends Model
|
||||||
|
{
|
||||||
|
protected $fillable = [
|
||||||
|
'tenant_id',
|
||||||
|
'notification_log_id',
|
||||||
|
'user_id',
|
||||||
|
'recipient',
|
||||||
|
'status',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'created_at' => 'datetime',
|
||||||
|
'updated_at' => 'datetime',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function tenant()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Tenant::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function notificationLog()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(TenantNotificationLog::class, 'notification_log_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function user()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('tenant_notification_receipts', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->unsignedBigInteger('tenant_id');
|
||||||
|
$table->unsignedBigInteger('notification_log_id');
|
||||||
|
$table->unsignedBigInteger('user_id')->nullable();
|
||||||
|
$table->string('recipient')->nullable();
|
||||||
|
$table->enum('status', ['delivered', 'read', 'dismissed'])->default('delivered');
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->index(['tenant_id', 'notification_log_id']);
|
||||||
|
$table->index(['tenant_id', 'user_id']);
|
||||||
|
$table->index(['tenant_id', 'status']);
|
||||||
|
$table->foreign('tenant_id')->references('id')->on('tenants')->cascadeOnDelete();
|
||||||
|
$table->foreign('notification_log_id')->references('id')->on('tenant_notification_logs')->cascadeOnDelete();
|
||||||
|
$table->foreign('user_id')->references('id')->on('users')->nullOnDelete();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('tenant_notification_receipts');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -386,6 +386,19 @@ export type NotificationPreferences = Record<string, boolean>;
|
|||||||
|
|
||||||
export type NotificationPreferencesMeta = Record<string, never>;
|
export type NotificationPreferencesMeta = Record<string, never>;
|
||||||
|
|
||||||
|
export type NotificationLogEntry = {
|
||||||
|
id: number;
|
||||||
|
type: string;
|
||||||
|
channel: string;
|
||||||
|
recipient: string | null;
|
||||||
|
status: string;
|
||||||
|
context: Record<string, unknown> | null;
|
||||||
|
sent_at: string | null;
|
||||||
|
failed_at: string | null;
|
||||||
|
failure_reason: string | null;
|
||||||
|
is_read?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
export type PaddleTransactionSummary = {
|
export type PaddleTransactionSummary = {
|
||||||
id: string | null;
|
id: string | null;
|
||||||
status: string | null;
|
status: string | null;
|
||||||
@@ -2016,6 +2029,71 @@ export async function updateNotificationPreferences(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeNotificationLog(entry: JsonValue): NotificationLogEntry | null {
|
||||||
|
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const row = entry as Record<string, JsonValue>;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: Number(row.id ?? 0),
|
||||||
|
type: typeof row.type === 'string' ? row.type : '',
|
||||||
|
channel: typeof row.channel === 'string' ? row.channel : '',
|
||||||
|
recipient: typeof row.recipient === 'string' ? row.recipient : null,
|
||||||
|
status: typeof row.status === 'string' ? row.status : '',
|
||||||
|
context: (row.context && typeof row.context === 'object' && !Array.isArray(row.context)) ? (row.context as Record<string, unknown>) : null,
|
||||||
|
sent_at: typeof row.sent_at === 'string' ? row.sent_at : null,
|
||||||
|
failed_at: typeof row.failed_at === 'string' ? row.failed_at : null,
|
||||||
|
failure_reason: typeof row.failure_reason === 'string' ? row.failure_reason : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listNotificationLogs(options?: {
|
||||||
|
page?: number;
|
||||||
|
perPage?: number;
|
||||||
|
type?: string;
|
||||||
|
status?: string;
|
||||||
|
scope?: string;
|
||||||
|
eventId?: number;
|
||||||
|
}): Promise<{
|
||||||
|
data: NotificationLogEntry[];
|
||||||
|
meta: PaginationMeta & { unread_count?: number };
|
||||||
|
}> {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (options?.page) params.set('page', String(options.page));
|
||||||
|
if (options?.perPage) params.set('per_page', String(options.perPage));
|
||||||
|
if (options?.type) params.set('type', options.type);
|
||||||
|
if (options?.status) params.set('status', options.status);
|
||||||
|
if (options?.scope) params.set('scope', options.scope);
|
||||||
|
if (options?.eventId) params.set('event_id', String(options.eventId));
|
||||||
|
|
||||||
|
const response = await authorizedFetch(`/api/v1/tenant/notifications/logs${params.toString() ? `?${params.toString()}` : ''}`);
|
||||||
|
const payload = await jsonOrThrow<{ data?: JsonValue[]; meta?: Partial<PaginationMeta> }>(
|
||||||
|
response,
|
||||||
|
'Failed to load notification logs'
|
||||||
|
);
|
||||||
|
|
||||||
|
const rows = Array.isArray(payload.data) ? payload.data : [];
|
||||||
|
const meta = buildPagination((payload.meta ?? {}) as JsonValue, 0) as PaginationMeta & { unread_count?: number };
|
||||||
|
if (payload.meta && typeof (payload.meta as any).unread_count === 'number') {
|
||||||
|
meta.unread_count = (payload.meta as any).unread_count as number;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: rows.map((row) => normalizeNotificationLog(row)).filter((row): row is NotificationLogEntry => Boolean(row)),
|
||||||
|
meta,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function markNotificationLogs(ids: number[], status: 'read' | 'dismissed'): Promise<void> {
|
||||||
|
await authorizedFetch('/api/v1/tenant/notifications/logs/mark', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ ids, status }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function getTenantPaddleTransactions(cursor?: string): Promise<{
|
export async function getTenantPaddleTransactions(cursor?: string): Promise<{
|
||||||
data: PaddleTransactionSummary[];
|
data: PaddleTransactionSummary[];
|
||||||
nextCursor: string | null;
|
nextCursor: string | null;
|
||||||
|
|||||||
@@ -73,6 +73,12 @@
|
|||||||
"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"
|
||||||
},
|
},
|
||||||
|
"common": {
|
||||||
|
"loadMore": "Mehr laden",
|
||||||
|
"processing": "Verarbeite …",
|
||||||
|
"close": "Schließen",
|
||||||
|
"reset": "Zurücksetzen"
|
||||||
|
},
|
||||||
"limits": {
|
"limits": {
|
||||||
"photosTitle": "Foto-Limit",
|
"photosTitle": "Foto-Limit",
|
||||||
"photosWarning": "Nur noch {{remaining}} von {{limit}} Foto-Uploads verfügbar.",
|
"photosWarning": "Nur noch {{remaining}} von {{limit}} Foto-Uploads verfügbar.",
|
||||||
|
|||||||
@@ -149,6 +149,13 @@
|
|||||||
"title": "Achtung",
|
"title": "Achtung",
|
||||||
"description": "Paket-Hinweise und Limits, die du im Blick behalten solltest."
|
"description": "Paket-Hinweise und Limits, die du im Blick behalten solltest."
|
||||||
},
|
},
|
||||||
|
"common": {
|
||||||
|
"all": "Alle",
|
||||||
|
"loadMore": "Mehr laden",
|
||||||
|
"processing": "Verarbeite …",
|
||||||
|
"close": "Schließen",
|
||||||
|
"reset": "Zurücksetzen"
|
||||||
|
},
|
||||||
"photos": {
|
"photos": {
|
||||||
"moderation": {
|
"moderation": {
|
||||||
"title": "Fotos moderieren",
|
"title": "Fotos moderieren",
|
||||||
@@ -173,6 +180,7 @@
|
|||||||
"all": "Alle",
|
"all": "Alle",
|
||||||
"featured": "Highlights",
|
"featured": "Highlights",
|
||||||
"hidden": "Versteckt",
|
"hidden": "Versteckt",
|
||||||
|
"pending": "In Prüfung",
|
||||||
"photobooth": "Photobooth",
|
"photobooth": "Photobooth",
|
||||||
"search": "Uploads durchsuchen …",
|
"search": "Uploads durchsuchen …",
|
||||||
"count": "{{count}} Uploads",
|
"count": "{{count}} Uploads",
|
||||||
@@ -185,11 +193,77 @@
|
|||||||
"show": "Einblenden",
|
"show": "Einblenden",
|
||||||
"feature": "Als Highlight setzen",
|
"feature": "Als Highlight setzen",
|
||||||
"unfeature": "Highlight entfernen",
|
"unfeature": "Highlight entfernen",
|
||||||
|
"approve": "Freigeben",
|
||||||
|
"approve": "Freigeben",
|
||||||
"delete": "Löschen",
|
"delete": "Löschen",
|
||||||
"copy": "Link kopieren",
|
"copy": "Link kopieren",
|
||||||
"copySuccess": "Link kopiert"
|
"copySuccess": "Link kopiert"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"limits": {
|
||||||
|
"photosBlocked": "Upload-Limit erreicht. Kaufe weitere Fotos, um fortzufahren.",
|
||||||
|
"photosWarning": "{{remaining}} von {{limit}} Fotos verbleiben.",
|
||||||
|
"guestsBlocked": "Gäste-Limit erreicht.",
|
||||||
|
"guestsWarning": "{{remaining}} von {{limit}} Gästen verbleiben.",
|
||||||
|
"galleryExpired": "Galerie abgelaufen. Verlängere die Laufzeit.",
|
||||||
|
"galleryWarningDay": "Galerie läuft in {{days}} Tag ab.",
|
||||||
|
"galleryWarningDays": "Galerie läuft in {{days}} Tagen ab.",
|
||||||
|
"buyMorePhotos": "Mehr Fotos freischalten",
|
||||||
|
"extendGallery": "Galerie verlängern",
|
||||||
|
"buyMoreGuests": "Mehr Gäste freischalten"
|
||||||
|
},
|
||||||
|
"notificationLogs": {
|
||||||
|
"photoLimit": {
|
||||||
|
"title": "Foto-Limit erreicht",
|
||||||
|
"body": "{{event}} hat das Foto-Limit von {{limit}} erreicht."
|
||||||
|
},
|
||||||
|
"guestLimit": {
|
||||||
|
"title": "Gäste-Limit erreicht",
|
||||||
|
"body": "{{event}} hat das Gäste-Limit von {{limit}} erreicht."
|
||||||
|
},
|
||||||
|
"eventLimit": {
|
||||||
|
"title": "Event-Kontingent erreicht",
|
||||||
|
"body": "Dein Paket erlaubt keine weiteren Events. Limit: {{limit}}."
|
||||||
|
},
|
||||||
|
"galleryWarning": {
|
||||||
|
"title": "Galerie läuft bald ab",
|
||||||
|
"body": "{{event}} läuft in {{days}} Tagen ab."
|
||||||
|
},
|
||||||
|
"galleryExpired": {
|
||||||
|
"title": "Galerie abgelaufen",
|
||||||
|
"body": "Galerie von {{event}} ist offline. Verlängern zum Reaktivieren."
|
||||||
|
},
|
||||||
|
"photoThreshold": {
|
||||||
|
"title": "Foto-Nutzung Warnung",
|
||||||
|
"body": "{{event}} liegt bei {{used}} / {{limit}} Fotos."
|
||||||
|
},
|
||||||
|
"guestThreshold": {
|
||||||
|
"title": "Gäste-Nutzung Warnung",
|
||||||
|
"body": "{{event}} liegt bei {{used}} / {{limit}} Gästen."
|
||||||
|
},
|
||||||
|
"generic": {
|
||||||
|
"body": "Benachrichtigung über {{channel}}."
|
||||||
|
},
|
||||||
|
"filterEmpty": "Keine Benachrichtigungen für dieses Event.",
|
||||||
|
"clearFilter": "Alle Benachrichtigungen anzeigen",
|
||||||
|
"filter": {
|
||||||
|
"unread": "Ungelesen",
|
||||||
|
"read": "Gelesen",
|
||||||
|
"all": "Alle"
|
||||||
|
},
|
||||||
|
"scope": {
|
||||||
|
"all": "Alle Bereiche",
|
||||||
|
"photos": "Fotos",
|
||||||
|
"guests": "Gäste",
|
||||||
|
"gallery": "Galerie",
|
||||||
|
"events": "Events",
|
||||||
|
"package": "Paket",
|
||||||
|
"general": "Allgemein"
|
||||||
|
},
|
||||||
|
"markAllRead": "Alle als gelesen markieren",
|
||||||
|
"markFailed": "Benachrichtigungen konnten nicht aktualisiert werden.",
|
||||||
|
"unread": "Ungelesen"
|
||||||
|
},
|
||||||
"events": {
|
"events": {
|
||||||
"detail": {
|
"detail": {
|
||||||
"kpi": {
|
"kpi": {
|
||||||
@@ -564,6 +638,9 @@
|
|||||||
"eventTasks": {
|
"eventTasks": {
|
||||||
"title": "Aufgaben & Missionen",
|
"title": "Aufgaben & Missionen",
|
||||||
"subtitle": "Stelle Mission Cards und Aufgaben für dieses Event zusammen.",
|
"subtitle": "Stelle Mission Cards und Aufgaben für dieses Event zusammen.",
|
||||||
|
"search": "Aufgaben suchen",
|
||||||
|
"emotionFilter": "Emotionen filtern",
|
||||||
|
"allEmotions": "Alle",
|
||||||
"actions": {
|
"actions": {
|
||||||
"back": "Zurück zur Übersicht",
|
"back": "Zurück zur Übersicht",
|
||||||
"assign": "Ausgewählte Tasks zuweisen"
|
"assign": "Ausgewählte Tasks zuweisen"
|
||||||
@@ -579,6 +656,8 @@
|
|||||||
"emotions": {
|
"emotions": {
|
||||||
"error": "Emotionen konnten nicht geladen werden."
|
"error": "Emotionen konnten nicht geladen werden."
|
||||||
},
|
},
|
||||||
|
"manageEmotions": "Emotionen verwalten",
|
||||||
|
"manageEmotionsHint": "Filtere und halte deine Taxonomie sauber.",
|
||||||
"alerts": {
|
"alerts": {
|
||||||
"notFoundTitle": "Event nicht gefunden",
|
"notFoundTitle": "Event nicht gefunden",
|
||||||
"notFoundDescription": "Bitte kehre zur Eventliste zurück."
|
"notFoundDescription": "Bitte kehre zur Eventliste zurück."
|
||||||
@@ -1819,7 +1898,9 @@
|
|||||||
"visibilityFailed": "Sichtbarkeit konnte nicht geändert werden.",
|
"visibilityFailed": "Sichtbarkeit konnte nicht geändert werden.",
|
||||||
"featureSuccess": "Als Highlight markiert",
|
"featureSuccess": "Als Highlight markiert",
|
||||||
"unfeatureSuccess": "Highlight entfernt",
|
"unfeatureSuccess": "Highlight entfernt",
|
||||||
"featureFailed": "Highlight konnte nicht geändert werden."
|
"featureFailed": "Highlight konnte nicht geändert werden.",
|
||||||
|
"approveSuccess": "Foto freigegeben",
|
||||||
|
"approveFailed": "Freigabe fehlgeschlagen."
|
||||||
},
|
},
|
||||||
"mobileProfile": {
|
"mobileProfile": {
|
||||||
"title": "Profil",
|
"title": "Profil",
|
||||||
@@ -1952,6 +2033,7 @@
|
|||||||
"mobileNotifications": {
|
"mobileNotifications": {
|
||||||
"title": "Benachrichtigungen",
|
"title": "Benachrichtigungen",
|
||||||
"empty": "Keine Benachrichtigungen vorhanden.",
|
"empty": "Keine Benachrichtigungen vorhanden.",
|
||||||
"filterByEvent": "Nach Event filtern"
|
"filterByEvent": "Nach Event filtern",
|
||||||
|
"unknownEvent": "Event"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,6 +73,12 @@
|
|||||||
"photoLimit": "This event reached its photo upload limit.",
|
"photoLimit": "This event reached its photo upload limit.",
|
||||||
"goToBilling": "Manage subscription"
|
"goToBilling": "Manage subscription"
|
||||||
},
|
},
|
||||||
|
"common": {
|
||||||
|
"loadMore": "Load more",
|
||||||
|
"processing": "Processing…",
|
||||||
|
"close": "Close",
|
||||||
|
"reset": "Reset"
|
||||||
|
},
|
||||||
"limits": {
|
"limits": {
|
||||||
"photosTitle": "Photo limit",
|
"photosTitle": "Photo limit",
|
||||||
"photosWarning": "Only {{remaining}} of {{limit}} photo uploads remaining.",
|
"photosWarning": "Only {{remaining}} of {{limit}} photo uploads remaining.",
|
||||||
|
|||||||
@@ -145,6 +145,13 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"common": {
|
||||||
|
"all": "All",
|
||||||
|
"loadMore": "Load more",
|
||||||
|
"processing": "Processing…",
|
||||||
|
"close": "Close",
|
||||||
|
"reset": "Reset"
|
||||||
|
},
|
||||||
"photos": {
|
"photos": {
|
||||||
"moderation": {
|
"moderation": {
|
||||||
"title": "Moderate photos",
|
"title": "Moderate photos",
|
||||||
@@ -169,6 +176,7 @@
|
|||||||
"all": "All",
|
"all": "All",
|
||||||
"featured": "Highlights",
|
"featured": "Highlights",
|
||||||
"hidden": "Hidden",
|
"hidden": "Hidden",
|
||||||
|
"pending": "Pending",
|
||||||
"photobooth": "Photobooth",
|
"photobooth": "Photobooth",
|
||||||
"search": "Search uploads …",
|
"search": "Search uploads …",
|
||||||
"count": "{{count}} uploads",
|
"count": "{{count}} uploads",
|
||||||
@@ -181,11 +189,76 @@
|
|||||||
"show": "Show",
|
"show": "Show",
|
||||||
"feature": "Set highlight",
|
"feature": "Set highlight",
|
||||||
"unfeature": "Remove highlight",
|
"unfeature": "Remove highlight",
|
||||||
|
"approve": "Approve",
|
||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
"copy": "Copy link",
|
"copy": "Copy link",
|
||||||
"copySuccess": "Link copied"
|
"copySuccess": "Link copied"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"limits": {
|
||||||
|
"photosBlocked": "Upload limit reached. Buy more photos to continue.",
|
||||||
|
"photosWarning": "{{remaining}} of {{limit}} photos remaining.",
|
||||||
|
"guestsBlocked": "Guest limit reached.",
|
||||||
|
"guestsWarning": "{{remaining}} of {{limit}} guests remaining.",
|
||||||
|
"galleryExpired": "Gallery expired. Extend to keep it online.",
|
||||||
|
"galleryWarningDay": "Gallery expires in {{days}} day.",
|
||||||
|
"galleryWarningDays": "Gallery expires in {{days}} days.",
|
||||||
|
"buyMorePhotos": "Buy more photos",
|
||||||
|
"extendGallery": "Extend gallery",
|
||||||
|
"buyMoreGuests": "Add more guests"
|
||||||
|
},
|
||||||
|
"notificationLogs": {
|
||||||
|
"photoLimit": {
|
||||||
|
"title": "Photo limit reached",
|
||||||
|
"body": "{{event}} reached its photo limit of {{limit}}."
|
||||||
|
},
|
||||||
|
"guestLimit": {
|
||||||
|
"title": "Guest limit reached",
|
||||||
|
"body": "{{event}} reached its guest limit of {{limit}}."
|
||||||
|
},
|
||||||
|
"eventLimit": {
|
||||||
|
"title": "Event quota reached",
|
||||||
|
"body": "Your package allows no more events. Limit: {{limit}}."
|
||||||
|
},
|
||||||
|
"galleryWarning": {
|
||||||
|
"title": "Gallery expiring soon",
|
||||||
|
"body": "{{event}} expires in {{days}} days."
|
||||||
|
},
|
||||||
|
"galleryExpired": {
|
||||||
|
"title": "Gallery expired",
|
||||||
|
"body": "{{event}} gallery is offline. Extend to reactivate."
|
||||||
|
},
|
||||||
|
"photoThreshold": {
|
||||||
|
"title": "Photo usage warning",
|
||||||
|
"body": "{{event}} is at {{used}} / {{limit}} photos."
|
||||||
|
},
|
||||||
|
"guestThreshold": {
|
||||||
|
"title": "Guest usage warning",
|
||||||
|
"body": "{{event}} is at {{used}} / {{limit}} guests."
|
||||||
|
},
|
||||||
|
"generic": {
|
||||||
|
"body": "Notification sent via {{channel}}."
|
||||||
|
},
|
||||||
|
"filterEmpty": "No notifications for this event.",
|
||||||
|
"clearFilter": "Show all notifications",
|
||||||
|
"filter": {
|
||||||
|
"unread": "Unread",
|
||||||
|
"read": "Read",
|
||||||
|
"all": "All"
|
||||||
|
},
|
||||||
|
"scope": {
|
||||||
|
"all": "All scopes",
|
||||||
|
"photos": "Photos",
|
||||||
|
"guests": "Guests",
|
||||||
|
"gallery": "Gallery",
|
||||||
|
"events": "Events",
|
||||||
|
"package": "Package",
|
||||||
|
"general": "General"
|
||||||
|
},
|
||||||
|
"markAllRead": "Mark all read",
|
||||||
|
"markFailed": "Could not update notifications.",
|
||||||
|
"unread": "Unread"
|
||||||
|
},
|
||||||
"events": {
|
"events": {
|
||||||
"detail": {
|
"detail": {
|
||||||
"kpi": {
|
"kpi": {
|
||||||
@@ -312,6 +385,9 @@
|
|||||||
"eventTasks": {
|
"eventTasks": {
|
||||||
"title": "Tasks & missions",
|
"title": "Tasks & missions",
|
||||||
"subtitle": "Curate mission cards and tasks for this event.",
|
"subtitle": "Curate mission cards and tasks for this event.",
|
||||||
|
"search": "Search tasks",
|
||||||
|
"emotionFilter": "Emotion filter",
|
||||||
|
"allEmotions": "All",
|
||||||
"actions": {
|
"actions": {
|
||||||
"back": "Back to overview",
|
"back": "Back to overview",
|
||||||
"assign": "Assign selected tasks"
|
"assign": "Assign selected tasks"
|
||||||
@@ -327,6 +403,8 @@
|
|||||||
"emotions": {
|
"emotions": {
|
||||||
"error": "Could not load emotions."
|
"error": "Could not load emotions."
|
||||||
},
|
},
|
||||||
|
"manageEmotions": "Manage emotions",
|
||||||
|
"manageEmotionsHint": "Filter and keep your taxonomy tidy.",
|
||||||
"alerts": {
|
"alerts": {
|
||||||
"notFoundTitle": "Event not found",
|
"notFoundTitle": "Event not found",
|
||||||
"notFoundDescription": "Please return to the event list."
|
"notFoundDescription": "Please return to the event list."
|
||||||
@@ -1840,7 +1918,9 @@
|
|||||||
"visibilityFailed": "Visibility could not be changed.",
|
"visibilityFailed": "Visibility could not be changed.",
|
||||||
"featureSuccess": "Marked as highlight",
|
"featureSuccess": "Marked as highlight",
|
||||||
"unfeatureSuccess": "Highlight removed",
|
"unfeatureSuccess": "Highlight removed",
|
||||||
"featureFailed": "Highlight could not be changed"
|
"featureFailed": "Highlight could not be changed",
|
||||||
|
"approveSuccess": "Photo approved",
|
||||||
|
"approveFailed": "Approval failed."
|
||||||
},
|
},
|
||||||
"mobileProfile": {
|
"mobileProfile": {
|
||||||
"title": "Profile",
|
"title": "Profile",
|
||||||
@@ -1973,6 +2053,7 @@
|
|||||||
"mobileNotifications": {
|
"mobileNotifications": {
|
||||||
"title": "Notifications",
|
"title": "Notifications",
|
||||||
"empty": "No notifications yet.",
|
"empty": "No notifications yet.",
|
||||||
"filterByEvent": "Filter by event"
|
"filterByEvent": "Filter by event",
|
||||||
|
"unknownEvent": "Event"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -266,6 +266,18 @@ export default function MobileEventPhotosPage() {
|
|||||||
))}
|
))}
|
||||||
</XStack>
|
</XStack>
|
||||||
|
|
||||||
|
{!loading ? (
|
||||||
|
<LimitWarnings
|
||||||
|
limits={limits}
|
||||||
|
addons={catalogAddons}
|
||||||
|
onCheckout={(scopeOrKey) => { void handleCheckout(scopeOrKey, slug, catalogAddons, setBusyScope, t); }}
|
||||||
|
busyScope={busyScope}
|
||||||
|
translate={translateLimits(t)}
|
||||||
|
textColor={text}
|
||||||
|
borderColor={border}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<YStack space="$2">
|
<YStack space="$2">
|
||||||
{Array.from({ length: 4 }).map((_, idx) => (
|
{Array.from({ length: 4 }).map((_, idx) => (
|
||||||
@@ -281,15 +293,6 @@ export default function MobileEventPhotosPage() {
|
|||||||
</MobileCard>
|
</MobileCard>
|
||||||
) : (
|
) : (
|
||||||
<YStack space="$3">
|
<YStack space="$3">
|
||||||
<LimitWarnings
|
|
||||||
limits={limits}
|
|
||||||
addons={catalogAddons}
|
|
||||||
onCheckout={(scopeOrKey) => { void handleCheckout(scopeOrKey, slug, catalogAddons, setBusyScope, t); }}
|
|
||||||
busyScope={busyScope}
|
|
||||||
translate={translateLimits(t)}
|
|
||||||
textColor={text}
|
|
||||||
borderColor={border}
|
|
||||||
/>
|
|
||||||
<Text fontSize="$sm" color={muted}>
|
<Text fontSize="$sm" color={muted}>
|
||||||
{t('mobilePhotos.count', '{{count}} photos', { count: totalCount })}
|
{t('mobilePhotos.count', '{{count}} photos', { count: totalCount })}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -490,6 +493,7 @@ function translateLimits(t: (key: string, defaultValue?: string, options?: Recor
|
|||||||
galleryWarningDays: 'Gallery expires in {{days}} days.',
|
galleryWarningDays: 'Gallery expires in {{days}} days.',
|
||||||
buyMorePhotos: 'Buy more photos',
|
buyMorePhotos: 'Buy more photos',
|
||||||
extendGallery: 'Extend gallery',
|
extendGallery: 'Extend gallery',
|
||||||
|
buyMoreGuests: 'Add more guests',
|
||||||
};
|
};
|
||||||
return (key, options) => t(`limits.${key}`, defaults[key] ?? key, options);
|
return (key, options) => t(`limits.${key}`, defaults[key] ?? key, options);
|
||||||
}
|
}
|
||||||
@@ -524,7 +528,7 @@ function LimitWarnings({
|
|||||||
<Text fontSize="$sm" color={textColor} fontWeight="700">
|
<Text fontSize="$sm" color={textColor} fontWeight="700">
|
||||||
{warning.message}
|
{warning.message}
|
||||||
</Text>
|
</Text>
|
||||||
{(warning.scope === 'photos' || warning.scope === 'gallery') && addons.length ? (
|
{(warning.scope === 'photos' || warning.scope === 'gallery' || warning.scope === 'guests') && addons.length ? (
|
||||||
<MobileAddonsPicker
|
<MobileAddonsPicker
|
||||||
scope={warning.scope}
|
scope={warning.scope}
|
||||||
addons={addons}
|
addons={addons}
|
||||||
@@ -539,7 +543,7 @@ function LimitWarnings({
|
|||||||
? translate('buyMorePhotos')
|
? translate('buyMorePhotos')
|
||||||
: warning.scope === 'gallery'
|
: warning.scope === 'gallery'
|
||||||
? translate('extendGallery')
|
? translate('extendGallery')
|
||||||
: translate('buyMorePhotos')
|
: translate('buyMoreGuests')
|
||||||
}
|
}
|
||||||
onPress={() => onCheckout(warning.scope)}
|
onPress={() => onCheckout(warning.scope)}
|
||||||
loading={busyScope === warning.scope}
|
loading={busyScope === warning.scope}
|
||||||
@@ -557,7 +561,7 @@ function MobileAddonsPicker({
|
|||||||
onCheckout,
|
onCheckout,
|
||||||
translate,
|
translate,
|
||||||
}: {
|
}: {
|
||||||
scope: 'photos' | 'gallery';
|
scope: 'photos' | 'gallery' | 'guests';
|
||||||
addons: EventAddonCatalogItem[];
|
addons: EventAddonCatalogItem[];
|
||||||
busy: boolean;
|
busy: boolean;
|
||||||
onCheckout: (addonKey: string) => void;
|
onCheckout: (addonKey: string) => void;
|
||||||
@@ -603,7 +607,13 @@ function MobileAddonsPicker({
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
<CTAButton
|
<CTAButton
|
||||||
label={scope === 'gallery' ? translate('extendGallery') : translate('buyMorePhotos')}
|
label={
|
||||||
|
scope === 'gallery'
|
||||||
|
? translate('extendGallery')
|
||||||
|
: scope === 'guests'
|
||||||
|
? translate('buyMoreGuests')
|
||||||
|
: translate('buyMorePhotos')
|
||||||
|
}
|
||||||
disabled={!selected || busy}
|
disabled={!selected || busy}
|
||||||
onPress={() => selected && onCheckout(selected)}
|
onPress={() => selected && onCheckout(selected)}
|
||||||
loading={busy}
|
loading={busy}
|
||||||
@@ -640,15 +650,25 @@ function EventAddonList({ addons, textColor, mutedColor }: { addons: EventAddonS
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleCheckout(
|
async function handleCheckout(
|
||||||
scopeOrKey: 'photos' | 'gallery' | string,
|
scopeOrKey: 'photos' | 'gallery' | 'guests' | string,
|
||||||
slug: string | null,
|
slug: string | null,
|
||||||
addons: EventAddonCatalogItem[],
|
addons: EventAddonCatalogItem[],
|
||||||
setBusyScope: (scope: string | null) => void,
|
setBusyScope: (scope: string | null) => void,
|
||||||
t: (key: string, defaultValue?: string) => string,
|
t: (key: string, defaultValue?: string) => string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (!slug) return;
|
if (!slug) return;
|
||||||
const scope = scopeOrKey === 'photos' || scopeOrKey === 'gallery' ? scopeOrKey : scopeOrKey.includes('gallery') ? 'gallery' : 'photos';
|
const scope =
|
||||||
const addonKey = scopeOrKey === 'photos' || scopeOrKey === 'gallery' ? selectAddonKeyForScope(addons, scope) : scopeOrKey;
|
scopeOrKey === 'photos' || scopeOrKey === 'gallery' || scopeOrKey === 'guests'
|
||||||
|
? scopeOrKey
|
||||||
|
: scopeOrKey.includes('gallery')
|
||||||
|
? 'gallery'
|
||||||
|
: scopeOrKey.includes('guest')
|
||||||
|
? 'guests'
|
||||||
|
: 'photos';
|
||||||
|
const addonKey =
|
||||||
|
scopeOrKey === 'photos' || scopeOrKey === 'gallery' || scopeOrKey === 'guests'
|
||||||
|
? selectAddonKeyForScope(addons, scope)
|
||||||
|
: scopeOrKey;
|
||||||
const currentUrl = typeof window !== 'undefined' ? `${window.location.origin}${adminPath(`/mobile/events/${slug}/photos`)}` : '';
|
const currentUrl = typeof window !== 'undefined' ? `${window.location.origin}${adminPath(`/mobile/events/${slug}/photos`)}` : '';
|
||||||
const successUrl = `${currentUrl}?addon_success=1`;
|
const successUrl = `${currentUrl}?addon_success=1`;
|
||||||
setBusyScope(scope);
|
setBusyScope(scope);
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { SizableText as Text } from '@tamagui/text';
|
|||||||
import { Pressable } from '@tamagui/react-native-web-lite';
|
import { Pressable } from '@tamagui/react-native-web-lite';
|
||||||
import { MobileShell } from './components/MobileShell';
|
import { MobileShell } from './components/MobileShell';
|
||||||
import { MobileCard, PillBadge } from './components/Primitives';
|
import { MobileCard, PillBadge } from './components/Primitives';
|
||||||
import { GuestNotificationSummary, listGuestNotifications } from '../api';
|
import { listNotificationLogs, markNotificationLogs, NotificationLogEntry } from '../api';
|
||||||
import { isAuthError } from '../auth/tokens';
|
import { isAuthError } from '../auth/tokens';
|
||||||
import { getApiErrorMessage } from '../lib/apiError';
|
import { getApiErrorMessage } from '../lib/apiError';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
@@ -21,18 +21,184 @@ type NotificationItem = {
|
|||||||
body: string;
|
body: string;
|
||||||
time: string;
|
time: string;
|
||||||
tone: 'info' | 'warning';
|
tone: 'info' | 'warning';
|
||||||
|
eventId?: number | null;
|
||||||
|
is_read?: boolean;
|
||||||
|
scope: 'photos' | 'guests' | 'gallery' | 'events' | 'package' | 'general';
|
||||||
};
|
};
|
||||||
|
|
||||||
async function loadNotifications(slug?: string): Promise<NotificationItem[]> {
|
function formatLog(
|
||||||
|
log: NotificationLogEntry,
|
||||||
|
t: (key: string, defaultValue?: string, options?: Record<string, unknown>) => string,
|
||||||
|
eventName?: string | null
|
||||||
|
): NotificationItem {
|
||||||
|
const ctx = log.context ?? {};
|
||||||
|
const limit = typeof ctx.limit === 'number' ? ctx.limit : null;
|
||||||
|
const used = typeof ctx.used === 'number' ? ctx.used : null;
|
||||||
|
const remaining = typeof ctx.remaining === 'number' ? ctx.remaining : null;
|
||||||
|
const days = typeof ctx.day === 'number' ? ctx.day : null;
|
||||||
|
const ctxEventId = ctx.event_id ?? ctx.eventId;
|
||||||
|
const eventId = typeof ctxEventId === 'string' ? Number(ctxEventId) : (typeof ctxEventId === 'number' ? ctxEventId : null);
|
||||||
|
const name = eventName ?? t('mobileNotifications.unknownEvent', 'Event');
|
||||||
|
const isRead = log.is_read === true;
|
||||||
|
const scope = (() => {
|
||||||
|
switch (log.type) {
|
||||||
|
case 'photo_limit':
|
||||||
|
case 'photo_threshold':
|
||||||
|
return 'photos';
|
||||||
|
case 'guest_limit':
|
||||||
|
case 'guest_threshold':
|
||||||
|
return 'guests';
|
||||||
|
case 'gallery_warning':
|
||||||
|
case 'gallery_expired':
|
||||||
|
return 'gallery';
|
||||||
|
case 'event_limit':
|
||||||
|
case 'event_threshold':
|
||||||
|
return 'events';
|
||||||
|
case 'package_expiring':
|
||||||
|
case 'package_expired':
|
||||||
|
return 'package';
|
||||||
|
default:
|
||||||
|
return 'general';
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
switch (log.type) {
|
||||||
|
case 'photo_limit':
|
||||||
|
return {
|
||||||
|
id: String(log.id),
|
||||||
|
title: t('notificationLogs.photoLimit.title', 'Photo limit reached'),
|
||||||
|
body: t('notificationLogs.photoLimit.body', '{{event}} reached its photo limit of {{limit}}.', {
|
||||||
|
event: name,
|
||||||
|
limit: limit ?? '—',
|
||||||
|
}),
|
||||||
|
time: log.sent_at ?? log.failed_at ?? '',
|
||||||
|
tone: 'warning',
|
||||||
|
eventId,
|
||||||
|
is_read: isRead,
|
||||||
|
scope,
|
||||||
|
};
|
||||||
|
case 'guest_limit':
|
||||||
|
return {
|
||||||
|
id: String(log.id),
|
||||||
|
title: t('notificationLogs.guestLimit.title', 'Guest limit reached'),
|
||||||
|
body: t('notificationLogs.guestLimit.body', '{{event}} reached its guest limit of {{limit}}.', {
|
||||||
|
event: name,
|
||||||
|
limit: limit ?? '—',
|
||||||
|
}),
|
||||||
|
time: log.sent_at ?? log.failed_at ?? '',
|
||||||
|
tone: 'warning',
|
||||||
|
eventId,
|
||||||
|
is_read: isRead,
|
||||||
|
scope,
|
||||||
|
};
|
||||||
|
case 'event_limit':
|
||||||
|
return {
|
||||||
|
id: String(log.id),
|
||||||
|
title: t('notificationLogs.eventLimit.title', 'Event quota reached'),
|
||||||
|
body: t('notificationLogs.eventLimit.body', 'Your package allows no more events. Limit: {{limit}}.', {
|
||||||
|
limit: limit ?? '—',
|
||||||
|
}),
|
||||||
|
time: log.sent_at ?? log.failed_at ?? '',
|
||||||
|
tone: 'warning',
|
||||||
|
eventId,
|
||||||
|
is_read: isRead,
|
||||||
|
scope,
|
||||||
|
};
|
||||||
|
case 'gallery_warning':
|
||||||
|
return {
|
||||||
|
id: String(log.id),
|
||||||
|
title: t('notificationLogs.galleryWarning.title', 'Gallery expiring soon'),
|
||||||
|
body: t('notificationLogs.galleryWarning.body', '{{event}} expires in {{days}} days.', {
|
||||||
|
event: name,
|
||||||
|
days: days ?? ctx.threshold ?? '—',
|
||||||
|
}),
|
||||||
|
time: log.sent_at ?? log.failed_at ?? '',
|
||||||
|
tone: 'warning',
|
||||||
|
eventId,
|
||||||
|
is_read: isRead,
|
||||||
|
scope,
|
||||||
|
};
|
||||||
|
case 'gallery_expired':
|
||||||
|
return {
|
||||||
|
id: String(log.id),
|
||||||
|
title: t('notificationLogs.galleryExpired.title', 'Gallery expired'),
|
||||||
|
body: t('notificationLogs.galleryExpired.body', '{{event}} gallery is offline. Extend to reactivate.', {
|
||||||
|
event: name,
|
||||||
|
}),
|
||||||
|
time: log.sent_at ?? log.failed_at ?? '',
|
||||||
|
tone: 'warning',
|
||||||
|
eventId,
|
||||||
|
is_read: isRead,
|
||||||
|
scope,
|
||||||
|
};
|
||||||
|
case 'photo_threshold':
|
||||||
|
return {
|
||||||
|
id: String(log.id),
|
||||||
|
title: t('notificationLogs.photoThreshold.title', 'Photo usage warning'),
|
||||||
|
body: t('notificationLogs.photoThreshold.body', '{{event}} is at {{used}} / {{limit}} photos.', {
|
||||||
|
event: name,
|
||||||
|
used: used ?? '—',
|
||||||
|
limit: limit ?? '—',
|
||||||
|
}),
|
||||||
|
time: log.sent_at ?? log.failed_at ?? '',
|
||||||
|
tone: 'info',
|
||||||
|
eventId,
|
||||||
|
is_read: isRead,
|
||||||
|
scope,
|
||||||
|
};
|
||||||
|
case 'guest_threshold':
|
||||||
|
return {
|
||||||
|
id: String(log.id),
|
||||||
|
title: t('notificationLogs.guestThreshold.title', 'Guest usage warning'),
|
||||||
|
body: t('notificationLogs.guestThreshold.body', '{{event}} is at {{used}} / {{limit}} guests.', {
|
||||||
|
event: name,
|
||||||
|
used: used ?? '—',
|
||||||
|
limit: limit ?? '—',
|
||||||
|
}),
|
||||||
|
time: log.sent_at ?? log.failed_at ?? '',
|
||||||
|
tone: 'info',
|
||||||
|
eventId,
|
||||||
|
is_read: isRead,
|
||||||
|
scope,
|
||||||
|
};
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
id: String(log.id),
|
||||||
|
title: log.type,
|
||||||
|
body: t('notificationLogs.generic.body', 'Notification sent via {{channel}}.', { channel: log.channel }),
|
||||||
|
time: log.sent_at ?? log.failed_at ?? '',
|
||||||
|
tone: 'info',
|
||||||
|
eventId,
|
||||||
|
is_read: isRead,
|
||||||
|
scope,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadNotifications(
|
||||||
|
t: (key: string, defaultValue?: string, options?: Record<string, unknown>) => string,
|
||||||
|
events?: TenantEvent[],
|
||||||
|
filters?: { scope?: string; status?: string; eventSlug?: string }
|
||||||
|
): Promise<NotificationItem[]> {
|
||||||
try {
|
try {
|
||||||
const result = slug ? await listGuestNotifications(slug) : [];
|
const eventId = filters?.eventSlug ? (events ?? []).find((ev) => ev.slug === filters.eventSlug)?.id ?? undefined : undefined;
|
||||||
return (result ?? []).map((item: GuestNotificationSummary) => ({
|
const response = await listNotificationLogs({
|
||||||
id: String(item.id),
|
perPage: 50,
|
||||||
title: item.title || 'Notification',
|
scope: filters?.scope && filters.scope !== 'all' ? filters.scope : undefined,
|
||||||
body: item.body ?? '',
|
status: filters?.status === 'all' ? undefined : filters?.status,
|
||||||
time: item.created_at ?? '',
|
eventId: eventId,
|
||||||
tone: item.type === 'support_tip' ? 'warning' : 'info',
|
});
|
||||||
}));
|
const lookup = new Map<number, string>();
|
||||||
|
(events ?? []).forEach((event) => {
|
||||||
|
lookup.set(event.id, typeof event.name === 'string' ? event.name : (event.name as Record<string, string>)?.en ?? '');
|
||||||
|
});
|
||||||
|
|
||||||
|
return (response.data ?? [])
|
||||||
|
.map((log) => {
|
||||||
|
const ctxEventId = log.context?.event_id ?? log.context?.eventId;
|
||||||
|
const parsed = typeof ctxEventId === 'string' ? Number(ctxEventId) : ctxEventId;
|
||||||
|
return formatLog(log, t, lookup.get(parsed as number));
|
||||||
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
@@ -43,6 +209,8 @@ export default function MobileNotificationsPage() {
|
|||||||
const { t } = useTranslation('management');
|
const { t } = useTranslation('management');
|
||||||
const search = new URLSearchParams(typeof window !== 'undefined' ? window.location.search : '');
|
const search = new URLSearchParams(typeof window !== 'undefined' ? window.location.search : '');
|
||||||
const slug = search.get('event') ?? undefined;
|
const slug = search.get('event') ?? undefined;
|
||||||
|
const scopeParam = search.get('scope') ?? 'all';
|
||||||
|
const statusParam = search.get('status') ?? 'unread';
|
||||||
const [notifications, setNotifications] = React.useState<NotificationItem[]>([]);
|
const [notifications, setNotifications] = React.useState<NotificationItem[]>([]);
|
||||||
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);
|
||||||
@@ -62,7 +230,7 @@ export default function MobileNotificationsPage() {
|
|||||||
const reload = React.useCallback(async () => {
|
const reload = React.useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const data = await loadNotifications(slug ?? undefined);
|
const data = await loadNotifications(t, events, { scope: scopeParam, status: statusParam, eventSlug: slug });
|
||||||
setNotifications(data);
|
setNotifications(data);
|
||||||
setError(null);
|
setError(null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -74,7 +242,7 @@ export default function MobileNotificationsPage() {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [slug, t]);
|
}, [slug, t, events]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
void reload();
|
void reload();
|
||||||
@@ -91,6 +259,31 @@ export default function MobileNotificationsPage() {
|
|||||||
})();
|
})();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const filtered = React.useMemo(() => {
|
||||||
|
if (!slug) return notifications;
|
||||||
|
const target = events.find((ev) => ev.slug === slug);
|
||||||
|
if (!target) return notifications;
|
||||||
|
return notifications.filter((item) => item.eventId === target.id || item.body.includes(String(target.name)) || item.title.includes(String(target.name)));
|
||||||
|
}, [notifications, slug, events]);
|
||||||
|
|
||||||
|
const scoped = React.useMemo(() => {
|
||||||
|
if (scopeParam === 'all') return filtered;
|
||||||
|
return filtered.filter((item) => item.scope === scopeParam);
|
||||||
|
}, [filtered, scopeParam]);
|
||||||
|
|
||||||
|
const statusFiltered = React.useMemo(() => {
|
||||||
|
if (statusParam === 'all') return scoped;
|
||||||
|
if (statusParam === 'read') return scoped.filter((item) => item.is_read);
|
||||||
|
return scoped.filter((item) => !item.is_read);
|
||||||
|
}, [scoped, statusParam]);
|
||||||
|
|
||||||
|
const unreadIds = React.useMemo(
|
||||||
|
() => scoped.filter((item) => !item.is_read).map((item) => Number(item.id)).filter((id) => Number.isFinite(id)),
|
||||||
|
[scoped]
|
||||||
|
);
|
||||||
|
|
||||||
|
const showFilterNotice = Boolean(slug) && filtered.length === 0 && notifications.length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MobileShell
|
<MobileShell
|
||||||
activeTab="home"
|
activeTab="home"
|
||||||
@@ -110,13 +303,69 @@ export default function MobileNotificationsPage() {
|
|||||||
</MobileCard>
|
</MobileCard>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{showFilterNotice ? (
|
||||||
|
<MobileCard space="$2">
|
||||||
|
<Text fontSize="$sm" fontWeight="700" color={text}>
|
||||||
|
{t('notificationLogs.filterEmpty', 'No notifications for this event.')}
|
||||||
|
</Text>
|
||||||
|
<Pressable
|
||||||
|
onPress={() => {
|
||||||
|
navigate('/admin/mobile/notifications', { replace: true });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text fontSize="$sm" color={primary} fontWeight="700">
|
||||||
|
{t('notificationLogs.clearFilter', 'Show all notifications')}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
</MobileCard>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<XStack space="$2" marginBottom="$2">
|
||||||
|
<select
|
||||||
|
value={statusParam}
|
||||||
|
onChange={(e) => navigate(`/admin/mobile/notifications?${new URLSearchParams({ status: e.target.value, scope: scopeParam, event: slug ?? '' }).toString()}`)}
|
||||||
|
style={{ border: `1px solid ${border}`, borderRadius: 10, padding: '6px 10px', fontSize: 13 }}
|
||||||
|
>
|
||||||
|
<option value="unread">{t('notificationLogs.filter.unread', 'Unread')}</option>
|
||||||
|
<option value="read">{t('notificationLogs.filter.read', 'Read')}</option>
|
||||||
|
<option value="all">{t('notificationLogs.filter.all', 'All')}</option>
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
value={scopeParam}
|
||||||
|
onChange={(e) => navigate(`/admin/mobile/notifications?${new URLSearchParams({ scope: e.target.value, status: statusParam, event: slug ?? '' }).toString()}`)}
|
||||||
|
style={{ border: `1px solid ${border}`, borderRadius: 10, padding: '6px 10px', fontSize: 13 }}
|
||||||
|
>
|
||||||
|
<option value="all">{t('notificationLogs.scope.all', 'All scopes')}</option>
|
||||||
|
<option value="photos">{t('notificationLogs.scope.photos', 'Photos')}</option>
|
||||||
|
<option value="guests">{t('notificationLogs.scope.guests', 'Guests')}</option>
|
||||||
|
<option value="gallery">{t('notificationLogs.scope.gallery', 'Gallery')}</option>
|
||||||
|
<option value="events">{t('notificationLogs.scope.events', 'Events')}</option>
|
||||||
|
<option value="package">{t('notificationLogs.scope.package', 'Package')}</option>
|
||||||
|
<option value="general">{t('notificationLogs.scope.general', 'General')}</option>
|
||||||
|
</select>
|
||||||
|
{unreadIds.length ? (
|
||||||
|
<CTAButton
|
||||||
|
label={t('notificationLogs.markAllRead', 'Mark all read')}
|
||||||
|
onPress={async () => {
|
||||||
|
try {
|
||||||
|
await markNotificationLogs(unreadIds, 'read');
|
||||||
|
void reload();
|
||||||
|
} catch {
|
||||||
|
toast.error(t('notificationLogs.markFailed', 'Could not update notifications.'));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
tone="ghost"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</XStack>
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<YStack space="$2">
|
<YStack space="$2">
|
||||||
{Array.from({ length: 4 }).map((_, idx) => (
|
{Array.from({ length: 4 }).map((_, idx) => (
|
||||||
<MobileCard key={`al-${idx}`} height={70} opacity={0.6} />
|
<MobileCard key={`al-${idx}`} height={70} opacity={0.6} />
|
||||||
))}
|
))}
|
||||||
</YStack>
|
</YStack>
|
||||||
) : notifications.length === 0 ? (
|
) : statusFiltered.length === 0 ? (
|
||||||
<MobileCard alignItems="center" justifyContent="center" space="$2">
|
<MobileCard alignItems="center" justifyContent="center" space="$2">
|
||||||
<Bell size={24} color={String(theme.gray9?.val ?? '#9ca3af')} />
|
<Bell size={24} color={String(theme.gray9?.val ?? '#9ca3af')} />
|
||||||
<Text fontSize="$sm" color={muted}>
|
<Text fontSize="$sm" color={muted}>
|
||||||
@@ -132,8 +381,8 @@ export default function MobileNotificationsPage() {
|
|||||||
</Text>
|
</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
) : null}
|
) : null}
|
||||||
{notifications.map((item) => (
|
{statusFiltered.map((item) => (
|
||||||
<MobileCard key={item.id} space="$2">
|
<MobileCard key={item.id} space="$2" borderColor={item.is_read ? border : primary}>
|
||||||
<XStack alignItems="center" space="$2">
|
<XStack alignItems="center" space="$2">
|
||||||
<XStack
|
<XStack
|
||||||
width={36}
|
width={36}
|
||||||
@@ -153,6 +402,7 @@ export default function MobileNotificationsPage() {
|
|||||||
{item.body}
|
{item.body}
|
||||||
</Text>
|
</Text>
|
||||||
</YStack>
|
</YStack>
|
||||||
|
{!item.is_read ? <PillBadge tone="warning">{t('notificationLogs.unread', 'Unread')}</PillBadge> : null}
|
||||||
<PillBadge tone={item.tone === 'warning' ? 'warning' : 'muted'}>{item.time}</PillBadge>
|
<PillBadge tone={item.tone === 'warning' ? 'warning' : 'muted'}>{item.time}</PillBadge>
|
||||||
</XStack>
|
</XStack>
|
||||||
</MobileCard>
|
</MobileCard>
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import type { EventAddonCatalogItem } from '../api';
|
import type { EventAddonCatalogItem } from '../api';
|
||||||
|
|
||||||
export const scopeDefaults: Record<'photos' | 'gallery', string[]> = {
|
export const scopeDefaults: Record<'photos' | 'gallery' | 'guests', string[]> = {
|
||||||
photos: ['extra_photos_500', 'extra_photos_2000'],
|
photos: ['extra_photos_500', 'extra_photos_2000'],
|
||||||
gallery: ['extend_gallery_30d', 'extend_gallery_90d'],
|
gallery: ['extend_gallery_30d', 'extend_gallery_90d'],
|
||||||
|
guests: ['extra_guests_300', 'extra_guests_100', 'extra_guests_50', 'extra_guests'],
|
||||||
};
|
};
|
||||||
|
|
||||||
export function selectAddonKeyForScope(addons: EventAddonCatalogItem[], scope: 'photos' | 'gallery'): string {
|
export function selectAddonKeyForScope(addons: EventAddonCatalogItem[], scope: 'photos' | 'gallery' | 'guests'): string {
|
||||||
const fallback = scope === 'photos' ? 'extra_photos_500' : 'extend_gallery_30d';
|
const fallback = scope === 'photos' ? 'extra_photos_500' : scope === 'gallery' ? 'extend_gallery_30d' : 'extra_guests';
|
||||||
const filtered = addons.filter((addon) => addon.price_id && scopeDefaults[scope].includes(addon.key));
|
const filtered = addons.filter((addon) => addon.price_id && scopeDefaults[scope].includes(addon.key));
|
||||||
if (filtered.length) {
|
if (filtered.length) {
|
||||||
return filtered[0].key;
|
return filtered[0].key;
|
||||||
|
|||||||
@@ -1,27 +1,24 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { useEventContext } from '../../context/EventContext';
|
import { listNotificationLogs } from '../../api';
|
||||||
import { listGuestNotifications } from '../../api';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Badge count for notifications bell in the mobile shell.
|
* Badge count for notifications bell in the mobile shell.
|
||||||
* Fetches guest notifications for the active event and returns count.
|
* Uses tenant notification logs so the badge matches the notifications screen.
|
||||||
*/
|
*/
|
||||||
export function useNotificationsBadge() {
|
export function useNotificationsBadge() {
|
||||||
const { activeEvent } = useEventContext();
|
|
||||||
const slug = activeEvent?.slug;
|
|
||||||
|
|
||||||
const { data: count = 0 } = useQuery<number>({
|
const { data: count = 0 } = useQuery<number>({
|
||||||
queryKey: ['mobile', 'notifications', 'badge', slug],
|
queryKey: ['mobile', 'notifications', 'badge', 'tenant'],
|
||||||
enabled: Boolean(slug),
|
|
||||||
staleTime: 60_000,
|
staleTime: 60_000,
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
if (!slug) {
|
const logs = await listNotificationLogs({ perPage: 1 });
|
||||||
return 0;
|
const meta: any = logs.meta ?? {};
|
||||||
|
if (typeof meta.unread_count === 'number') {
|
||||||
|
return meta.unread_count;
|
||||||
}
|
}
|
||||||
const notifications = await listGuestNotifications(slug);
|
return Array.isArray(logs.data) ? logs.data.filter((log) => log.is_read === false).length : 0;
|
||||||
return Array.isArray(notifications) ? notifications.length : 0;
|
|
||||||
},
|
},
|
||||||
|
retry: 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
return React.useMemo(() => ({ count }), [count]);
|
return React.useMemo(() => ({ count }), [count]);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import AppearanceToggleDropdown from '@/components/appearance-dropdown';
|
import AppearanceToggleDropdown from '@/components/appearance-dropdown';
|
||||||
import {
|
import {
|
||||||
@@ -147,6 +148,7 @@ export default function Header({ eventToken, title = '' }: { eventToken?: string
|
|||||||
const { event, status } = useEventData();
|
const { event, status } = useEventData();
|
||||||
const notificationCenter = useOptionalNotificationCenter();
|
const notificationCenter = useOptionalNotificationCenter();
|
||||||
const [notificationsOpen, setNotificationsOpen] = React.useState(false);
|
const [notificationsOpen, setNotificationsOpen] = React.useState(false);
|
||||||
|
const [statusFilter, setStatusFilter] = React.useState<'new' | 'read' | 'dismissed' | 'all'>('new');
|
||||||
const taskProgress = useGuestTaskProgress(eventToken);
|
const taskProgress = useGuestTaskProgress(eventToken);
|
||||||
const panelRef = React.useRef<HTMLDivElement | null>(null);
|
const panelRef = React.useRef<HTMLDivElement | null>(null);
|
||||||
const checklistItems = React.useMemo(
|
const checklistItems = React.useMemo(
|
||||||
@@ -277,11 +279,13 @@ type NotificationButtonProps = {
|
|||||||
type PushState = ReturnType<typeof usePushSubscription>;
|
type PushState = ReturnType<typeof usePushSubscription>;
|
||||||
|
|
||||||
function NotificationButton({ center, eventToken, open, onToggle, panelRef, checklistItems, taskProgress, t }: NotificationButtonProps) {
|
function NotificationButton({ center, eventToken, open, onToggle, panelRef, checklistItems, taskProgress, t }: NotificationButtonProps) {
|
||||||
const badgeCount = center.totalCount;
|
const badgeCount = center.unreadCount;
|
||||||
const progressRatio = taskProgress
|
const progressRatio = taskProgress
|
||||||
? Math.min(1, taskProgress.completedCount / TASK_BADGE_TARGET)
|
? Math.min(1, taskProgress.completedCount / TASK_BADGE_TARGET)
|
||||||
: 0;
|
: 0;
|
||||||
const [activeTab, setActiveTab] = React.useState<'unread' | 'all' | 'status'>(center.unreadCount > 0 ? 'unread' : 'all');
|
const [activeTab, setActiveTab] = React.useState<'unread' | 'all' | 'status'>(center.unreadCount > 0 ? 'unread' : 'all');
|
||||||
|
const [statusFilter, setStatusFilter] = React.useState<'new' | 'read' | 'dismissed' | 'all'>('new');
|
||||||
|
const [scopeFilter, setScopeFilter] = React.useState<'all' | 'uploads' | 'tips' | 'general'>('all');
|
||||||
const pushState = usePushSubscription(eventToken);
|
const pushState = usePushSubscription(eventToken);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
@@ -300,18 +304,39 @@ function NotificationButton({ center, eventToken, open, onToggle, panelRef, chec
|
|||||||
);
|
);
|
||||||
|
|
||||||
const filteredNotifications = React.useMemo(() => {
|
const filteredNotifications = React.useMemo(() => {
|
||||||
|
let base: typeof center.notifications = [];
|
||||||
switch (activeTab) {
|
switch (activeTab) {
|
||||||
case 'unread':
|
case 'unread':
|
||||||
return unreadNotifications;
|
base = unreadNotifications;
|
||||||
|
break;
|
||||||
case 'status':
|
case 'status':
|
||||||
return uploadNotifications;
|
base = uploadNotifications;
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
return center.notifications;
|
base = center.notifications;
|
||||||
}
|
}
|
||||||
}, [activeTab, center.notifications, unreadNotifications, uploadNotifications]);
|
|
||||||
|
if (statusFilter === 'all') return base;
|
||||||
|
return base.filter((item) => item.status === (statusFilter === 'new' ? 'new' : statusFilter));
|
||||||
|
}, [activeTab, center.notifications, unreadNotifications, uploadNotifications, statusFilter]);
|
||||||
|
|
||||||
|
const scopedNotifications = React.useMemo(() => {
|
||||||
|
if (scopeFilter === 'all') {
|
||||||
|
return filteredNotifications;
|
||||||
|
}
|
||||||
|
return filteredNotifications.filter((item) => {
|
||||||
|
if (scopeFilter === 'uploads') {
|
||||||
|
return item.type === 'upload_alert' || item.type === 'photo_activity';
|
||||||
|
}
|
||||||
|
if (scopeFilter === 'tips') {
|
||||||
|
return item.type === 'support_tip' || item.type === 'achievement_major';
|
||||||
|
}
|
||||||
|
return item.type === 'broadcast' || item.type === 'feedback_request';
|
||||||
|
});
|
||||||
|
}, [filteredNotifications, scopeFilter]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative">
|
<div className="relative z-50">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onToggle}
|
onClick={onToggle}
|
||||||
@@ -320,15 +345,15 @@ function NotificationButton({ center, eventToken, open, onToggle, panelRef, chec
|
|||||||
>
|
>
|
||||||
<Bell className="h-5 w-5" aria-hidden />
|
<Bell className="h-5 w-5" aria-hidden />
|
||||||
{badgeCount > 0 && (
|
{badgeCount > 0 && (
|
||||||
<span className="absolute -right-1 -top-1 rounded-full bg-white px-1.5 text-[10px] font-semibold text-pink-600">
|
<span className="absolute -right-1 -top-1 min-h-[18px] min-w-[18px] rounded-full bg-pink-500 px-1.5 text-[11px] font-semibold leading-[18px] text-white shadow-lg">
|
||||||
{badgeCount > 9 ? '9+' : badgeCount}
|
{badgeCount > 9 ? '9+' : badgeCount}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
{open && (
|
{open && createPortal(
|
||||||
<div
|
<div
|
||||||
ref={panelRef}
|
ref={panelRef}
|
||||||
className="absolute right-0 mt-2 w-80 rounded-2xl border border-white/30 bg-white/95 p-4 text-slate-900 shadow-2xl"
|
className="fixed right-4 top-16 z-[2147483000] w-80 rounded-2xl border border-white/30 bg-white/95 p-4 text-slate-900 shadow-2xl"
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
@@ -358,6 +383,40 @@ function NotificationButton({ center, eventToken, open, onToggle, panelRef, chec
|
|||||||
activeTab={activeTab}
|
activeTab={activeTab}
|
||||||
onTabChange={(next) => setActiveTab(next as typeof activeTab)}
|
onTabChange={(next) => setActiveTab(next as typeof activeTab)}
|
||||||
/>
|
/>
|
||||||
|
<div className="mt-2">
|
||||||
|
<label className="sr-only" htmlFor="notification-scope">
|
||||||
|
{t('header.notifications.scopeLabel', 'Bereich filtern')}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="notification-scope"
|
||||||
|
value={scopeFilter}
|
||||||
|
onChange={(event) => {
|
||||||
|
const next = event.target.value as typeof scopeFilter;
|
||||||
|
setScopeFilter(next);
|
||||||
|
notificationCenter?.setFilters({ scope: next, status: statusFilter });
|
||||||
|
}}
|
||||||
|
className="w-full rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm text-slate-700 shadow-sm"
|
||||||
|
>
|
||||||
|
<option value="all">{t('header.notifications.scope.all', 'Alle')}</option>
|
||||||
|
<option value="uploads">{t('header.notifications.scope.uploads', 'Uploads/Status')}</option>
|
||||||
|
<option value="tips">{t('header.notifications.scope.tips', 'Tipps & Achievements')}</option>
|
||||||
|
<option value="general">{t('header.notifications.scope.general', 'Allgemein')}</option>
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
value={statusFilter}
|
||||||
|
onChange={(event) => {
|
||||||
|
const value = event.target.value as typeof statusFilter;
|
||||||
|
setStatusFilter(value);
|
||||||
|
notificationCenter?.setFilters({ status: value, scope: scopeFilter });
|
||||||
|
}}
|
||||||
|
className="mt-2 w-full rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm text-slate-700 shadow-sm"
|
||||||
|
>
|
||||||
|
<option value="new">{t('header.notifications.filter.unread', 'Neu')}</option>
|
||||||
|
<option value="read">{t('header.notifications.filter.read', 'Gelesen')}</option>
|
||||||
|
<option value="dismissed">{t('header.notifications.filter.dismissed', 'Ausgeblendet')}</option>
|
||||||
|
<option value="all">{t('header.notifications.filter.all', 'Alle')}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
<NotificationStatusBar
|
<NotificationStatusBar
|
||||||
lastFetchedAt={center.lastFetchedAt}
|
lastFetchedAt={center.lastFetchedAt}
|
||||||
isOffline={center.isOffline}
|
isOffline={center.isOffline}
|
||||||
@@ -367,7 +426,7 @@ function NotificationButton({ center, eventToken, open, onToggle, panelRef, chec
|
|||||||
<div className="mt-3 max-h-80 space-y-2 overflow-y-auto pr-1">
|
<div className="mt-3 max-h-80 space-y-2 overflow-y-auto pr-1">
|
||||||
{center.loading ? (
|
{center.loading ? (
|
||||||
<NotificationSkeleton />
|
<NotificationSkeleton />
|
||||||
) : filteredNotifications.length === 0 ? (
|
) : scopedNotifications.length === 0 ? (
|
||||||
<NotificationEmptyState
|
<NotificationEmptyState
|
||||||
t={t}
|
t={t}
|
||||||
message={
|
message={
|
||||||
@@ -379,7 +438,7 @@ function NotificationButton({ center, eventToken, open, onToggle, panelRef, chec
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
filteredNotifications.map((item) => (
|
scopedNotifications.map((item) => (
|
||||||
<NotificationListItem
|
<NotificationListItem
|
||||||
key={item.id}
|
key={item.id}
|
||||||
item={item}
|
item={item}
|
||||||
@@ -444,7 +503,8 @@ function NotificationButton({ center, eventToken, open, onToggle, panelRef, chec
|
|||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>,
|
||||||
|
typeof document !== 'undefined' ? document.body : undefined
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export type NotificationCenterValue = {
|
|||||||
totalCount: number;
|
totalCount: number;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
refresh: () => Promise<void>;
|
refresh: () => Promise<void>;
|
||||||
|
setFilters: (filters: { status?: 'new' | 'read' | 'dismissed' | 'all'; scope?: 'all' | 'uploads' | 'tips' | 'general' }) => void;
|
||||||
markAsRead: (id: number) => Promise<void>;
|
markAsRead: (id: number) => Promise<void>;
|
||||||
dismiss: (id: number) => Promise<void>;
|
dismiss: (id: number) => Promise<void>;
|
||||||
eventToken: string;
|
eventToken: string;
|
||||||
@@ -30,6 +31,10 @@ export function NotificationCenterProvider({ eventToken, children }: { eventToke
|
|||||||
const [notifications, setNotifications] = React.useState<GuestNotificationItem[]>([]);
|
const [notifications, setNotifications] = React.useState<GuestNotificationItem[]>([]);
|
||||||
const [unreadCount, setUnreadCount] = React.useState(0);
|
const [unreadCount, setUnreadCount] = React.useState(0);
|
||||||
const [loadingNotifications, setLoadingNotifications] = React.useState(true);
|
const [loadingNotifications, setLoadingNotifications] = React.useState(true);
|
||||||
|
const [filters, setFiltersState] = React.useState<{ status?: 'new' | 'read' | 'dismissed' | 'all'; scope?: 'all' | 'uploads' | 'tips' | 'general' }>({
|
||||||
|
status: 'new',
|
||||||
|
scope: 'all',
|
||||||
|
});
|
||||||
const etagRef = React.useRef<string | null>(null);
|
const etagRef = React.useRef<string | null>(null);
|
||||||
const fetchLockRef = React.useRef(false);
|
const fetchLockRef = React.useRef(false);
|
||||||
const [lastFetchedAt, setLastFetchedAt] = React.useState<Date | null>(null);
|
const [lastFetchedAt, setLastFetchedAt] = React.useState<Date | null>(null);
|
||||||
@@ -59,7 +64,11 @@ export function NotificationCenterProvider({ eventToken, children }: { eventToke
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await fetchGuestNotifications(eventToken, etagRef.current);
|
const statusFilter = filters.status && filters.status !== 'all' ? (filters.status === 'new' ? 'unread' : filters.status) : undefined;
|
||||||
|
const result = await fetchGuestNotifications(eventToken, etagRef.current, {
|
||||||
|
status: statusFilter as any,
|
||||||
|
scope: filters.scope,
|
||||||
|
});
|
||||||
if (!result.notModified) {
|
if (!result.notModified) {
|
||||||
setNotifications(result.notifications);
|
setNotifications(result.notifications);
|
||||||
setUnreadCount(result.unreadCount);
|
setUnreadCount(result.unreadCount);
|
||||||
@@ -217,6 +226,11 @@ export function NotificationCenterProvider({ eventToken, children }: { eventToke
|
|||||||
[eventToken, loadNotifications]
|
[eventToken, loadNotifications]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const setFilters = React.useCallback((next: { status?: 'new' | 'read' | 'dismissed' | 'all'; scope?: 'all' | 'uploads' | 'tips' | 'general' }) => {
|
||||||
|
setFiltersState((prev) => ({ ...prev, ...next }));
|
||||||
|
void loadNotifications({ silent: true });
|
||||||
|
}, [loadNotifications]);
|
||||||
|
|
||||||
const refresh = React.useCallback(async () => {
|
const refresh = React.useCallback(async () => {
|
||||||
await Promise.all([loadNotifications(), refreshQueue()]);
|
await Promise.all([loadNotifications(), refreshQueue()]);
|
||||||
}, [loadNotifications, refreshQueue]);
|
}, [loadNotifications, refreshQueue]);
|
||||||
@@ -232,6 +246,7 @@ export function NotificationCenterProvider({ eventToken, children }: { eventToke
|
|||||||
totalCount,
|
totalCount,
|
||||||
loading,
|
loading,
|
||||||
refresh,
|
refresh,
|
||||||
|
setFilters,
|
||||||
markAsRead,
|
markAsRead,
|
||||||
dismiss,
|
dismiss,
|
||||||
eventToken,
|
eventToken,
|
||||||
|
|||||||
@@ -152,7 +152,7 @@ export default function UploadPage() {
|
|||||||
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 [immersiveMode, setImmersiveMode] = useState(true);
|
const [immersiveMode, setImmersiveMode] = useState(false);
|
||||||
const [showCelebration, setShowCelebration] = useState(false);
|
const [showCelebration, setShowCelebration] = useState(false);
|
||||||
const [showHeroOverlay, setShowHeroOverlay] = useState(true);
|
const [showHeroOverlay, setShowHeroOverlay] = useState(true);
|
||||||
|
|
||||||
|
|||||||
@@ -74,8 +74,16 @@ function mapNotification(payload: GuestNotificationRow): GuestNotificationItem {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchGuestNotifications(eventToken: string, etag?: string | null): Promise<GuestNotificationFetchResult> {
|
export async function fetchGuestNotifications(
|
||||||
const response = await fetch(`/api/v1/events/${encodeURIComponent(eventToken)}/notifications`, {
|
eventToken: string,
|
||||||
|
etag?: string | null,
|
||||||
|
options?: { status?: 'unread' | 'read' | 'dismissed'; scope?: 'all' | 'uploads' | 'tips' | 'general' }
|
||||||
|
): Promise<GuestNotificationFetchResult> {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (options?.status) params.set('status', options.status);
|
||||||
|
if (options?.scope && options.scope !== 'all') params.set('scope', options.scope);
|
||||||
|
|
||||||
|
const response = await fetch(`/api/v1/events/${encodeURIComponent(eventToken)}/notifications${params.toString() ? `?${params.toString()}` : ''}`, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: buildHeaders(etag),
|
headers: buildHeaders(etag),
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
|
|||||||
@@ -284,6 +284,9 @@ Route::prefix('v1')->name('api.v1.')->group(function () {
|
|||||||
Route::get('notifications/logs', [NotificationLogController::class, 'index'])
|
Route::get('notifications/logs', [NotificationLogController::class, 'index'])
|
||||||
->middleware('tenant.admin')
|
->middleware('tenant.admin')
|
||||||
->name('tenant.notifications.logs.index');
|
->name('tenant.notifications.logs.index');
|
||||||
|
Route::post('notifications/logs/mark', [NotificationLogController::class, 'mark'])
|
||||||
|
->middleware('tenant.admin')
|
||||||
|
->name('tenant.notifications.logs.mark');
|
||||||
|
|
||||||
Route::prefix('packages')->middleware('tenant.admin')->group(function () {
|
Route::prefix('packages')->middleware('tenant.admin')->group(function () {
|
||||||
Route::get('/', [PackageController::class, 'index'])->name('packages.index');
|
Route::get('/', [PackageController::class, 'index'])->name('packages.index');
|
||||||
|
|||||||
Reference in New Issue
Block a user