Added Phase‑1 continuation work across deep links, offline moderation queue, and admin push.
resources/js/admin/mobile/lib.
- Admin push is end‑to‑end: new backend model/migration/service/job + API endpoints, admin runtime config, push‑aware
service worker, and a settings toggle via useAdminPushSubscription. Notifications now auto‑refresh on push.
- New PHP/JS tests: admin push API feature test and queue/haptics unit tests
Added admin-specific PWA icon assets and wired them into the admin manifest, service worker, and admin shell, plus a
new “Device & permissions” card in mobile Settings with a persistent storage action and translations.
Details: public/manifest.json, public/admin-sw.js, resources/views/admin.blade.php, new icons in public/; new hook
resources/js/admin/mobile/hooks/useDevicePermissions.ts, helpers/tests in resources/js/admin/mobile/lib/
devicePermissions.ts + resources/js/admin/mobile/lib/devicePermissions.test.ts, and Settings UI updates in resources/
js/admin/mobile/SettingsPage.tsx with copy in resources/js/admin/i18n/locales/en/management.json and resources/js/
admin/i18n/locales/de/management.json.
This commit is contained in:
@@ -0,0 +1,65 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\Tenant;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Requests\Tenant\AdminPushSubscriptionDeleteRequest;
|
||||||
|
use App\Http\Requests\Tenant\AdminPushSubscriptionStoreRequest;
|
||||||
|
use App\Services\TenantAdminPushSubscriptionService;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
|
||||||
|
class AdminPushSubscriptionController extends Controller
|
||||||
|
{
|
||||||
|
public function store(
|
||||||
|
AdminPushSubscriptionStoreRequest $request,
|
||||||
|
TenantAdminPushSubscriptionService $subscriptions
|
||||||
|
): 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 push subscriptions.',
|
||||||
|
],
|
||||||
|
], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$payload = array_merge($request->validated(), [
|
||||||
|
'language' => $request->getPreferredLanguage() ?? $request->headers->get('Accept-Language'),
|
||||||
|
'user_agent' => (string) $request->userAgent(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$subscription = $subscriptions->register($tenant, $request->user(), $payload);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'id' => $subscription->id,
|
||||||
|
'status' => $subscription->status,
|
||||||
|
], 201);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(
|
||||||
|
AdminPushSubscriptionDeleteRequest $request,
|
||||||
|
TenantAdminPushSubscriptionService $subscriptions
|
||||||
|
): 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 push subscriptions.',
|
||||||
|
],
|
||||||
|
], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$validated = $request->validated();
|
||||||
|
$revoked = $subscriptions->revoke($tenant, $validated['endpoint'], $request->user());
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'status' => $revoked ? 'revoked' : 'not_found',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Tenant;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class AdminPushSubscriptionDeleteRequest extends FormRequest
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Determine if the user is authorized to make this request.
|
||||||
|
*/
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the validation rules that apply to the request.
|
||||||
|
*
|
||||||
|
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'endpoint' => ['required', 'url', 'max:500'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Tenant;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class AdminPushSubscriptionStoreRequest extends FormRequest
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Determine if the user is authorized to make this request.
|
||||||
|
*/
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the validation rules that apply to the request.
|
||||||
|
*
|
||||||
|
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'endpoint' => ['required', 'url', 'max:500'],
|
||||||
|
'keys.p256dh' => ['required', 'string', 'max:255'],
|
||||||
|
'keys.auth' => ['required', 'string', 'max:255'],
|
||||||
|
'expiration_time' => ['nullable'],
|
||||||
|
'content_encoding' => ['nullable', 'string', 'max:32'],
|
||||||
|
'device_id' => ['nullable', 'string', 'max:120'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
125
app/Jobs/SendTenantAdminPushNotification.php
Normal file
125
app/Jobs/SendTenantAdminPushNotification.php
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Jobs;
|
||||||
|
|
||||||
|
use App\Models\Event;
|
||||||
|
use App\Models\TenantAdminPushSubscription;
|
||||||
|
use App\Models\TenantNotificationLog;
|
||||||
|
use App\Services\Push\AdminWebPushDispatcher;
|
||||||
|
use App\Services\TenantAdminPushSubscriptionService;
|
||||||
|
use Illuminate\Bus\Queueable;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Foundation\Bus\Dispatchable;
|
||||||
|
use Illuminate\Queue\InteractsWithQueue;
|
||||||
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
use Illuminate\Support\Arr;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
class SendTenantAdminPushNotification implements ShouldQueue
|
||||||
|
{
|
||||||
|
use Dispatchable;
|
||||||
|
use InteractsWithQueue;
|
||||||
|
use Queueable;
|
||||||
|
use SerializesModels;
|
||||||
|
|
||||||
|
public function __construct(public int $notificationLogId)
|
||||||
|
{
|
||||||
|
$this->onQueue('notifications');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute the job.
|
||||||
|
*/
|
||||||
|
public function handle(
|
||||||
|
AdminWebPushDispatcher $dispatcher,
|
||||||
|
TenantAdminPushSubscriptionService $subscriptions
|
||||||
|
): void {
|
||||||
|
if (! config('push.enabled')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$notification = TenantNotificationLog::query()->find($this->notificationLogId);
|
||||||
|
|
||||||
|
if (! $notification) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$targets = TenantAdminPushSubscription::query()
|
||||||
|
->where('tenant_id', $notification->tenant_id)
|
||||||
|
->where('status', 'active')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
if ($targets->isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$context = $notification->context ?? [];
|
||||||
|
$eventId = Arr::get($context, 'event_id') ?? Arr::get($context, 'eventId');
|
||||||
|
$event = $eventId ? Event::query()->select(['id', 'name', 'slug'])->find($eventId) : null;
|
||||||
|
$eventName = $this->resolveEventName($event?->name);
|
||||||
|
|
||||||
|
$title = $eventName
|
||||||
|
? sprintf('%s · %s', $eventName, Str::headline(str_replace('_', ' ', $notification->type)))
|
||||||
|
: Str::headline(str_replace('_', ' ', $notification->type));
|
||||||
|
|
||||||
|
$payload = [
|
||||||
|
'title' => $title,
|
||||||
|
'body' => $notification->status === 'failed'
|
||||||
|
? ($notification->failure_reason ?: 'Benachrichtigung fehlgeschlagen')
|
||||||
|
: 'Neue Admin-Benachrichtigung',
|
||||||
|
'data' => [
|
||||||
|
'notification_id' => $notification->id,
|
||||||
|
'event_id' => $event?->id,
|
||||||
|
'url' => "/event-admin/mobile/notifications/{$notification->id}",
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($targets as $target) {
|
||||||
|
try {
|
||||||
|
$report = $dispatcher->send($target, $payload);
|
||||||
|
|
||||||
|
if ($report === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($report->isSuccess()) {
|
||||||
|
$subscriptions->markDelivered($target);
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($report->isSubscriptionExpired()) {
|
||||||
|
$target->update(['status' => 'revoked']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$subscriptions->markFailed($target, $report->getReason());
|
||||||
|
} catch (\Throwable $exception) {
|
||||||
|
Log::channel('notifications')->warning('Admin web push delivery failed', [
|
||||||
|
'subscription_id' => $target->id,
|
||||||
|
'tenant_id' => $notification->tenant_id,
|
||||||
|
'reason' => $exception->getMessage(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$subscriptions->markFailed($target, $exception->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveEventName(mixed $name): ?string
|
||||||
|
{
|
||||||
|
if (is_string($name)) {
|
||||||
|
return $name;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_array($name)) {
|
||||||
|
foreach ($name as $value) {
|
||||||
|
if (is_string($value) && $value !== '') {
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
51
app/Models/TenantAdminPushSubscription.php
Normal file
51
app/Models/TenantAdminPushSubscription.php
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class TenantAdminPushSubscription extends Model
|
||||||
|
{
|
||||||
|
/** @use HasFactory<\Database\Factories\TenantAdminPushSubscriptionFactory> */
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'tenant_id',
|
||||||
|
'user_id',
|
||||||
|
'device_id',
|
||||||
|
'endpoint',
|
||||||
|
'endpoint_hash',
|
||||||
|
'public_key',
|
||||||
|
'auth_token',
|
||||||
|
'content_encoding',
|
||||||
|
'status',
|
||||||
|
'expires_at',
|
||||||
|
'last_seen_at',
|
||||||
|
'last_notified_at',
|
||||||
|
'last_failed_at',
|
||||||
|
'failure_count',
|
||||||
|
'language',
|
||||||
|
'user_agent',
|
||||||
|
'meta',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'expires_at' => 'datetime',
|
||||||
|
'last_seen_at' => 'datetime',
|
||||||
|
'last_notified_at' => 'datetime',
|
||||||
|
'last_failed_at' => 'datetime',
|
||||||
|
'meta' => 'array',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function tenant(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Tenant::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function user(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Services\Packages;
|
namespace App\Services\Packages;
|
||||||
|
|
||||||
|
use App\Jobs\SendTenantAdminPushNotification;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\TenantNotificationLog;
|
use App\Models\TenantNotificationLog;
|
||||||
use Illuminate\Support\Arr;
|
use Illuminate\Support\Arr;
|
||||||
@@ -32,6 +33,8 @@ class TenantNotificationLogger
|
|||||||
'recipient' => $log->recipient,
|
'recipient' => $log->recipient,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
SendTenantAdminPushNotification::dispatch($log->id);
|
||||||
|
|
||||||
return $log;
|
return $log;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
82
app/Services/Push/AdminWebPushDispatcher.php
Normal file
82
app/Services/Push/AdminWebPushDispatcher.php
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Push;
|
||||||
|
|
||||||
|
use App\Models\TenantAdminPushSubscription;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Minishlink\WebPush\MessageSentReport;
|
||||||
|
use Minishlink\WebPush\Subscription as WebPushSubscription;
|
||||||
|
use Minishlink\WebPush\WebPush;
|
||||||
|
|
||||||
|
class AdminWebPushDispatcher
|
||||||
|
{
|
||||||
|
private ?WebPush $client = null;
|
||||||
|
|
||||||
|
public function send(TenantAdminPushSubscription $subscription, array $payload): ?MessageSentReport
|
||||||
|
{
|
||||||
|
if (! config('push.enabled')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$client = $this->client ??= $this->buildClient();
|
||||||
|
|
||||||
|
if (! $client) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$body = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
|
||||||
|
} catch (\JsonException $exception) {
|
||||||
|
Log::channel('notifications')->warning('Unable to encode admin push payload', [
|
||||||
|
'reason' => $exception->getMessage(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$body = '{}';
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return $client->sendOneNotification(
|
||||||
|
WebPushSubscription::create([
|
||||||
|
'endpoint' => $subscription->endpoint,
|
||||||
|
'publicKey' => $subscription->public_key,
|
||||||
|
'authToken' => $subscription->auth_token,
|
||||||
|
'contentEncoding' => $subscription->content_encoding ?? 'aes128gcm',
|
||||||
|
]),
|
||||||
|
$body
|
||||||
|
);
|
||||||
|
} catch (\Throwable $exception) {
|
||||||
|
Log::channel('notifications')->warning('Admin web push transport error', [
|
||||||
|
'tenant_id' => $subscription->tenant_id,
|
||||||
|
'subscription_id' => $subscription->id,
|
||||||
|
'reason' => $exception->getMessage(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildClient(): ?WebPush
|
||||||
|
{
|
||||||
|
$vapid = config('push.vapid', []);
|
||||||
|
|
||||||
|
if (empty($vapid['public_key']) || empty($vapid['private_key'])) {
|
||||||
|
Log::channel('notifications')->warning('Admin web push skipped because VAPID keys are missing.');
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$client = new WebPush([
|
||||||
|
'VAPID' => [
|
||||||
|
'subject' => $vapid['subject'] ?? config('app.url'),
|
||||||
|
'publicKey' => $vapid['public_key'],
|
||||||
|
'privateKey' => $vapid['private_key'],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$client->setDefaultOptions([
|
||||||
|
'TTL' => (int) config('push.ttl', 900),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $client;
|
||||||
|
}
|
||||||
|
}
|
||||||
153
app/Services/TenantAdminPushSubscriptionService.php
Normal file
153
app/Services/TenantAdminPushSubscriptionService.php
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\TenantAdminPushSubscription;
|
||||||
|
use App\Models\User;
|
||||||
|
use Carbon\CarbonImmutable;
|
||||||
|
use Illuminate\Support\Arr;
|
||||||
|
|
||||||
|
class TenantAdminPushSubscriptionService
|
||||||
|
{
|
||||||
|
public function register(Tenant $tenant, ?User $user, array $payload): TenantAdminPushSubscription
|
||||||
|
{
|
||||||
|
$endpoint = (string) ($payload['endpoint'] ?? '');
|
||||||
|
if ($endpoint === '') {
|
||||||
|
throw new \InvalidArgumentException('Push endpoint missing.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$keys = Arr::get($payload, 'keys', []);
|
||||||
|
$publicKey = (string) ($keys['p256dh'] ?? '');
|
||||||
|
$authToken = (string) ($keys['auth'] ?? '');
|
||||||
|
|
||||||
|
if ($publicKey === '' || $authToken === '') {
|
||||||
|
throw new \InvalidArgumentException('Push key material missing.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$deviceId = $this->sanitizeIdentifier(Arr::get($payload, 'device_id'));
|
||||||
|
$contentEncoding = (string) ($payload['content_encoding'] ?? Arr::get($payload, 'encoding', 'aes128gcm'));
|
||||||
|
$language = (string) ($payload['language'] ?? null);
|
||||||
|
$userAgent = (string) ($payload['user_agent'] ?? null);
|
||||||
|
$expiresAt = $this->normalizeExpiration(Arr::get($payload, 'expiration_time'));
|
||||||
|
|
||||||
|
$endpointHash = hash('sha256', $endpoint);
|
||||||
|
$data = [
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'user_id' => $user?->id,
|
||||||
|
'device_id' => $deviceId,
|
||||||
|
'public_key' => $publicKey,
|
||||||
|
'auth_token' => $authToken,
|
||||||
|
'content_encoding' => $contentEncoding ?: 'aes128gcm',
|
||||||
|
'status' => 'active',
|
||||||
|
'expires_at' => $expiresAt,
|
||||||
|
'last_seen_at' => now(),
|
||||||
|
'language' => $language !== '' ? substr($language, 0, 12) : null,
|
||||||
|
'user_agent' => $userAgent !== '' ? substr($userAgent, 0, 255) : null,
|
||||||
|
'failure_count' => 0,
|
||||||
|
];
|
||||||
|
|
||||||
|
$subscription = TenantAdminPushSubscription::query()
|
||||||
|
->where('endpoint_hash', $endpointHash)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($subscription) {
|
||||||
|
$subscription->fill($data);
|
||||||
|
$subscription->status = 'active';
|
||||||
|
$subscription->endpoint = $endpoint;
|
||||||
|
$subscription->save();
|
||||||
|
|
||||||
|
return $subscription;
|
||||||
|
}
|
||||||
|
|
||||||
|
return TenantAdminPushSubscription::create(array_merge($data, [
|
||||||
|
'endpoint' => $endpoint,
|
||||||
|
'endpoint_hash' => $endpointHash,
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function revoke(Tenant $tenant, string $endpoint, ?User $user = null): bool
|
||||||
|
{
|
||||||
|
$hash = hash('sha256', (string) $endpoint);
|
||||||
|
|
||||||
|
$subscription = TenantAdminPushSubscription::query()
|
||||||
|
->where('tenant_id', $tenant->id)
|
||||||
|
->when($user, fn ($query) => $query->where('user_id', $user->id))
|
||||||
|
->where(function ($query) use ($hash, $endpoint) {
|
||||||
|
$query->where('endpoint_hash', $hash)
|
||||||
|
->orWhere('endpoint', $endpoint);
|
||||||
|
})
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (! $subscription) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$subscription->update([
|
||||||
|
'status' => 'revoked',
|
||||||
|
'last_failed_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function markFailed(TenantAdminPushSubscription $subscription, ?string $message = null): void
|
||||||
|
{
|
||||||
|
$subscription->fill([
|
||||||
|
'last_failed_at' => now(),
|
||||||
|
'failure_count' => min(65535, $subscription->failure_count + 1),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($subscription->failure_count >= 3) {
|
||||||
|
$subscription->status = 'revoked';
|
||||||
|
}
|
||||||
|
|
||||||
|
$meta = $subscription->meta ?? [];
|
||||||
|
if ($message) {
|
||||||
|
$meta['last_error'] = substr($message, 0, 255);
|
||||||
|
}
|
||||||
|
|
||||||
|
$subscription->meta = $meta;
|
||||||
|
$subscription->save();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function markDelivered(TenantAdminPushSubscription $subscription): void
|
||||||
|
{
|
||||||
|
$subscription->fill([
|
||||||
|
'last_notified_at' => now(),
|
||||||
|
'last_failed_at' => null,
|
||||||
|
'failure_count' => 0,
|
||||||
|
])->save();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function sanitizeIdentifier(?string $value): ?string
|
||||||
|
{
|
||||||
|
if ($value === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$sanitized = preg_replace('/[^A-Za-z0-9 _\-]/', '', $value) ?? '';
|
||||||
|
$sanitized = trim(mb_substr($sanitized, 0, 120));
|
||||||
|
|
||||||
|
return $sanitized === '' ? null : $sanitized;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalizeExpiration(mixed $value): ?CarbonImmutable
|
||||||
|
{
|
||||||
|
if ($value === null || $value === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_numeric($value)) {
|
||||||
|
$seconds = (int) round(((float) $value) / 1000);
|
||||||
|
|
||||||
|
return CarbonImmutable::createFromTimestampUTC($seconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return CarbonImmutable::parse((string) $value);
|
||||||
|
} catch (\Throwable) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
41
database/factories/TenantAdminPushSubscriptionFactory.php
Normal file
41
database/factories/TenantAdminPushSubscriptionFactory.php
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Factories;
|
||||||
|
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\TenantAdminPushSubscription;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\TenantAdminPushSubscription>
|
||||||
|
*/
|
||||||
|
class TenantAdminPushSubscriptionFactory extends Factory
|
||||||
|
{
|
||||||
|
protected $model = TenantAdminPushSubscription::class;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Define the model's default state.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function definition(): array
|
||||||
|
{
|
||||||
|
$endpoint = $this->faker->url();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'tenant_id' => Tenant::factory(),
|
||||||
|
'user_id' => User::factory(),
|
||||||
|
'device_id' => (string) Str::uuid(),
|
||||||
|
'endpoint' => $endpoint,
|
||||||
|
'endpoint_hash' => hash('sha256', $endpoint),
|
||||||
|
'public_key' => base64_encode(random_bytes(32)),
|
||||||
|
'auth_token' => base64_encode(random_bytes(16)),
|
||||||
|
'content_encoding' => 'aes128gcm',
|
||||||
|
'status' => 'active',
|
||||||
|
'language' => 'de',
|
||||||
|
'user_agent' => 'Mozilla/5.0',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('tenant_admin_push_subscriptions', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('tenant_id')->constrained()->cascadeOnDelete();
|
||||||
|
$table->foreignId('user_id')->nullable()->constrained()->nullOnDelete();
|
||||||
|
$table->string('device_id', 120)->nullable();
|
||||||
|
$table->string('endpoint', 500)->unique();
|
||||||
|
$table->string('endpoint_hash', 128)->index();
|
||||||
|
$table->string('public_key', 255);
|
||||||
|
$table->string('auth_token', 255);
|
||||||
|
$table->string('content_encoding', 32)->default('aes128gcm');
|
||||||
|
$table->string('status', 32)->default('active');
|
||||||
|
$table->timestamp('expires_at')->nullable();
|
||||||
|
$table->timestamp('last_seen_at')->nullable();
|
||||||
|
$table->timestamp('last_notified_at')->nullable();
|
||||||
|
$table->timestamp('last_failed_at')->nullable();
|
||||||
|
$table->unsignedSmallInteger('failure_count')->default(0);
|
||||||
|
$table->string('language', 12)->nullable();
|
||||||
|
$table->string('user_agent', 255)->nullable();
|
||||||
|
$table->json('meta')->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->index(['tenant_id', 'status']);
|
||||||
|
$table->index(['tenant_id', 'user_id']);
|
||||||
|
$table->index(['tenant_id', 'device_id']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('tenant_admin_push_subscriptions');
|
||||||
|
}
|
||||||
|
};
|
||||||
19
database/seeders/TenantAdminPushSubscriptionSeeder.php
Normal file
19
database/seeders/TenantAdminPushSubscriptionSeeder.php
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Seeders;
|
||||||
|
|
||||||
|
use App\Models\TenantAdminPushSubscription;
|
||||||
|
use Illuminate\Database\Seeder;
|
||||||
|
|
||||||
|
class TenantAdminPushSubscriptionSeeder extends Seeder
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the database seeds.
|
||||||
|
*/
|
||||||
|
public function run(): void
|
||||||
|
{
|
||||||
|
TenantAdminPushSubscription::factory()
|
||||||
|
->count(3)
|
||||||
|
->create();
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
public/admin-apple-touch-icon.png
Normal file
BIN
public/admin-apple-touch-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 39 KiB |
BIN
public/admin-badge.png
Normal file
BIN
public/admin-badge.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.9 KiB |
BIN
public/admin-icon-192-maskable.png
Normal file
BIN
public/admin-icon-192-maskable.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 34 KiB |
BIN
public/admin-icon-192.png
Normal file
BIN
public/admin-icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 44 KiB |
BIN
public/admin-icon-512-maskable.png
Normal file
BIN
public/admin-icon-512-maskable.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 186 KiB |
BIN
public/admin-icon-512.png
Normal file
BIN
public/admin-icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 240 KiB |
@@ -97,3 +97,51 @@ self.addEventListener('fetch', (event) => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
self.addEventListener('push', (event) => {
|
||||||
|
const payload = event.data?.json?.() ?? {};
|
||||||
|
|
||||||
|
event.waitUntil(
|
||||||
|
(async () => {
|
||||||
|
const title = payload.title ?? 'Neue Admin-Benachrichtigung';
|
||||||
|
const options = {
|
||||||
|
body: payload.body ?? '',
|
||||||
|
icon: '/admin-icon-192.png',
|
||||||
|
badge: '/admin-badge.png',
|
||||||
|
data: payload.data ?? {},
|
||||||
|
};
|
||||||
|
|
||||||
|
await self.registration.showNotification(title, options);
|
||||||
|
|
||||||
|
const clients = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
|
||||||
|
clients.forEach((client) => client.postMessage({ type: 'admin-notification-refresh' }));
|
||||||
|
})()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener('notificationclick', (event) => {
|
||||||
|
event.notification.close();
|
||||||
|
const targetUrl = event.notification.data?.url || '/event-admin/mobile/notifications';
|
||||||
|
|
||||||
|
event.waitUntil(
|
||||||
|
self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clientList) => {
|
||||||
|
for (const client of clientList) {
|
||||||
|
if ('focus' in client) {
|
||||||
|
client.navigate(targetUrl);
|
||||||
|
return client.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (self.clients.openWindow) {
|
||||||
|
return self.clients.openWindow(targetUrl);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener('pushsubscriptionchange', (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clientList) => {
|
||||||
|
clientList.forEach((client) => client.postMessage({ type: 'push-subscription-change' }));
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
@@ -19,20 +19,26 @@
|
|||||||
"purpose": "any"
|
"purpose": "any"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "/favicon.svg",
|
"src": "/admin-icon-192.png",
|
||||||
"sizes": "any",
|
"sizes": "192x192",
|
||||||
"type": "image/svg+xml",
|
|
||||||
"purpose": "maskable"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"src": "/apple-touch-icon.png",
|
|
||||||
"sizes": "180x180",
|
|
||||||
"type": "image/png",
|
"type": "image/png",
|
||||||
"purpose": "any"
|
"purpose": "any"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "/apple-touch-icon.png",
|
"src": "/admin-icon-512.png",
|
||||||
"sizes": "180x180",
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/admin-icon-192-maskable.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/admin-icon-512-maskable.png",
|
||||||
|
"sizes": "512x512",
|
||||||
"type": "image/png",
|
"type": "image/png",
|
||||||
"purpose": "maskable"
|
"purpose": "maskable"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,6 +59,19 @@ export function DevTenantSwitcher({ bottomOffset = 16, variant = 'floating' }: D
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleLogin(key: string) {
|
||||||
|
if (!helper) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoggingIn(key);
|
||||||
|
try {
|
||||||
|
await helper.loginAs(key);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[DevAuth] Switch failed', error);
|
||||||
|
setLoggingIn(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (variant === 'inline') {
|
if (variant === 'inline') {
|
||||||
if (collapsed) {
|
if (collapsed) {
|
||||||
return (
|
return (
|
||||||
@@ -133,25 +146,14 @@ export function DevTenantSwitcher({ bottomOffset = 16, variant = 'floating' }: D
|
|||||||
icon={<PanelRightOpen size={16} />}
|
icon={<PanelRightOpen size={16} />}
|
||||||
borderRadius={999}
|
borderRadius={999}
|
||||||
position="fixed"
|
position="fixed"
|
||||||
right="$4"
|
right="$4"
|
||||||
zIndex={1000}
|
zIndex={1000}
|
||||||
onPress={() => setCollapsed(false)}
|
onPress={() => setCollapsed(false)}
|
||||||
style={{ bottom: bottomOffset + 70 }}
|
style={{ bottom: bottomOffset + 70 }}
|
||||||
>
|
>
|
||||||
Demo tenants
|
Demo tenants
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
async function handleLogin(key: string) {
|
|
||||||
if (!helper) return;
|
|
||||||
setLoggingIn(key);
|
|
||||||
try {
|
|
||||||
await helper.loginAs(key);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[DevAuth] Switch failed', error);
|
|
||||||
setLoggingIn(null);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -673,6 +673,15 @@ export type EventToolkitNotification = {
|
|||||||
};
|
};
|
||||||
type CreatedEventResponse = { message: string; data: JsonValue; balance: number };
|
type CreatedEventResponse = { message: string; data: JsonValue; balance: number };
|
||||||
type PhotoResponse = { message: string; data: TenantPhoto };
|
type PhotoResponse = { message: string; data: TenantPhoto };
|
||||||
|
type AdminPushSubscriptionPayload = {
|
||||||
|
endpoint: string;
|
||||||
|
keys: {
|
||||||
|
p256dh: string;
|
||||||
|
auth: string;
|
||||||
|
};
|
||||||
|
expirationTime?: number | null;
|
||||||
|
contentEncoding?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
type EventSavePayload = {
|
type EventSavePayload = {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -1498,6 +1507,43 @@ export async function getEventPhotos(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getEventPhoto(slug: string, id: number): Promise<TenantPhoto> {
|
||||||
|
const response = await authorizedFetch(`${eventEndpoint(slug)}/photos/${id}`);
|
||||||
|
const data = await jsonOrThrow<PhotoResponse>(response, 'Failed to load photo');
|
||||||
|
return normalizePhoto(data.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function registerAdminPushSubscription(subscription: PushSubscription, deviceId?: string): Promise<void> {
|
||||||
|
const json = subscription.toJSON() as AdminPushSubscriptionPayload;
|
||||||
|
const response = await authorizedFetch('/api/v1/tenant/notifications/push-subscriptions', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
endpoint: json.endpoint,
|
||||||
|
keys: json.keys,
|
||||||
|
expiration_time: json.expirationTime ?? null,
|
||||||
|
content_encoding: json.contentEncoding ?? null,
|
||||||
|
device_id: deviceId ?? null,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
await jsonOrThrow<{ id: number; status: string }>(response, 'Failed to register push subscription', { suppressToast: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function unregisterAdminPushSubscription(endpoint: string): Promise<void> {
|
||||||
|
const response = await authorizedFetch('/api/v1/tenant/notifications/push-subscriptions', {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ endpoint }),
|
||||||
|
});
|
||||||
|
|
||||||
|
await jsonOrThrow<{ status: string }>(response, 'Failed to unregister push subscription', { suppressToast: true });
|
||||||
|
}
|
||||||
|
|
||||||
export async function featurePhoto(slug: string, id: number): Promise<TenantPhoto> {
|
export async function featurePhoto(slug: string, id: number): Promise<TenantPhoto> {
|
||||||
const response = await authorizedFetch(`${eventEndpoint(slug)}/photos/${id}/feature`, { method: 'POST' });
|
const response = await authorizedFetch(`${eventEndpoint(slug)}/photos/${id}/feature`, { method: 'POST' });
|
||||||
const data = await jsonOrThrow<PhotoResponse>(response, 'Failed to feature photo');
|
const data = await jsonOrThrow<PhotoResponse>(response, 'Failed to feature photo');
|
||||||
|
|||||||
@@ -1955,7 +1955,14 @@
|
|||||||
"unfeatureSuccess": "Highlight entfernt",
|
"unfeatureSuccess": "Highlight entfernt",
|
||||||
"featureFailed": "Highlight konnte nicht geändert werden.",
|
"featureFailed": "Highlight konnte nicht geändert werden.",
|
||||||
"approveSuccess": "Foto freigegeben",
|
"approveSuccess": "Foto freigegeben",
|
||||||
"approveFailed": "Freigabe fehlgeschlagen."
|
"approveFailed": "Freigabe fehlgeschlagen.",
|
||||||
|
"queued": "Aktion gespeichert. Wird synchronisiert, sobald du online bist.",
|
||||||
|
"queueTitle": "Änderungen warten auf Sync",
|
||||||
|
"queueOnline": "{{count}} Aktionen bereit zur Synchronisierung.",
|
||||||
|
"queueOffline": "{{count}} Aktionen gespeichert – offline.",
|
||||||
|
"queueSync": "Sync",
|
||||||
|
"queueWaiting": "Offline",
|
||||||
|
"syncFailed": "Synchronisierung fehlgeschlagen. Bitte später erneut versuchen."
|
||||||
},
|
},
|
||||||
"mobileProfile": {
|
"mobileProfile": {
|
||||||
"title": "Profil",
|
"title": "Profil",
|
||||||
@@ -1976,6 +1983,39 @@
|
|||||||
"tenantBadge": "Tenant #{{id}}",
|
"tenantBadge": "Tenant #{{id}}",
|
||||||
"notificationsTitle": "Benachrichtigungen",
|
"notificationsTitle": "Benachrichtigungen",
|
||||||
"notificationsLoading": "Lade Einstellungen ...",
|
"notificationsLoading": "Lade Einstellungen ...",
|
||||||
|
"pushTitle": "App Push",
|
||||||
|
"pushUnsupported": "Push-Benachrichtigungen werden auf diesem Gerät nicht unterstützt.",
|
||||||
|
"pushDenied": "Benachrichtigungen sind im Browser blockiert.",
|
||||||
|
"pushActive": "Push aktiv",
|
||||||
|
"pushInactive": "Push deaktiviert",
|
||||||
|
"pushLoading": "Lädt ...",
|
||||||
|
"deviceTitle": "Gerät & Berechtigungen",
|
||||||
|
"deviceDescription": "Halte die Admin-App schnell, offline-bereit und für Benachrichtigungen freigeschaltet.",
|
||||||
|
"deviceLoading": "Gerätestatus wird geprüft ...",
|
||||||
|
"deviceStorageAction": "Offline-Schutz aktivieren",
|
||||||
|
"deviceStorageError": "Offline-Schutz konnte nicht aktiviert werden.",
|
||||||
|
"deviceStatusValues": {
|
||||||
|
"granted": "Erlaubt",
|
||||||
|
"denied": "Blockiert",
|
||||||
|
"prompt": "Berechtigung nötig",
|
||||||
|
"unsupported": "Nicht unterstützt",
|
||||||
|
"persisted": "Geschützt",
|
||||||
|
"available": "Nicht geschützt"
|
||||||
|
},
|
||||||
|
"deviceStatus": {
|
||||||
|
"notifications": {
|
||||||
|
"label": "Benachrichtigungen",
|
||||||
|
"description": "Erlaubt Warnungen und Admin-Updates."
|
||||||
|
},
|
||||||
|
"camera": {
|
||||||
|
"label": "Kamera",
|
||||||
|
"description": "Für QR-Scans und schnelle Aufnahmen."
|
||||||
|
},
|
||||||
|
"storage": {
|
||||||
|
"label": "Offline-Speicher",
|
||||||
|
"description": "Schützt zwischengespeicherte Daten vor Löschung."
|
||||||
|
}
|
||||||
|
},
|
||||||
"pref": {}
|
"pref": {}
|
||||||
},
|
},
|
||||||
"events": {
|
"events": {
|
||||||
|
|||||||
@@ -1975,7 +1975,14 @@
|
|||||||
"unfeatureSuccess": "Highlight removed",
|
"unfeatureSuccess": "Highlight removed",
|
||||||
"featureFailed": "Highlight could not be changed",
|
"featureFailed": "Highlight could not be changed",
|
||||||
"approveSuccess": "Photo approved",
|
"approveSuccess": "Photo approved",
|
||||||
"approveFailed": "Approval failed."
|
"approveFailed": "Approval failed.",
|
||||||
|
"queued": "Action saved. Syncs when you are back online.",
|
||||||
|
"queueTitle": "Changes waiting to sync",
|
||||||
|
"queueOnline": "{{count}} actions ready to sync.",
|
||||||
|
"queueOffline": "{{count}} actions saved offline.",
|
||||||
|
"queueSync": "Sync",
|
||||||
|
"queueWaiting": "Offline",
|
||||||
|
"syncFailed": "Sync failed. Please try again later."
|
||||||
},
|
},
|
||||||
"mobileProfile": {
|
"mobileProfile": {
|
||||||
"title": "Profile",
|
"title": "Profile",
|
||||||
@@ -1996,6 +2003,39 @@
|
|||||||
"tenantBadge": "Tenant #{{id}}",
|
"tenantBadge": "Tenant #{{id}}",
|
||||||
"notificationsTitle": "Notifications",
|
"notificationsTitle": "Notifications",
|
||||||
"notificationsLoading": "Loading settings ...",
|
"notificationsLoading": "Loading settings ...",
|
||||||
|
"pushTitle": "App Push",
|
||||||
|
"pushUnsupported": "Push notifications are not supported on this device.",
|
||||||
|
"pushDenied": "Notifications are blocked in your browser.",
|
||||||
|
"pushActive": "Push active",
|
||||||
|
"pushInactive": "Push disabled",
|
||||||
|
"pushLoading": "Loading ...",
|
||||||
|
"deviceTitle": "Device & permissions",
|
||||||
|
"deviceDescription": "Keep the admin app fast, offline-ready, and allowed to send alerts.",
|
||||||
|
"deviceLoading": "Checking device status ...",
|
||||||
|
"deviceStorageAction": "Enable offline protection",
|
||||||
|
"deviceStorageError": "Offline storage could not be enabled.",
|
||||||
|
"deviceStatusValues": {
|
||||||
|
"granted": "Allowed",
|
||||||
|
"denied": "Blocked",
|
||||||
|
"prompt": "Needs permission",
|
||||||
|
"unsupported": "Not supported",
|
||||||
|
"persisted": "Protected",
|
||||||
|
"available": "Not protected"
|
||||||
|
},
|
||||||
|
"deviceStatus": {
|
||||||
|
"notifications": {
|
||||||
|
"label": "Notifications",
|
||||||
|
"description": "Allow alerts and admin updates."
|
||||||
|
},
|
||||||
|
"camera": {
|
||||||
|
"label": "Camera",
|
||||||
|
"description": "Needed for QR scans and quick capture."
|
||||||
|
},
|
||||||
|
"storage": {
|
||||||
|
"label": "Offline storage",
|
||||||
|
"description": "Protect cached data from eviction."
|
||||||
|
}
|
||||||
|
},
|
||||||
"pref": {}
|
"pref": {}
|
||||||
},
|
},
|
||||||
"events": {
|
"events": {
|
||||||
|
|||||||
17
resources/js/admin/lib/device.ts
Normal file
17
resources/js/admin/lib/device.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
export function getAdminDeviceId(): string {
|
||||||
|
const key = 'admin-device-id';
|
||||||
|
let id = localStorage.getItem(key);
|
||||||
|
if (!id) {
|
||||||
|
id = generateId();
|
||||||
|
localStorage.setItem(key, id);
|
||||||
|
}
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateId(): string {
|
||||||
|
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
||||||
|
const r = (crypto.getRandomValues(new Uint8Array(1))[0] & 0xf) >> 0;
|
||||||
|
const v = c === 'x' ? r : (r & 0x3) | 0x8;
|
||||||
|
return v.toString(16);
|
||||||
|
});
|
||||||
|
}
|
||||||
23
resources/js/admin/lib/runtime-config.ts
Normal file
23
resources/js/admin/lib/runtime-config.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
type PushConfig = {
|
||||||
|
enabled: boolean;
|
||||||
|
vapidPublicKey: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AdminRuntimeConfig = {
|
||||||
|
push: PushConfig;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getAdminRuntimeConfig(): AdminRuntimeConfig {
|
||||||
|
const raw = typeof window !== 'undefined' ? window.__ADMIN_RUNTIME_CONFIG__ : undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
push: {
|
||||||
|
enabled: Boolean(raw?.push?.enabled),
|
||||||
|
vapidPublicKey: raw?.push?.vapidPublicKey ?? null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAdminPushConfig(): PushConfig {
|
||||||
|
return getAdminRuntimeConfig().push;
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import { MobileCard, PillBadge, CTAButton, SkeletonCard } from './components/Pri
|
|||||||
import { MobileField, MobileInput, MobileSelect } from './components/FormControls';
|
import { MobileField, MobileInput, MobileSelect } from './components/FormControls';
|
||||||
import {
|
import {
|
||||||
getEventPhotos,
|
getEventPhotos,
|
||||||
|
getEventPhoto,
|
||||||
updatePhotoVisibility,
|
updatePhotoVisibility,
|
||||||
featurePhoto,
|
featurePhoto,
|
||||||
unfeaturePhoto,
|
unfeaturePhoto,
|
||||||
@@ -33,11 +34,21 @@ import { buildLimitWarnings } from '../lib/limitWarnings';
|
|||||||
import { adminPath } from '../constants';
|
import { adminPath } from '../constants';
|
||||||
import { scopeDefaults, selectAddonKeyForScope } from './addons';
|
import { scopeDefaults, selectAddonKeyForScope } from './addons';
|
||||||
import { LegalConsentSheet } from './components/LegalConsentSheet';
|
import { LegalConsentSheet } from './components/LegalConsentSheet';
|
||||||
|
import { triggerHaptic } from './lib/haptics';
|
||||||
|
import { useOnlineStatus } from './hooks/useOnlineStatus';
|
||||||
|
import {
|
||||||
|
enqueuePhotoAction,
|
||||||
|
loadPhotoQueue,
|
||||||
|
removePhotoAction,
|
||||||
|
replacePhotoQueue,
|
||||||
|
type PhotoModerationAction,
|
||||||
|
} from './lib/photoModerationQueue';
|
||||||
|
import { ADMIN_EVENT_PHOTOS_PATH } from '../constants';
|
||||||
|
|
||||||
type FilterKey = 'all' | 'featured' | 'hidden' | 'pending';
|
type FilterKey = 'all' | 'featured' | 'hidden' | 'pending';
|
||||||
|
|
||||||
export default function MobileEventPhotosPage() {
|
export default function MobileEventPhotosPage() {
|
||||||
const { slug: slugParam } = useParams<{ slug?: string }>();
|
const { slug: slugParam, photoId: photoIdParam } = useParams<{ slug?: string; photoId?: string }>();
|
||||||
const { activeEvent, selectEvent } = useEventContext();
|
const { activeEvent, selectEvent } = useEventContext();
|
||||||
const slug = slugParam ?? activeEvent?.slug ?? null;
|
const slug = slugParam ?? activeEvent?.slug ?? null;
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -57,7 +68,9 @@ export default function MobileEventPhotosPage() {
|
|||||||
const [uploaderFilter, setUploaderFilter] = React.useState('');
|
const [uploaderFilter, setUploaderFilter] = React.useState('');
|
||||||
const [onlyFeatured, setOnlyFeatured] = React.useState(false);
|
const [onlyFeatured, setOnlyFeatured] = React.useState(false);
|
||||||
const [onlyHidden, setOnlyHidden] = React.useState(false);
|
const [onlyHidden, setOnlyHidden] = React.useState(false);
|
||||||
const [lightbox, setLightbox] = React.useState<TenantPhoto | null>(null);
|
const [lightboxId, setLightboxId] = React.useState<number | null>(null);
|
||||||
|
const [pendingPhotoId, setPendingPhotoId] = React.useState<number | null>(null);
|
||||||
|
const [syncingQueue, setSyncingQueue] = React.useState(false);
|
||||||
const [selectionMode, setSelectionMode] = React.useState(false);
|
const [selectionMode, setSelectionMode] = React.useState(false);
|
||||||
const [selectedIds, setSelectedIds] = React.useState<number[]>([]);
|
const [selectedIds, setSelectedIds] = React.useState<number[]>([]);
|
||||||
const [bulkBusy, setBulkBusy] = React.useState(false);
|
const [bulkBusy, setBulkBusy] = React.useState(false);
|
||||||
@@ -68,6 +81,9 @@ export default function MobileEventPhotosPage() {
|
|||||||
const [consentOpen, setConsentOpen] = React.useState(false);
|
const [consentOpen, setConsentOpen] = React.useState(false);
|
||||||
const [consentTarget, setConsentTarget] = React.useState<{ scope: 'photos' | 'gallery' | 'guests'; addonKey: string } | null>(null);
|
const [consentTarget, setConsentTarget] = React.useState<{ scope: 'photos' | 'gallery' | 'guests'; addonKey: string } | null>(null);
|
||||||
const [consentBusy, setConsentBusy] = React.useState(false);
|
const [consentBusy, setConsentBusy] = React.useState(false);
|
||||||
|
const [queuedActions, setQueuedActions] = React.useState<PhotoModerationAction[]>(() => loadPhotoQueue());
|
||||||
|
const online = useOnlineStatus();
|
||||||
|
const syncingQueueRef = React.useRef(false);
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const text = String(theme.color?.val ?? '#111827');
|
const text = String(theme.color?.val ?? '#111827');
|
||||||
const muted = String(theme.gray?.val ?? '#4b5563');
|
const muted = String(theme.gray?.val ?? '#4b5563');
|
||||||
@@ -78,12 +94,47 @@ export default function MobileEventPhotosPage() {
|
|||||||
const surface = String(theme.surface?.val ?? '#ffffff');
|
const surface = String(theme.surface?.val ?? '#ffffff');
|
||||||
const backdrop = String(theme.gray12?.val ?? '#0f172a');
|
const backdrop = String(theme.gray12?.val ?? '#0f172a');
|
||||||
|
|
||||||
|
const lightboxIndex = React.useMemo(() => {
|
||||||
|
if (lightboxId === null) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
return photos.findIndex((photo) => photo.id === lightboxId);
|
||||||
|
}, [photos, lightboxId]);
|
||||||
|
const lightbox = lightboxIndex >= 0 ? photos[lightboxIndex] : null;
|
||||||
|
const basePhotosPath = slug ? ADMIN_EVENT_PHOTOS_PATH(slug) : adminPath('/mobile/events');
|
||||||
|
const parsedPhotoId = React.useMemo(() => {
|
||||||
|
if (!photoIdParam) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const parsed = Number(photoIdParam);
|
||||||
|
return Number.isFinite(parsed) ? parsed : null;
|
||||||
|
}, [photoIdParam]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (lightbox) {
|
if (lightboxId !== null && lightboxIndex === -1 && !loading && pendingPhotoId !== lightboxId) {
|
||||||
|
setLightboxId(null);
|
||||||
|
}
|
||||||
|
}, [lightboxId, lightboxIndex, loading, pendingPhotoId]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (lightboxId !== null) {
|
||||||
setSelectionMode(false);
|
setSelectionMode(false);
|
||||||
setSelectedIds([]);
|
setSelectedIds([]);
|
||||||
}
|
}
|
||||||
}, [lightbox]);
|
}, [lightboxId]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (parsedPhotoId === null) {
|
||||||
|
if (!photoIdParam) {
|
||||||
|
setLightboxId(null);
|
||||||
|
}
|
||||||
|
setPendingPhotoId(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLightboxId(parsedPhotoId);
|
||||||
|
setPendingPhotoId(parsedPhotoId);
|
||||||
|
}, [parsedPhotoId, photoIdParam]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (slugParam && activeEvent?.slug !== slugParam) {
|
if (slugParam && activeEvent?.slug !== slugParam) {
|
||||||
@@ -150,66 +201,255 @@ export default function MobileEventPhotosPage() {
|
|||||||
setPage(1);
|
setPage(1);
|
||||||
}, [filter, slug]);
|
}, [filter, slug]);
|
||||||
|
|
||||||
async function toggleVisibility(photo: TenantPhoto) {
|
const updateQueueState = React.useCallback((queue: PhotoModerationAction[]) => {
|
||||||
if (!slug) return;
|
replacePhotoQueue(queue);
|
||||||
setBusyId(photo.id);
|
setQueuedActions(queue);
|
||||||
try {
|
}, []);
|
||||||
const updated = await updatePhotoVisibility(slug, photo.id, photo.status === 'hidden');
|
|
||||||
setPhotos((prev) => prev.map((p) => (p.id === photo.id ? updated : p)));
|
const applyOptimisticUpdate = React.useCallback((photoId: number, action: PhotoModerationAction['action']) => {
|
||||||
setLightbox((prev) => (prev && prev.id === photo.id ? updated : prev));
|
setPhotos((prev) =>
|
||||||
toast.success(
|
prev.map((photo) => {
|
||||||
updated.status === 'hidden'
|
if (photo.id !== photoId) {
|
||||||
? t('mobilePhotos.hideSuccess', 'Photo hidden')
|
return photo;
|
||||||
: t('mobilePhotos.showSuccess', 'Photo shown'),
|
}
|
||||||
);
|
|
||||||
} catch (err) {
|
if (action === 'approve') {
|
||||||
if (!isAuthError(err)) {
|
return { ...photo, status: 'approved' };
|
||||||
setError(getApiErrorMessage(err, t('mobilePhotos.visibilityFailed', 'Sichtbarkeit konnte nicht geändert werden.')));
|
}
|
||||||
toast.error(t('mobilePhotos.visibilityFailed', 'Sichtbarkeit konnte nicht geändert werden.'));
|
|
||||||
|
if (action === 'hide') {
|
||||||
|
return { ...photo, status: 'hidden' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === 'show') {
|
||||||
|
return { ...photo, status: 'approved' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === 'feature') {
|
||||||
|
return { ...photo, is_featured: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === 'unfeature') {
|
||||||
|
return { ...photo, is_featured: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
return photo;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const enqueueModerationAction = React.useCallback(
|
||||||
|
(action: PhotoModerationAction['action'], photoId: number) => {
|
||||||
|
if (!slug) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
} finally {
|
const nextQueue = enqueuePhotoAction({ eventSlug: slug, photoId, action });
|
||||||
setBusyId(null);
|
setQueuedActions(nextQueue);
|
||||||
|
applyOptimisticUpdate(photoId, action);
|
||||||
|
toast.success(t('mobilePhotos.queued', 'Aktion gespeichert. Wird synchronisiert, sobald du online bist.'));
|
||||||
|
triggerHaptic('selection');
|
||||||
|
},
|
||||||
|
[applyOptimisticUpdate, slug, t],
|
||||||
|
);
|
||||||
|
|
||||||
|
const syncQueuedActions = React.useCallback(
|
||||||
|
async (options?: { silent?: boolean }) => {
|
||||||
|
if (!online || syncingQueueRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const queue = loadPhotoQueue();
|
||||||
|
if (queue.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
syncingQueueRef.current = true;
|
||||||
|
setSyncingQueue(true);
|
||||||
|
|
||||||
|
let remaining = queue;
|
||||||
|
|
||||||
|
for (const entry of queue) {
|
||||||
|
try {
|
||||||
|
let updated: TenantPhoto | null = null;
|
||||||
|
if (entry.action === 'approve') {
|
||||||
|
updated = await updatePhotoStatus(entry.eventSlug, entry.photoId, 'approved');
|
||||||
|
} else if (entry.action === 'hide') {
|
||||||
|
updated = await updatePhotoVisibility(entry.eventSlug, entry.photoId, true);
|
||||||
|
} else if (entry.action === 'show') {
|
||||||
|
updated = await updatePhotoVisibility(entry.eventSlug, entry.photoId, false);
|
||||||
|
} else if (entry.action === 'feature') {
|
||||||
|
updated = await featurePhoto(entry.eventSlug, entry.photoId);
|
||||||
|
} else if (entry.action === 'unfeature') {
|
||||||
|
updated = await unfeaturePhoto(entry.eventSlug, entry.photoId);
|
||||||
|
}
|
||||||
|
|
||||||
|
remaining = removePhotoAction(remaining, entry.id);
|
||||||
|
|
||||||
|
if (updated && entry.eventSlug === slug) {
|
||||||
|
setPhotos((prev) => prev.map((photo) => (photo.id === updated!.id ? updated! : photo)));
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (!options?.silent) {
|
||||||
|
toast.error(t('mobilePhotos.syncFailed', 'Synchronisierung fehlgeschlagen. Bitte später erneut versuchen.'));
|
||||||
|
}
|
||||||
|
if (isAuthError(err)) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateQueueState(remaining);
|
||||||
|
setSyncingQueue(false);
|
||||||
|
syncingQueueRef.current = false;
|
||||||
|
},
|
||||||
|
[online, slug, t, updateQueueState],
|
||||||
|
);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (online) {
|
||||||
|
void syncQueuedActions({ silent: true });
|
||||||
}
|
}
|
||||||
|
}, [online, syncQueuedActions]);
|
||||||
|
|
||||||
|
const setLightboxWithUrl = React.useCallback(
|
||||||
|
(photoId: number | null, options?: { replace?: boolean }) => {
|
||||||
|
setLightboxId(photoId);
|
||||||
|
if (!slug) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const nextPath = photoId ? `${basePhotosPath}/${photoId}` : basePhotosPath;
|
||||||
|
if (location.pathname !== nextPath) {
|
||||||
|
navigate(`${nextPath}${location.search}`, { replace: options?.replace ?? false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[basePhotosPath, location.pathname, location.search, navigate, slug],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleModerationAction = React.useCallback(
|
||||||
|
async (action: PhotoModerationAction['action'], photo: TenantPhoto) => {
|
||||||
|
if (!slug) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!online) {
|
||||||
|
enqueueModerationAction(action, photo.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setBusyId(photo.id);
|
||||||
|
|
||||||
|
const successMessage = () => {
|
||||||
|
if (action === 'approve') {
|
||||||
|
return t('mobilePhotos.approveSuccess', 'Photo approved');
|
||||||
|
}
|
||||||
|
if (action === 'hide') {
|
||||||
|
return t('mobilePhotos.hideSuccess', 'Photo hidden');
|
||||||
|
}
|
||||||
|
if (action === 'show') {
|
||||||
|
return t('mobilePhotos.showSuccess', 'Photo shown');
|
||||||
|
}
|
||||||
|
if (action === 'feature') {
|
||||||
|
return t('mobilePhotos.featureSuccess', 'Als Highlight markiert');
|
||||||
|
}
|
||||||
|
return t('mobilePhotos.unfeatureSuccess', 'Highlight entfernt');
|
||||||
|
};
|
||||||
|
|
||||||
|
const errorMessage = () => {
|
||||||
|
if (action === 'approve') {
|
||||||
|
return t('mobilePhotos.approveFailed', 'Freigabe fehlgeschlagen.');
|
||||||
|
}
|
||||||
|
if (action === 'hide' || action === 'show') {
|
||||||
|
return t('mobilePhotos.visibilityFailed', 'Sichtbarkeit konnte nicht geändert werden.');
|
||||||
|
}
|
||||||
|
return t('mobilePhotos.featureFailed', 'Feature konnte nicht geändert werden.');
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
let updated: TenantPhoto;
|
||||||
|
if (action === 'approve') {
|
||||||
|
updated = await updatePhotoStatus(slug, photo.id, 'approved');
|
||||||
|
} else if (action === 'hide') {
|
||||||
|
updated = await updatePhotoVisibility(slug, photo.id, true);
|
||||||
|
} else if (action === 'show') {
|
||||||
|
updated = await updatePhotoVisibility(slug, photo.id, false);
|
||||||
|
} else if (action === 'feature') {
|
||||||
|
updated = await featurePhoto(slug, photo.id);
|
||||||
|
} else {
|
||||||
|
updated = await unfeaturePhoto(slug, photo.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
setPhotos((prev) => prev.map((p) => (p.id === photo.id ? updated : p)));
|
||||||
|
toast.success(successMessage());
|
||||||
|
triggerHaptic(action === 'approve' ? 'success' : 'medium');
|
||||||
|
} catch (err) {
|
||||||
|
if (!isAuthError(err)) {
|
||||||
|
setError(getApiErrorMessage(err, errorMessage()));
|
||||||
|
toast.error(errorMessage());
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setBusyId(null);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[enqueueModerationAction, online, slug, t],
|
||||||
|
);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!slug || pendingPhotoId === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (photos.some((photo) => photo.id === pendingPhotoId)) {
|
||||||
|
setPendingPhotoId(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let active = true;
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const fetched = await getEventPhoto(slug, pendingPhotoId);
|
||||||
|
if (!active) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPhotos((prev) => {
|
||||||
|
if (prev.some((photo) => photo.id === fetched.id)) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
return [fetched, ...prev];
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
if (!isAuthError(err)) {
|
||||||
|
toast.error(t('mobilePhotos.loadFailed', 'Fotos konnten nicht geladen werden.'));
|
||||||
|
}
|
||||||
|
if (active) {
|
||||||
|
setLightboxWithUrl(null, { replace: true });
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (active) {
|
||||||
|
setPendingPhotoId(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
};
|
||||||
|
}, [pendingPhotoId, slug, photos, loading, t, setLightboxWithUrl]);
|
||||||
|
|
||||||
|
async function toggleVisibility(photo: TenantPhoto) {
|
||||||
|
const action = photo.status === 'hidden' ? 'show' : 'hide';
|
||||||
|
await handleModerationAction(action, photo);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function toggleFeature(photo: TenantPhoto) {
|
async function toggleFeature(photo: TenantPhoto) {
|
||||||
if (!slug) return;
|
const action = photo.is_featured ? 'unfeature' : 'feature';
|
||||||
setBusyId(photo.id);
|
await handleModerationAction(action, photo);
|
||||||
try {
|
|
||||||
const updated = photo.is_featured ? await unfeaturePhoto(slug, photo.id) : await featurePhoto(slug, photo.id);
|
|
||||||
setPhotos((prev) => prev.map((p) => (p.id === photo.id ? updated : p)));
|
|
||||||
setLightbox((prev) => (prev && prev.id === photo.id ? updated : prev));
|
|
||||||
toast.success(
|
|
||||||
updated.is_featured
|
|
||||||
? t('mobilePhotos.featureSuccess', 'Als Highlight markiert')
|
|
||||||
: t('mobilePhotos.unfeatureSuccess', 'Highlight entfernt'),
|
|
||||||
);
|
|
||||||
} catch (err) {
|
|
||||||
if (!isAuthError(err)) {
|
|
||||||
setError(getApiErrorMessage(err, t('mobilePhotos.featureFailed', 'Feature konnte nicht geändert werden.')));
|
|
||||||
toast.error(t('mobilePhotos.featureFailed', 'Feature konnte nicht geändert werden.'));
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
setBusyId(null);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function approvePhoto(photo: TenantPhoto) {
|
async function approvePhoto(photo: TenantPhoto) {
|
||||||
if (!slug) return;
|
await handleModerationAction('approve', photo);
|
||||||
setBusyId(photo.id);
|
|
||||||
try {
|
|
||||||
const updated = await updatePhotoStatus(slug, photo.id, 'approved');
|
|
||||||
setPhotos((prev) => prev.map((p) => (p.id === photo.id ? updated : p)));
|
|
||||||
setLightbox((prev) => (prev && prev.id === photo.id ? updated : prev));
|
|
||||||
toast.success(t('mobilePhotos.approveSuccess', 'Photo approved'));
|
|
||||||
} catch (err) {
|
|
||||||
if (!isAuthError(err)) {
|
|
||||||
setError(getApiErrorMessage(err, t('mobilePhotos.approveFailed', 'Freigabe fehlgeschlagen.')));
|
|
||||||
toast.error(t('mobilePhotos.approveFailed', 'Freigabe fehlgeschlagen.'));
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
setBusyId(null);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectedPhotos = React.useMemo(
|
const selectedPhotos = React.useMemo(
|
||||||
@@ -221,6 +461,12 @@ export default function MobileEventPhotosPage() {
|
|||||||
const hasVisibleSelection = selectedPhotos.some((photo) => photo.status !== 'hidden');
|
const hasVisibleSelection = selectedPhotos.some((photo) => photo.status !== 'hidden');
|
||||||
const hasFeaturedSelection = selectedPhotos.some((photo) => photo.is_featured);
|
const hasFeaturedSelection = selectedPhotos.some((photo) => photo.is_featured);
|
||||||
const hasUnfeaturedSelection = selectedPhotos.some((photo) => !photo.is_featured);
|
const hasUnfeaturedSelection = selectedPhotos.some((photo) => !photo.is_featured);
|
||||||
|
const queuedEventCount = React.useMemo(() => {
|
||||||
|
if (!slug) {
|
||||||
|
return queuedActions.length;
|
||||||
|
}
|
||||||
|
return queuedActions.filter((action) => action.eventSlug === slug).length;
|
||||||
|
}, [queuedActions, slug]);
|
||||||
|
|
||||||
function toggleSelection(id: number) {
|
function toggleSelection(id: number) {
|
||||||
setSelectedIds((prev) => (prev.includes(id) ? prev.filter((item) => item !== id) : [...prev, id]));
|
setSelectedIds((prev) => (prev.includes(id) ? prev.filter((item) => item !== id) : [...prev, id]));
|
||||||
@@ -231,6 +477,32 @@ export default function MobileEventPhotosPage() {
|
|||||||
setSelectionMode(false);
|
setSelectionMode(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleLightboxDragEnd = React.useCallback(
|
||||||
|
(_event: PointerEvent, info: { offset: { x: number; y: number } }) => {
|
||||||
|
if (lightboxIndex < 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { x, y } = info.offset;
|
||||||
|
const absX = Math.abs(x);
|
||||||
|
const absY = Math.abs(y);
|
||||||
|
const swipeThreshold = 80;
|
||||||
|
const dismissThreshold = 90;
|
||||||
|
|
||||||
|
if (absY > absX && y > dismissThreshold) {
|
||||||
|
setLightboxWithUrl(null, { replace: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (absX > swipeThreshold) {
|
||||||
|
const nextIndex = x < 0 ? lightboxIndex + 1 : lightboxIndex - 1;
|
||||||
|
if (nextIndex >= 0 && nextIndex < photos.length) {
|
||||||
|
setLightboxWithUrl(photos[nextIndex]?.id ?? null, { replace: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[lightboxIndex, photos, setLightboxWithUrl],
|
||||||
|
);
|
||||||
|
|
||||||
async function applyBulkAction(action: 'approve' | 'hide' | 'show' | 'feature' | 'unfeature') {
|
async function applyBulkAction(action: 'approve' | 'hide' | 'show' | 'feature' | 'unfeature') {
|
||||||
if (!slug || bulkBusy || selectedPhotos.length === 0) return;
|
if (!slug || bulkBusy || selectedPhotos.length === 0) return;
|
||||||
setBulkBusy(true);
|
setBulkBusy(true);
|
||||||
@@ -246,6 +518,19 @@ export default function MobileEventPhotosPage() {
|
|||||||
setBulkBusy(false);
|
setBulkBusy(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!online) {
|
||||||
|
let nextQueue: PhotoModerationAction[] = [];
|
||||||
|
targets.forEach((photo) => {
|
||||||
|
nextQueue = enqueuePhotoAction({ eventSlug: slug, photoId: photo.id, action });
|
||||||
|
applyOptimisticUpdate(photo.id, action);
|
||||||
|
});
|
||||||
|
setQueuedActions(nextQueue);
|
||||||
|
toast.success(t('mobilePhotos.queued', 'Aktion gespeichert. Wird synchronisiert, sobald du online bist.'));
|
||||||
|
triggerHaptic('selection');
|
||||||
|
setBulkBusy(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const results = await Promise.allSettled(
|
const results = await Promise.allSettled(
|
||||||
targets.map(async (photo) => {
|
targets.map(async (photo) => {
|
||||||
@@ -271,8 +556,8 @@ export default function MobileEventPhotosPage() {
|
|||||||
|
|
||||||
if (updates.length) {
|
if (updates.length) {
|
||||||
setPhotos((prev) => prev.map((photo) => updates.find((update) => update.id === photo.id) ?? photo));
|
setPhotos((prev) => prev.map((photo) => updates.find((update) => update.id === photo.id) ?? photo));
|
||||||
setLightbox((prev) => (prev ? updates.find((update) => update.id === prev.id) ?? prev : prev));
|
|
||||||
toast.success(t('mobilePhotos.bulkUpdated', 'Bulk update applied'));
|
toast.success(t('mobilePhotos.bulkUpdated', 'Bulk update applied'));
|
||||||
|
triggerHaptic('success');
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
toast.error(t('mobilePhotos.bulkFailed', 'Bulk update failed'));
|
toast.error(t('mobilePhotos.bulkFailed', 'Bulk update failed'));
|
||||||
@@ -375,6 +660,35 @@ export default function MobileEventPhotosPage() {
|
|||||||
</MobileCard>
|
</MobileCard>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{queuedEventCount > 0 ? (
|
||||||
|
<MobileCard>
|
||||||
|
<XStack alignItems="center" justifyContent="space-between" space="$2">
|
||||||
|
<YStack space="$1" flex={1}>
|
||||||
|
<Text fontSize="$sm" fontWeight="700" color={text}>
|
||||||
|
{t('mobilePhotos.queueTitle', 'Änderungen warten auf Sync')}
|
||||||
|
</Text>
|
||||||
|
<Text fontSize="$xs" color={muted}>
|
||||||
|
{online
|
||||||
|
? t('mobilePhotos.queueOnline', '{{count}} Aktionen bereit zum Synchronisieren.', {
|
||||||
|
count: queuedEventCount,
|
||||||
|
})
|
||||||
|
: t('mobilePhotos.queueOffline', '{{count}} Aktionen gespeichert – offline.', {
|
||||||
|
count: queuedEventCount,
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
</YStack>
|
||||||
|
<CTAButton
|
||||||
|
label={online ? t('mobilePhotos.queueSync', 'Sync') : t('mobilePhotos.queueWaiting', 'Offline')}
|
||||||
|
onPress={() => syncQueuedActions()}
|
||||||
|
tone="ghost"
|
||||||
|
fullWidth={false}
|
||||||
|
disabled={!online}
|
||||||
|
loading={syncingQueue}
|
||||||
|
/>
|
||||||
|
</XStack>
|
||||||
|
</MobileCard>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<MobileInput
|
<MobileInput
|
||||||
type="search"
|
type="search"
|
||||||
value={search}
|
value={search}
|
||||||
@@ -451,7 +765,7 @@ export default function MobileEventPhotosPage() {
|
|||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
key={photo.id}
|
key={photo.id}
|
||||||
onPress={() => (selectionMode ? toggleSelection(photo.id) : setLightbox(photo))}
|
onPress={() => (selectionMode ? toggleSelection(photo.id) : setLightboxWithUrl(photo.id))}
|
||||||
>
|
>
|
||||||
<YStack
|
<YStack
|
||||||
borderRadius={10}
|
borderRadius={10}
|
||||||
@@ -605,12 +919,21 @@ export default function MobileEventPhotosPage() {
|
|||||||
boxShadow: '0 10px 30px rgba(0,0,0,0.25)',
|
boxShadow: '0 10px 30px rgba(0,0,0,0.25)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<motion.img
|
<motion.div
|
||||||
layoutId={`photo-${lightbox.id}`}
|
drag
|
||||||
src={lightbox.url ?? lightbox.thumbnail_url ?? undefined}
|
dragElastic={0.2}
|
||||||
alt={lightbox.caption ?? 'Photo'}
|
dragConstraints={{ left: -120, right: 120, top: -120, bottom: 120 }}
|
||||||
style={{ width: '100%', maxHeight: '60vh', objectFit: 'contain', background: backdrop }}
|
dragSnapToOrigin
|
||||||
/>
|
onDragEnd={handleLightboxDragEnd}
|
||||||
|
style={{ touchAction: 'none' }}
|
||||||
|
>
|
||||||
|
<motion.img
|
||||||
|
layoutId={`photo-${lightbox.id}`}
|
||||||
|
src={lightbox.url ?? lightbox.thumbnail_url ?? undefined}
|
||||||
|
alt={lightbox.caption ?? 'Photo'}
|
||||||
|
style={{ width: '100%', maxHeight: '60vh', objectFit: 'contain', background: backdrop }}
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
<YStack padding="$3" space="$2">
|
<YStack padding="$3" space="$2">
|
||||||
<XStack space="$2" alignItems="center">
|
<XStack space="$2" alignItems="center">
|
||||||
<PillBadge tone="muted">{lightbox.uploader_name || t('events.members.roles.guest', 'Guest')}</PillBadge>
|
<PillBadge tone="muted">{lightbox.uploader_name || t('events.members.roles.guest', 'Guest')}</PillBadge>
|
||||||
@@ -657,7 +980,11 @@ export default function MobileEventPhotosPage() {
|
|||||||
style={{ flex: 1, minWidth: 140 }}
|
style={{ flex: 1, minWidth: 140 }}
|
||||||
/>
|
/>
|
||||||
</XStack>
|
</XStack>
|
||||||
<CTAButton label={t('common.close', 'Close')} tone="ghost" onPress={() => setLightbox(null)} />
|
<CTAButton
|
||||||
|
label={t('common.close', 'Close')}
|
||||||
|
tone="ghost"
|
||||||
|
onPress={() => setLightboxWithUrl(null, { replace: true })}
|
||||||
|
/>
|
||||||
</YStack>
|
</YStack>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useLocation, useNavigate, useParams } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Bell, RefreshCcw } from 'lucide-react';
|
import { Bell, Check, ChevronRight, RefreshCcw } from 'lucide-react';
|
||||||
import { YStack, XStack } from '@tamagui/stacks';
|
import { YStack, XStack } from '@tamagui/stacks';
|
||||||
import { SizableText as Text } from '@tamagui/text';
|
import { SizableText as Text } from '@tamagui/text';
|
||||||
import { Pressable } from '@tamagui/react-native-web-lite';
|
import { Pressable } from '@tamagui/react-native-web-lite';
|
||||||
|
import { motion, useAnimationControls, type PanInfo } from 'framer-motion';
|
||||||
import { MobileShell, HeaderActionButton } from './components/MobileShell';
|
import { MobileShell, HeaderActionButton } from './components/MobileShell';
|
||||||
import { MobileCard, PillBadge, SkeletonCard, CTAButton } from './components/Primitives';
|
import { MobileCard, PillBadge, SkeletonCard, CTAButton } from './components/Primitives';
|
||||||
import { MobileSelect } from './components/FormControls';
|
import { MobileSelect } from './components/FormControls';
|
||||||
@@ -15,6 +16,8 @@ import toast from 'react-hot-toast';
|
|||||||
import { MobileSheet } from './components/Sheet';
|
import { MobileSheet } from './components/Sheet';
|
||||||
import { getEvents, TenantEvent } from '../api';
|
import { getEvents, TenantEvent } from '../api';
|
||||||
import { useTheme } from '@tamagui/core';
|
import { useTheme } from '@tamagui/core';
|
||||||
|
import { triggerHaptic } from './lib/haptics';
|
||||||
|
import { adminPath } from '../constants';
|
||||||
|
|
||||||
type NotificationItem = {
|
type NotificationItem = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -27,6 +30,94 @@ type NotificationItem = {
|
|||||||
scope: 'photos' | 'guests' | 'gallery' | 'events' | 'package' | 'general';
|
scope: 'photos' | 'guests' | 'gallery' | 'events' | 'package' | 'general';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type NotificationSwipeRowProps = {
|
||||||
|
item: NotificationItem;
|
||||||
|
onOpen: (item: NotificationItem) => void;
|
||||||
|
onMarkRead: (item: NotificationItem) => void;
|
||||||
|
children: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
function NotificationSwipeRow({ item, onOpen, onMarkRead, children }: NotificationSwipeRowProps) {
|
||||||
|
const { t } = useTranslation('management');
|
||||||
|
const theme = useTheme();
|
||||||
|
const controls = useAnimationControls();
|
||||||
|
const dragged = React.useRef(false);
|
||||||
|
const markBg = String(theme.green3?.val ?? '#dcfce7');
|
||||||
|
const markText = String(theme.green10?.val ?? '#166534');
|
||||||
|
const detailBg = String(theme.blue3?.val ?? '#dbeafe');
|
||||||
|
const detailText = String(theme.blue10?.val ?? '#1d4ed8');
|
||||||
|
|
||||||
|
const handleDrag = (_event: MouseEvent | TouchEvent | PointerEvent, info: PanInfo) => {
|
||||||
|
dragged.current = Math.abs(info.offset.x) > 6;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragEnd = (_event: MouseEvent | TouchEvent | PointerEvent, info: PanInfo) => {
|
||||||
|
const swipeThreshold = 64;
|
||||||
|
const offsetX = info.offset.x;
|
||||||
|
if (offsetX > swipeThreshold && !item.is_read) {
|
||||||
|
void onMarkRead(item);
|
||||||
|
} else if (offsetX < -swipeThreshold) {
|
||||||
|
onOpen(item);
|
||||||
|
}
|
||||||
|
dragged.current = false;
|
||||||
|
void controls.start({ x: 0, transition: { type: 'spring', stiffness: 320, damping: 26 } });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePress = () => {
|
||||||
|
if (dragged.current) {
|
||||||
|
dragged.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onOpen(item);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ position: 'relative' }}>
|
||||||
|
<XStack
|
||||||
|
alignItems="center"
|
||||||
|
justifyContent="space-between"
|
||||||
|
paddingHorizontal="$3"
|
||||||
|
borderRadius="$4"
|
||||||
|
pointerEvents="none"
|
||||||
|
style={{ position: 'absolute', top: 0, right: 0, bottom: 0, left: 0 }}
|
||||||
|
>
|
||||||
|
<XStack alignItems="center" space="$2">
|
||||||
|
<Check size={16} color={markText} />
|
||||||
|
<Text fontSize="$xs" fontWeight="700" color={markText}>
|
||||||
|
{item.is_read ? t('notificationLogs.read', 'Read') : t('notificationLogs.markRead', 'Mark read')}
|
||||||
|
</Text>
|
||||||
|
</XStack>
|
||||||
|
<XStack alignItems="center" space="$2">
|
||||||
|
<Text fontSize="$xs" fontWeight="700" color={detailText}>
|
||||||
|
Details
|
||||||
|
</Text>
|
||||||
|
<ChevronRight size={16} color={detailText} />
|
||||||
|
</XStack>
|
||||||
|
</XStack>
|
||||||
|
<XStack
|
||||||
|
borderRadius="$4"
|
||||||
|
overflow="hidden"
|
||||||
|
pointerEvents="none"
|
||||||
|
backgroundColor={item.is_read ? detailBg : markBg}
|
||||||
|
opacity={0.5}
|
||||||
|
style={{ position: 'absolute', top: 0, right: 0, bottom: 0, left: 0 }}
|
||||||
|
/>
|
||||||
|
<motion.div
|
||||||
|
drag="x"
|
||||||
|
dragElastic={0.2}
|
||||||
|
dragConstraints={{ left: -96, right: 96 }}
|
||||||
|
onDrag={handleDrag}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
animate={controls}
|
||||||
|
initial={{ x: 0 }}
|
||||||
|
style={{ touchAction: 'pan-y', position: 'relative', zIndex: 1 }}
|
||||||
|
>
|
||||||
|
<Pressable onPress={handlePress}>{children}</Pressable>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function formatLog(
|
function formatLog(
|
||||||
log: NotificationLogEntry,
|
log: NotificationLogEntry,
|
||||||
t: (key: string, defaultValue?: string, options?: Record<string, unknown>) => string,
|
t: (key: string, defaultValue?: string, options?: Record<string, unknown>) => string,
|
||||||
@@ -207,6 +298,8 @@ async function loadNotifications(
|
|||||||
|
|
||||||
export default function MobileNotificationsPage() {
|
export default function MobileNotificationsPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
const { notificationId } = useParams<{ notificationId?: string }>();
|
||||||
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;
|
||||||
@@ -251,6 +344,20 @@ export default function MobileNotificationsPage() {
|
|||||||
void reload();
|
void reload();
|
||||||
}, [reload]);
|
}, [reload]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const handleMessage = (event: MessageEvent) => {
|
||||||
|
if (event.data?.type === 'admin-notification-refresh') {
|
||||||
|
void reload();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
navigator.serviceWorker?.addEventListener('message', handleMessage);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
navigator.serviceWorker?.removeEventListener('message', handleMessage);
|
||||||
|
};
|
||||||
|
}, [reload]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -287,19 +394,58 @@ export default function MobileNotificationsPage() {
|
|||||||
|
|
||||||
const showFilterNotice = Boolean(slug) && filtered.length === 0 && notifications.length > 0;
|
const showFilterNotice = Boolean(slug) && filtered.length === 0 && notifications.length > 0;
|
||||||
|
|
||||||
const markSelectedRead = async () => {
|
const markNotificationRead = React.useCallback(
|
||||||
|
async (item: NotificationItem, options?: { close?: boolean }) => {
|
||||||
|
const id = Number(item.id);
|
||||||
|
if (!Number.isFinite(id)) return;
|
||||||
|
try {
|
||||||
|
await markNotificationLogs([id], 'read');
|
||||||
|
await reload();
|
||||||
|
triggerHaptic('success');
|
||||||
|
if (options?.close) {
|
||||||
|
setDetailOpen(false);
|
||||||
|
setSelectedNotification(null);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error(t('notificationLogs.markFailed', 'Could not update notifications.'));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[reload, t],
|
||||||
|
);
|
||||||
|
|
||||||
|
const markSelectedRead = React.useCallback(async () => {
|
||||||
if (!selectedNotification) return;
|
if (!selectedNotification) return;
|
||||||
const id = Number(selectedNotification.id);
|
await markNotificationRead(selectedNotification, { close: true });
|
||||||
if (!Number.isFinite(id)) return;
|
}, [markNotificationRead, selectedNotification]);
|
||||||
try {
|
|
||||||
await markNotificationLogs([id], 'read');
|
const notificationListPath = adminPath('/mobile/notifications');
|
||||||
await reload();
|
|
||||||
setDetailOpen(false);
|
const openNotification = React.useCallback(
|
||||||
setSelectedNotification(null);
|
(item: NotificationItem) => {
|
||||||
} catch {
|
setSelectedNotification(item);
|
||||||
toast.error(t('notificationLogs.markFailed', 'Could not update notifications.'));
|
setDetailOpen(true);
|
||||||
|
if (notificationId !== String(item.id)) {
|
||||||
|
navigate(`${notificationListPath}/${item.id}${location.search}`, { replace: false });
|
||||||
|
}
|
||||||
|
triggerHaptic('light');
|
||||||
|
},
|
||||||
|
[location.search, navigate, notificationId, notificationListPath],
|
||||||
|
);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!notificationId || loading) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
};
|
const targetId = Number(notificationId);
|
||||||
|
if (!Number.isFinite(targetId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const target = notifications.find((item) => Number(item.id) === targetId);
|
||||||
|
if (target) {
|
||||||
|
setSelectedNotification(target);
|
||||||
|
setDetailOpen(true);
|
||||||
|
}
|
||||||
|
}, [notificationId, notifications, loading]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MobileShell
|
<MobileShell
|
||||||
@@ -327,7 +473,7 @@ export default function MobileNotificationsPage() {
|
|||||||
</Text>
|
</Text>
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
navigate('/admin/mobile/notifications', { replace: true });
|
navigate(notificationListPath, { replace: true });
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Text fontSize="$sm" color={primary} fontWeight="700">
|
<Text fontSize="$sm" color={primary} fontWeight="700">
|
||||||
@@ -340,7 +486,15 @@ export default function MobileNotificationsPage() {
|
|||||||
<XStack space="$2" marginBottom="$2">
|
<XStack space="$2" marginBottom="$2">
|
||||||
<MobileSelect
|
<MobileSelect
|
||||||
value={statusParam}
|
value={statusParam}
|
||||||
onChange={(e) => navigate(`/admin/mobile/notifications?${new URLSearchParams({ status: e.target.value, scope: scopeParam, event: slug ?? '' }).toString()}`)}
|
onChange={(e) =>
|
||||||
|
navigate(
|
||||||
|
`${notificationListPath}?${new URLSearchParams({
|
||||||
|
status: e.target.value,
|
||||||
|
scope: scopeParam,
|
||||||
|
event: slug ?? '',
|
||||||
|
}).toString()}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
compact
|
compact
|
||||||
style={{ minWidth: 120 }}
|
style={{ minWidth: 120 }}
|
||||||
>
|
>
|
||||||
@@ -350,7 +504,15 @@ export default function MobileNotificationsPage() {
|
|||||||
</MobileSelect>
|
</MobileSelect>
|
||||||
<MobileSelect
|
<MobileSelect
|
||||||
value={scopeParam}
|
value={scopeParam}
|
||||||
onChange={(e) => navigate(`/admin/mobile/notifications?${new URLSearchParams({ scope: e.target.value, status: statusParam, event: slug ?? '' }).toString()}`)}
|
onChange={(e) =>
|
||||||
|
navigate(
|
||||||
|
`${notificationListPath}?${new URLSearchParams({
|
||||||
|
scope: e.target.value,
|
||||||
|
status: statusParam,
|
||||||
|
event: slug ?? '',
|
||||||
|
}).toString()}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
compact
|
compact
|
||||||
style={{ minWidth: 140 }}
|
style={{ minWidth: 140 }}
|
||||||
>
|
>
|
||||||
@@ -369,6 +531,7 @@ export default function MobileNotificationsPage() {
|
|||||||
try {
|
try {
|
||||||
await markNotificationLogs(unreadIds, 'read');
|
await markNotificationLogs(unreadIds, 'read');
|
||||||
void reload();
|
void reload();
|
||||||
|
triggerHaptic('success');
|
||||||
} catch {
|
} catch {
|
||||||
toast.error(t('notificationLogs.markFailed', 'Could not update notifications.'));
|
toast.error(t('notificationLogs.markFailed', 'Could not update notifications.'));
|
||||||
}
|
}
|
||||||
@@ -401,12 +564,11 @@ export default function MobileNotificationsPage() {
|
|||||||
</Pressable>
|
</Pressable>
|
||||||
) : null}
|
) : null}
|
||||||
{statusFiltered.map((item) => (
|
{statusFiltered.map((item) => (
|
||||||
<Pressable
|
<NotificationSwipeRow
|
||||||
key={item.id}
|
key={item.id}
|
||||||
onPress={() => {
|
item={item}
|
||||||
setSelectedNotification(item);
|
onOpen={openNotification}
|
||||||
setDetailOpen(true);
|
onMarkRead={markNotificationRead}
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<MobileCard space="$2" borderColor={item.is_read ? border : primary}>
|
<MobileCard space="$2" borderColor={item.is_read ? border : primary}>
|
||||||
<XStack alignItems="center" space="$2">
|
<XStack alignItems="center" space="$2">
|
||||||
@@ -432,7 +594,7 @@ export default function MobileNotificationsPage() {
|
|||||||
<PillBadge tone={item.tone === 'warning' ? 'warning' : 'muted'}>{item.time}</PillBadge>
|
<PillBadge tone={item.tone === 'warning' ? 'warning' : 'muted'}>{item.time}</PillBadge>
|
||||||
</XStack>
|
</XStack>
|
||||||
</MobileCard>
|
</MobileCard>
|
||||||
</Pressable>
|
</NotificationSwipeRow>
|
||||||
))}
|
))}
|
||||||
</YStack>
|
</YStack>
|
||||||
)}
|
)}
|
||||||
@@ -442,6 +604,9 @@ export default function MobileNotificationsPage() {
|
|||||||
onClose={() => {
|
onClose={() => {
|
||||||
setDetailOpen(false);
|
setDetailOpen(false);
|
||||||
setSelectedNotification(null);
|
setSelectedNotification(null);
|
||||||
|
if (notificationId) {
|
||||||
|
navigate(`${notificationListPath}${location.search}`, { replace: true });
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
title={selectedNotification?.title ?? t('mobileNotifications.title', 'Notifications')}
|
title={selectedNotification?.title ?? t('mobileNotifications.title', 'Notifications')}
|
||||||
footer={
|
footer={
|
||||||
@@ -489,7 +654,7 @@ export default function MobileNotificationsPage() {
|
|||||||
onPress={() => {
|
onPress={() => {
|
||||||
setShowEventPicker(false);
|
setShowEventPicker(false);
|
||||||
if (ev.slug) {
|
if (ev.slug) {
|
||||||
navigate(`/admin/mobile/notifications?event=${ev.slug}`);
|
navigate(`${notificationListPath}?event=${ev.slug}`);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Shield, Bell, User } from 'lucide-react';
|
import { Shield, Bell, User, Smartphone } from 'lucide-react';
|
||||||
import { YStack, XStack } from '@tamagui/stacks';
|
import { YStack, XStack } from '@tamagui/stacks';
|
||||||
import { YGroup } from '@tamagui/group';
|
import { YGroup } from '@tamagui/group';
|
||||||
import { ListItem } from '@tamagui/list-item';
|
import { ListItem } from '@tamagui/list-item';
|
||||||
@@ -18,6 +18,9 @@ import {
|
|||||||
} from '../api';
|
} from '../api';
|
||||||
import { getApiErrorMessage } from '../lib/apiError';
|
import { getApiErrorMessage } from '../lib/apiError';
|
||||||
import { adminPath } from '../constants';
|
import { adminPath } from '../constants';
|
||||||
|
import { useAdminPushSubscription } from './hooks/useAdminPushSubscription';
|
||||||
|
import { useDevicePermissions } from './hooks/useDevicePermissions';
|
||||||
|
import { type PermissionStatus, type StorageStatus } from './lib/devicePermissions';
|
||||||
|
|
||||||
type PreferenceKey = keyof NotificationPreferences;
|
type PreferenceKey = keyof NotificationPreferences;
|
||||||
|
|
||||||
@@ -47,6 +50,48 @@ export default function MobileSettingsPage() {
|
|||||||
const [loading, setLoading] = React.useState(true);
|
const [loading, setLoading] = React.useState(true);
|
||||||
const [saving, setSaving] = React.useState(false);
|
const [saving, setSaving] = React.useState(false);
|
||||||
const [error, setError] = React.useState<string | null>(null);
|
const [error, setError] = React.useState<string | null>(null);
|
||||||
|
const [storageSaving, setStorageSaving] = React.useState(false);
|
||||||
|
const [storageError, setStorageError] = React.useState<string | null>(null);
|
||||||
|
const pushState = useAdminPushSubscription();
|
||||||
|
const devicePermissions = useDevicePermissions();
|
||||||
|
|
||||||
|
const pushDescription = React.useMemo(() => {
|
||||||
|
if (!pushState.supported) {
|
||||||
|
return t('mobileSettings.pushUnsupported', 'Push wird auf diesem Gerät nicht unterstützt.');
|
||||||
|
}
|
||||||
|
if (pushState.permission === 'denied') {
|
||||||
|
return t('mobileSettings.pushDenied', 'Benachrichtigungen sind im Browser blockiert.');
|
||||||
|
}
|
||||||
|
if (pushState.subscribed) {
|
||||||
|
return t('mobileSettings.pushActive', 'Push aktiv');
|
||||||
|
}
|
||||||
|
return t('mobileSettings.pushInactive', 'Push deaktiviert');
|
||||||
|
}, [pushState.permission, pushState.subscribed, pushState.supported, t]);
|
||||||
|
|
||||||
|
const permissionTone = (status: PermissionStatus) => {
|
||||||
|
if (status === 'granted') {
|
||||||
|
return 'success';
|
||||||
|
}
|
||||||
|
if (status === 'denied' || status === 'prompt') {
|
||||||
|
return 'warning';
|
||||||
|
}
|
||||||
|
return 'muted';
|
||||||
|
};
|
||||||
|
|
||||||
|
const storageTone = (status: StorageStatus) => {
|
||||||
|
if (status === 'persisted') {
|
||||||
|
return 'success';
|
||||||
|
}
|
||||||
|
if (status === 'available') {
|
||||||
|
return 'warning';
|
||||||
|
}
|
||||||
|
return 'muted';
|
||||||
|
};
|
||||||
|
|
||||||
|
const permissionLabel = (status: PermissionStatus) =>
|
||||||
|
t(`mobileSettings.deviceStatusValues.${status}`, status);
|
||||||
|
const storageLabel = (status: StorageStatus) =>
|
||||||
|
t(`mobileSettings.deviceStatusValues.${status}`, status);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
@@ -71,6 +116,12 @@ export default function MobileSettingsPage() {
|
|||||||
})();
|
})();
|
||||||
}, [t]);
|
}, [t]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (devicePermissions.storage === 'persisted') {
|
||||||
|
setStorageError(null);
|
||||||
|
}
|
||||||
|
}, [devicePermissions.storage]);
|
||||||
|
|
||||||
const togglePref = (key: PreferenceKey) => {
|
const togglePref = (key: PreferenceKey) => {
|
||||||
setPreferences((prev) => ({
|
setPreferences((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
@@ -98,6 +149,20 @@ export default function MobileSettingsPage() {
|
|||||||
setPreferences(defaults);
|
setPreferences(defaults);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleStoragePersist = async () => {
|
||||||
|
setStorageSaving(true);
|
||||||
|
const granted = await devicePermissions.requestPersistentStorage();
|
||||||
|
setStorageSaving(false);
|
||||||
|
if (granted) {
|
||||||
|
setStorageError(null);
|
||||||
|
void devicePermissions.refresh();
|
||||||
|
} else {
|
||||||
|
setStorageError(
|
||||||
|
t('mobileSettings.deviceStorageError', 'Offline-Schutz konnte nicht aktiviert werden.')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MobileShell activeTab="profile" title={t('mobileSettings.title', 'Settings')} onBack={() => navigate(-1)}>
|
<MobileShell activeTab="profile" title={t('mobileSettings.title', 'Settings')} onBack={() => navigate(-1)}>
|
||||||
{error ? (
|
{error ? (
|
||||||
@@ -140,6 +205,43 @@ export default function MobileSettingsPage() {
|
|||||||
</Text>
|
</Text>
|
||||||
) : (
|
) : (
|
||||||
<YGroup borderRadius="$4" borderWidth={1} borderColor={border} overflow="hidden">
|
<YGroup borderRadius="$4" borderWidth={1} borderColor={border} overflow="hidden">
|
||||||
|
<YGroup.Item bordered>
|
||||||
|
<ListItem
|
||||||
|
hoverTheme
|
||||||
|
pressTheme
|
||||||
|
paddingVertical="$2"
|
||||||
|
paddingHorizontal="$3"
|
||||||
|
title={
|
||||||
|
<Text fontSize="$sm" color={text} fontWeight="700">
|
||||||
|
{t('mobileSettings.pushTitle', 'App Push')}
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
|
subTitle={
|
||||||
|
<Text fontSize="$xs" color={muted}>
|
||||||
|
{pushState.loading
|
||||||
|
? t('mobileSettings.pushLoading', 'Lädt ...')
|
||||||
|
: pushDescription}
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
|
iconAfter={
|
||||||
|
<Switch
|
||||||
|
size="$4"
|
||||||
|
checked={pushState.subscribed}
|
||||||
|
onCheckedChange={(value) => {
|
||||||
|
if (value) {
|
||||||
|
void pushState.enable();
|
||||||
|
} else {
|
||||||
|
void pushState.disable();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={!pushState.supported || pushState.permission === 'denied' || pushState.loading}
|
||||||
|
aria-label={t('mobileSettings.pushTitle', 'App Push')}
|
||||||
|
>
|
||||||
|
<Switch.Thumb />
|
||||||
|
</Switch>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</YGroup.Item>
|
||||||
{AVAILABLE_PREFS.map((key, index) => (
|
{AVAILABLE_PREFS.map((key, index) => (
|
||||||
<YGroup.Item key={key} bordered={index < AVAILABLE_PREFS.length - 1}>
|
<YGroup.Item key={key} bordered={index < AVAILABLE_PREFS.length - 1}>
|
||||||
<ListItem
|
<ListItem
|
||||||
@@ -172,12 +274,88 @@ export default function MobileSettingsPage() {
|
|||||||
))}
|
))}
|
||||||
</YGroup>
|
</YGroup>
|
||||||
)}
|
)}
|
||||||
|
{pushState.error ? (
|
||||||
|
<Text fontSize="$xs" color="#b91c1c">
|
||||||
|
{pushState.error}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
<XStack space="$2">
|
<XStack space="$2">
|
||||||
<CTAButton label={saving ? t('common.processing', '...') : t('settings.notifications.actions.save', 'Speichern')} onPress={() => handleSave()} />
|
<CTAButton label={saving ? t('common.processing', '...') : t('settings.notifications.actions.save', 'Speichern')} onPress={() => handleSave()} />
|
||||||
<CTAButton label={t('common.reset', 'Reset')} tone="ghost" onPress={() => handleReset()} />
|
<CTAButton label={t('common.reset', 'Reset')} tone="ghost" onPress={() => handleReset()} />
|
||||||
</XStack>
|
</XStack>
|
||||||
</MobileCard>
|
</MobileCard>
|
||||||
|
|
||||||
|
<MobileCard space="$3">
|
||||||
|
<XStack alignItems="center" space="$2">
|
||||||
|
<Smartphone size={18} color={text} />
|
||||||
|
<Text fontSize="$md" fontWeight="800" color={text}>
|
||||||
|
{t('mobileSettings.deviceTitle', 'Device & permissions')}
|
||||||
|
</Text>
|
||||||
|
</XStack>
|
||||||
|
<Text fontSize="$sm" color={muted}>
|
||||||
|
{t('mobileSettings.deviceDescription', 'Check permissions so the admin app stays fast and offline-ready.')}
|
||||||
|
</Text>
|
||||||
|
{devicePermissions.loading ? (
|
||||||
|
<Text fontSize="$sm" color={muted}>
|
||||||
|
{t('mobileSettings.deviceLoading', 'Checking device status ...')}
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<YStack space="$2">
|
||||||
|
<XStack alignItems="center" justifyContent="space-between" gap="$2">
|
||||||
|
<YStack flex={1} space="$1">
|
||||||
|
<Text fontSize="$sm" fontWeight="700" color={text}>
|
||||||
|
{t('mobileSettings.deviceStatus.notifications.label', 'Notifications')}
|
||||||
|
</Text>
|
||||||
|
<Text fontSize="$xs" color={muted}>
|
||||||
|
{t('mobileSettings.deviceStatus.notifications.description', 'Allow alerts and admin updates.')}
|
||||||
|
</Text>
|
||||||
|
</YStack>
|
||||||
|
<PillBadge tone={permissionTone(devicePermissions.notifications)}>
|
||||||
|
{permissionLabel(devicePermissions.notifications)}
|
||||||
|
</PillBadge>
|
||||||
|
</XStack>
|
||||||
|
<XStack alignItems="center" justifyContent="space-between" gap="$2">
|
||||||
|
<YStack flex={1} space="$1">
|
||||||
|
<Text fontSize="$sm" fontWeight="700" color={text}>
|
||||||
|
{t('mobileSettings.deviceStatus.camera.label', 'Camera')}
|
||||||
|
</Text>
|
||||||
|
<Text fontSize="$xs" color={muted}>
|
||||||
|
{t('mobileSettings.deviceStatus.camera.description', 'Needed for QR scans and quick capture.')}
|
||||||
|
</Text>
|
||||||
|
</YStack>
|
||||||
|
<PillBadge tone={permissionTone(devicePermissions.camera)}>
|
||||||
|
{permissionLabel(devicePermissions.camera)}
|
||||||
|
</PillBadge>
|
||||||
|
</XStack>
|
||||||
|
<XStack alignItems="center" justifyContent="space-between" gap="$2">
|
||||||
|
<YStack flex={1} space="$1">
|
||||||
|
<Text fontSize="$sm" fontWeight="700" color={text}>
|
||||||
|
{t('mobileSettings.deviceStatus.storage.label', 'Offline storage')}
|
||||||
|
</Text>
|
||||||
|
<Text fontSize="$xs" color={muted}>
|
||||||
|
{t('mobileSettings.deviceStatus.storage.description', 'Protect cached data from eviction.')}
|
||||||
|
</Text>
|
||||||
|
</YStack>
|
||||||
|
<PillBadge tone={storageTone(devicePermissions.storage)}>
|
||||||
|
{storageLabel(devicePermissions.storage)}
|
||||||
|
</PillBadge>
|
||||||
|
</XStack>
|
||||||
|
</YStack>
|
||||||
|
)}
|
||||||
|
{devicePermissions.storage === 'available' ? (
|
||||||
|
<CTAButton
|
||||||
|
label={storageSaving ? t('common.processing', '...') : t('mobileSettings.deviceStorageAction', 'Enable offline protection')}
|
||||||
|
onPress={() => handleStoragePersist()}
|
||||||
|
disabled={storageSaving}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{storageError ? (
|
||||||
|
<Text fontSize="$xs" color="#b91c1c">
|
||||||
|
{storageError}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</MobileCard>
|
||||||
|
|
||||||
<MobileCard space="$3">
|
<MobileCard space="$3">
|
||||||
<XStack alignItems="center" space="$2">
|
<XStack alignItems="center" space="$2">
|
||||||
<User size={18} color={text} />
|
<User size={18} color={text} />
|
||||||
|
|||||||
170
resources/js/admin/mobile/hooks/useAdminPushSubscription.ts
Normal file
170
resources/js/admin/mobile/hooks/useAdminPushSubscription.ts
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { getAdminPushConfig } from '../../lib/runtime-config';
|
||||||
|
import { registerAdminPushSubscription, unregisterAdminPushSubscription } from '../../api';
|
||||||
|
import { getAdminDeviceId } from '../../lib/device';
|
||||||
|
|
||||||
|
type PushSubscriptionState = {
|
||||||
|
supported: boolean;
|
||||||
|
permission: NotificationPermission;
|
||||||
|
subscribed: boolean;
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
enable: () => Promise<void>;
|
||||||
|
disable: () => Promise<void>;
|
||||||
|
refresh: () => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useAdminPushSubscription(): PushSubscriptionState {
|
||||||
|
const pushConfig = React.useMemo(() => getAdminPushConfig(), []);
|
||||||
|
const supported = React.useMemo(() => {
|
||||||
|
return typeof window !== 'undefined'
|
||||||
|
&& typeof navigator !== 'undefined'
|
||||||
|
&& typeof Notification !== 'undefined'
|
||||||
|
&& 'serviceWorker' in navigator
|
||||||
|
&& 'PushManager' in window
|
||||||
|
&& pushConfig.enabled;
|
||||||
|
}, [pushConfig.enabled]);
|
||||||
|
|
||||||
|
const [permission, setPermission] = React.useState<NotificationPermission>(() => {
|
||||||
|
if (typeof Notification === 'undefined') {
|
||||||
|
return 'default';
|
||||||
|
}
|
||||||
|
|
||||||
|
return Notification.permission;
|
||||||
|
});
|
||||||
|
const [subscription, setSubscription] = React.useState<PushSubscription | null>(null);
|
||||||
|
const [loading, setLoading] = React.useState(false);
|
||||||
|
const [error, setError] = React.useState<string | null>(null);
|
||||||
|
|
||||||
|
const refresh = React.useCallback(async () => {
|
||||||
|
if (!supported) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const registration = await navigator.serviceWorker.ready;
|
||||||
|
const current = await registration.pushManager.getSubscription();
|
||||||
|
setSubscription(current);
|
||||||
|
setPermission(Notification.permission);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Unable to refresh admin push subscription', err);
|
||||||
|
setSubscription(null);
|
||||||
|
}
|
||||||
|
}, [supported]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!supported) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
void refresh();
|
||||||
|
|
||||||
|
const handleMessage = (event: MessageEvent) => {
|
||||||
|
if (event.data?.type === 'push-subscription-change') {
|
||||||
|
void refresh();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
navigator.serviceWorker?.addEventListener('message', handleMessage);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
navigator.serviceWorker?.removeEventListener('message', handleMessage);
|
||||||
|
};
|
||||||
|
}, [refresh, supported]);
|
||||||
|
|
||||||
|
const enable = React.useCallback(async () => {
|
||||||
|
if (!supported) {
|
||||||
|
setError('Push-Benachrichtigungen werden auf diesem Gerät nicht unterstützt.');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const permissionResult = await Notification.requestPermission();
|
||||||
|
setPermission(permissionResult);
|
||||||
|
|
||||||
|
if (permissionResult !== 'granted') {
|
||||||
|
throw new Error('Bitte erlaube Benachrichtigungen, um Push zu aktivieren.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const registration = await navigator.serviceWorker.ready;
|
||||||
|
const existing = await registration.pushManager.getSubscription();
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
await registerAdminPushSubscription(existing, getAdminDeviceId());
|
||||||
|
setSubscription(existing);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!pushConfig.vapidPublicKey) {
|
||||||
|
throw new Error('Push-Konfiguration ist nicht vollständig.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const newSubscription = await registration.pushManager.subscribe({
|
||||||
|
userVisibleOnly: true,
|
||||||
|
applicationServerKey: urlBase64ToUint8Array(pushConfig.vapidPublicKey).buffer as ArrayBuffer,
|
||||||
|
});
|
||||||
|
|
||||||
|
await registerAdminPushSubscription(newSubscription, getAdminDeviceId());
|
||||||
|
setSubscription(newSubscription);
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : 'Push konnte nicht aktiviert werden.';
|
||||||
|
setError(message);
|
||||||
|
console.error(err);
|
||||||
|
await refresh();
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [pushConfig.vapidPublicKey, refresh, supported]);
|
||||||
|
|
||||||
|
const disable = React.useCallback(async () => {
|
||||||
|
if (!supported || !subscription) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await unregisterAdminPushSubscription(subscription.endpoint);
|
||||||
|
await subscription.unsubscribe();
|
||||||
|
setSubscription(null);
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : 'Push konnte nicht deaktiviert werden.';
|
||||||
|
setError(message);
|
||||||
|
console.error(err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [subscription, supported]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
supported,
|
||||||
|
permission,
|
||||||
|
subscribed: Boolean(subscription),
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
enable,
|
||||||
|
disable,
|
||||||
|
refresh,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function urlBase64ToUint8Array(base64String: string): Uint8Array {
|
||||||
|
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
|
||||||
|
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
|
||||||
|
const rawData = typeof window !== 'undefined'
|
||||||
|
? window.atob(base64)
|
||||||
|
: Buffer.from(base64, 'base64').toString('binary');
|
||||||
|
const outputArray = new Uint8Array(rawData.length);
|
||||||
|
|
||||||
|
for (let i = 0; i < rawData.length; i += 1) {
|
||||||
|
outputArray[i] = rawData.charCodeAt(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
return outputArray;
|
||||||
|
}
|
||||||
97
resources/js/admin/mobile/hooks/useDevicePermissions.ts
Normal file
97
resources/js/admin/mobile/hooks/useDevicePermissions.ts
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
normalizePermissionState,
|
||||||
|
resolveStorageStatus,
|
||||||
|
type PermissionStatus,
|
||||||
|
type StorageStatus,
|
||||||
|
} from '../lib/devicePermissions';
|
||||||
|
|
||||||
|
type DevicePermissionsState = {
|
||||||
|
notifications: PermissionStatus;
|
||||||
|
camera: PermissionStatus;
|
||||||
|
storage: StorageStatus;
|
||||||
|
};
|
||||||
|
|
||||||
|
type DevicePermissionsHook = DevicePermissionsState & {
|
||||||
|
loading: boolean;
|
||||||
|
refresh: () => Promise<void>;
|
||||||
|
requestPersistentStorage: () => Promise<boolean>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useDevicePermissions(): DevicePermissionsHook {
|
||||||
|
const [permissions, setPermissions] = React.useState<DevicePermissionsState>({
|
||||||
|
notifications: 'unsupported',
|
||||||
|
camera: 'unsupported',
|
||||||
|
storage: 'unsupported',
|
||||||
|
});
|
||||||
|
const [loading, setLoading] = React.useState(true);
|
||||||
|
|
||||||
|
const refresh = React.useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (typeof window === 'undefined' || typeof navigator === 'undefined') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let notificationState: PermissionStatus = 'unsupported';
|
||||||
|
if ('Notification' in window) {
|
||||||
|
notificationState = normalizePermissionState(Notification.permission);
|
||||||
|
}
|
||||||
|
|
||||||
|
let cameraState: PermissionStatus = 'unsupported';
|
||||||
|
if (navigator.permissions?.query) {
|
||||||
|
try {
|
||||||
|
const cameraPermission = await navigator.permissions.query({
|
||||||
|
name: 'camera' as PermissionName,
|
||||||
|
});
|
||||||
|
cameraState = normalizePermissionState(cameraPermission.state);
|
||||||
|
} catch {
|
||||||
|
cameraState = 'unsupported';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const storageSupported = Boolean(navigator.storage?.persisted);
|
||||||
|
let persisted: boolean | null = null;
|
||||||
|
if (storageSupported) {
|
||||||
|
persisted = await navigator.storage.persisted();
|
||||||
|
}
|
||||||
|
|
||||||
|
setPermissions({
|
||||||
|
notifications: notificationState,
|
||||||
|
camera: cameraState,
|
||||||
|
storage: resolveStorageStatus(persisted, storageSupported),
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const requestPersistentStorage = React.useCallback(async () => {
|
||||||
|
if (typeof navigator === 'undefined' || !navigator.storage?.persist) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const granted = await navigator.storage.persist();
|
||||||
|
setPermissions((prev) => ({
|
||||||
|
...prev,
|
||||||
|
storage: granted ? 'persisted' : 'available',
|
||||||
|
}));
|
||||||
|
return granted;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
void refresh();
|
||||||
|
}, [refresh]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...permissions,
|
||||||
|
loading,
|
||||||
|
refresh,
|
||||||
|
requestPersistentStorage,
|
||||||
|
};
|
||||||
|
}
|
||||||
34
resources/js/admin/mobile/lib/devicePermissions.test.ts
Normal file
34
resources/js/admin/mobile/lib/devicePermissions.test.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { normalizePermissionState, resolveStorageStatus } from './devicePermissions';
|
||||||
|
|
||||||
|
describe('normalizePermissionState', () => {
|
||||||
|
it('maps default to prompt', () => {
|
||||||
|
expect(normalizePermissionState('default')).toBe('prompt');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps undefined to unsupported', () => {
|
||||||
|
expect(normalizePermissionState(undefined)).toBe('unsupported');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes through granted', () => {
|
||||||
|
expect(normalizePermissionState('granted')).toBe('granted');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes through denied', () => {
|
||||||
|
expect(normalizePermissionState('denied')).toBe('denied');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('resolveStorageStatus', () => {
|
||||||
|
it('returns unsupported when not supported', () => {
|
||||||
|
expect(resolveStorageStatus(null, false)).toBe('unsupported');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns persisted when granted', () => {
|
||||||
|
expect(resolveStorageStatus(true, true)).toBe('persisted');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns available when supported but not persisted', () => {
|
||||||
|
expect(resolveStorageStatus(false, true)).toBe('available');
|
||||||
|
});
|
||||||
|
});
|
||||||
28
resources/js/admin/mobile/lib/devicePermissions.ts
Normal file
28
resources/js/admin/mobile/lib/devicePermissions.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
export type PermissionStatus = 'granted' | 'denied' | 'prompt' | 'unsupported';
|
||||||
|
export type StorageStatus = 'persisted' | 'available' | 'unsupported';
|
||||||
|
|
||||||
|
type RawPermissionState = PermissionState | 'default' | null | undefined;
|
||||||
|
|
||||||
|
export function normalizePermissionState(state: RawPermissionState): PermissionStatus {
|
||||||
|
if (!state) {
|
||||||
|
return 'unsupported';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state === 'default') {
|
||||||
|
return 'prompt';
|
||||||
|
}
|
||||||
|
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveStorageStatus(persisted: boolean | null, supported: boolean): StorageStatus {
|
||||||
|
if (!supported) {
|
||||||
|
return 'unsupported';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (persisted) {
|
||||||
|
return 'persisted';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'available';
|
||||||
|
}
|
||||||
19
resources/js/admin/mobile/lib/haptics.test.ts
Normal file
19
resources/js/admin/mobile/lib/haptics.test.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { triggerHaptic } from './haptics';
|
||||||
|
|
||||||
|
describe('triggerHaptic', () => {
|
||||||
|
it('uses navigator.vibrate when available', () => {
|
||||||
|
const vibrate = vi.fn();
|
||||||
|
Object.defineProperty(navigator, 'vibrate', { value: vibrate, configurable: true });
|
||||||
|
|
||||||
|
triggerHaptic();
|
||||||
|
|
||||||
|
expect(vibrate).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does nothing when vibrate is unavailable', () => {
|
||||||
|
Object.defineProperty(navigator, 'vibrate', { value: undefined, configurable: true });
|
||||||
|
|
||||||
|
expect(() => triggerHaptic('success')).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
19
resources/js/admin/mobile/lib/haptics.ts
Normal file
19
resources/js/admin/mobile/lib/haptics.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
export type HapticStyle = 'light' | 'medium' | 'heavy' | 'success' | 'warning' | 'error' | 'selection';
|
||||||
|
|
||||||
|
const PATTERNS: Record<HapticStyle, number | number[]> = {
|
||||||
|
light: 10,
|
||||||
|
medium: 18,
|
||||||
|
heavy: 26,
|
||||||
|
success: [10, 28, 10],
|
||||||
|
warning: [22, 30, 16],
|
||||||
|
error: [30, 30, 30],
|
||||||
|
selection: 12,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function triggerHaptic(style: HapticStyle = 'selection'): void {
|
||||||
|
if (typeof navigator === 'undefined' || typeof navigator.vibrate !== 'function') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
navigator.vibrate(PATTERNS[style] ?? PATTERNS.selection);
|
||||||
|
}
|
||||||
49
resources/js/admin/mobile/lib/photoModerationQueue.test.ts
Normal file
49
resources/js/admin/mobile/lib/photoModerationQueue.test.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
enqueuePhotoAction,
|
||||||
|
loadPhotoQueue,
|
||||||
|
removePhotoAction,
|
||||||
|
replacePhotoQueue,
|
||||||
|
type PhotoModerationAction,
|
||||||
|
} from './photoModerationQueue';
|
||||||
|
|
||||||
|
describe('photoModerationQueue', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
window.localStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('enqueues and loads actions', () => {
|
||||||
|
const queue = enqueuePhotoAction({ eventSlug: 'demo-event', photoId: 12, action: 'approve' });
|
||||||
|
|
||||||
|
expect(queue).toHaveLength(1);
|
||||||
|
const loaded = loadPhotoQueue();
|
||||||
|
expect(loaded).toHaveLength(1);
|
||||||
|
expect(loaded[0]?.eventSlug).toBe('demo-event');
|
||||||
|
expect(loaded[0]?.photoId).toBe(12);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes actions by id', () => {
|
||||||
|
const queue = enqueuePhotoAction({ eventSlug: 'demo-event', photoId: 12, action: 'approve' });
|
||||||
|
const next = removePhotoAction(queue, queue[0]!.id);
|
||||||
|
|
||||||
|
expect(next).toHaveLength(0);
|
||||||
|
expect(loadPhotoQueue()).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('replaces the queue', () => {
|
||||||
|
enqueuePhotoAction({ eventSlug: 'demo-event', photoId: 12, action: 'approve' });
|
||||||
|
const next: PhotoModerationAction[] = [
|
||||||
|
{
|
||||||
|
id: 'fixed',
|
||||||
|
eventSlug: 'another',
|
||||||
|
photoId: 99,
|
||||||
|
action: 'hide',
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
replacePhotoQueue(next);
|
||||||
|
|
||||||
|
expect(loadPhotoQueue()).toHaveLength(1);
|
||||||
|
expect(loadPhotoQueue()[0]?.id).toBe('fixed');
|
||||||
|
});
|
||||||
|
});
|
||||||
69
resources/js/admin/mobile/lib/photoModerationQueue.ts
Normal file
69
resources/js/admin/mobile/lib/photoModerationQueue.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
export type PhotoModerationAction = {
|
||||||
|
id: string;
|
||||||
|
eventSlug: string;
|
||||||
|
photoId: number;
|
||||||
|
action: 'approve' | 'hide' | 'show' | 'feature' | 'unfeature';
|
||||||
|
createdAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'fotospiel-admin-photo-queue';
|
||||||
|
|
||||||
|
function buildId(): string {
|
||||||
|
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||||
|
return crypto.randomUUID();
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${Date.now()}-${Math.random().toString(16).slice(2, 10)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadPhotoQueue(): PhotoModerationAction[] {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (!raw) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
return Array.isArray(parsed) ? (parsed as PhotoModerationAction[]) : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function savePhotoQueue(queue: PhotoModerationAction[]): void {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(queue));
|
||||||
|
} catch {
|
||||||
|
// Ignore persistence failures.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function enqueuePhotoAction(action: Omit<PhotoModerationAction, 'id' | 'createdAt'>): PhotoModerationAction[] {
|
||||||
|
const queue = loadPhotoQueue();
|
||||||
|
const entry: PhotoModerationAction = {
|
||||||
|
...action,
|
||||||
|
id: buildId(),
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
const next = [...queue, entry];
|
||||||
|
savePhotoQueue(next);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removePhotoAction(queue: PhotoModerationAction[], id: string): PhotoModerationAction[] {
|
||||||
|
const next = queue.filter((item) => item.id !== id);
|
||||||
|
savePhotoQueue(next);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function replacePhotoQueue(queue: PhotoModerationAction[]): PhotoModerationAction[] {
|
||||||
|
savePhotoQueue(queue);
|
||||||
|
return queue;
|
||||||
|
}
|
||||||
@@ -132,12 +132,14 @@ export const router = createBrowserRouter([
|
|||||||
{ path: 'mobile/events/:slug/qr', element: <RequireAdminAccess><MobileQrPrintPage /></RequireAdminAccess> },
|
{ path: 'mobile/events/:slug/qr', element: <RequireAdminAccess><MobileQrPrintPage /></RequireAdminAccess> },
|
||||||
{ path: 'mobile/events/:slug/qr/customize/:tokenId?', element: <RequireAdminAccess><MobileQrLayoutCustomizePage /></RequireAdminAccess> },
|
{ path: 'mobile/events/:slug/qr/customize/:tokenId?', element: <RequireAdminAccess><MobileQrLayoutCustomizePage /></RequireAdminAccess> },
|
||||||
{ path: 'mobile/events/:slug/photos', element: <MobileEventPhotosPage /> },
|
{ path: 'mobile/events/:slug/photos', element: <MobileEventPhotosPage /> },
|
||||||
|
{ path: 'mobile/events/:slug/photos/:photoId', element: <MobileEventPhotosPage /> },
|
||||||
{ path: 'mobile/events/:slug/recap', element: <RequireAdminAccess><MobileEventRecapPage /></RequireAdminAccess> },
|
{ path: 'mobile/events/:slug/recap', element: <RequireAdminAccess><MobileEventRecapPage /></RequireAdminAccess> },
|
||||||
{ path: 'mobile/events/:slug/members', element: <RequireAdminAccess><MobileEventMembersPage /></RequireAdminAccess> },
|
{ path: 'mobile/events/:slug/members', element: <RequireAdminAccess><MobileEventMembersPage /></RequireAdminAccess> },
|
||||||
{ path: 'mobile/events/:slug/tasks', element: <MobileEventTasksPage /> },
|
{ path: 'mobile/events/:slug/tasks', element: <MobileEventTasksPage /> },
|
||||||
{ path: 'mobile/events/:slug/photobooth', element: <RequireAdminAccess><MobileEventPhotoboothPage /></RequireAdminAccess> },
|
{ path: 'mobile/events/:slug/photobooth', element: <RequireAdminAccess><MobileEventPhotoboothPage /></RequireAdminAccess> },
|
||||||
{ path: 'mobile/events/:slug/guest-notifications', element: <RequireAdminAccess><MobileEventGuestNotificationsPage /></RequireAdminAccess> },
|
{ path: 'mobile/events/:slug/guest-notifications', element: <RequireAdminAccess><MobileEventGuestNotificationsPage /></RequireAdminAccess> },
|
||||||
{ path: 'mobile/notifications', element: <MobileNotificationsPage /> },
|
{ path: 'mobile/notifications', element: <MobileNotificationsPage /> },
|
||||||
|
{ path: 'mobile/notifications/:notificationId', element: <MobileNotificationsPage /> },
|
||||||
{ path: 'mobile/profile', element: <RequireAdminAccess><MobileProfilePage /></RequireAdminAccess> },
|
{ path: 'mobile/profile', element: <RequireAdminAccess><MobileProfilePage /></RequireAdminAccess> },
|
||||||
{ path: 'mobile/billing', element: <RequireAdminAccess><MobileBillingPage /></RequireAdminAccess> },
|
{ path: 'mobile/billing', element: <RequireAdminAccess><MobileBillingPage /></RequireAdminAccess> },
|
||||||
{ path: 'mobile/settings', element: <RequireAdminAccess><MobileSettingsPage /></RequireAdminAccess> },
|
{ path: 'mobile/settings', element: <RequireAdminAccess><MobileSettingsPage /></RequireAdminAccess> },
|
||||||
|
|||||||
12
resources/js/admin/types/global.d.ts
vendored
Normal file
12
resources/js/admin/types/global.d.ts
vendored
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
export {};
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
__ADMIN_RUNTIME_CONFIG__?: {
|
||||||
|
push?: {
|
||||||
|
enabled?: boolean;
|
||||||
|
vapidPublicKey?: string | null;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,7 +10,7 @@
|
|||||||
<meta name="theme-color" content="#f43f5e">
|
<meta name="theme-color" content="#f43f5e">
|
||||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
|
<link rel="apple-touch-icon" href="/admin-apple-touch-icon.png">
|
||||||
@viteReactRefresh
|
@viteReactRefresh
|
||||||
@vite(['resources/css/app.css', 'resources/js/admin/main.tsx'])
|
@vite(['resources/css/app.css', 'resources/js/admin/main.tsx'])
|
||||||
@php
|
@php
|
||||||
@@ -22,9 +22,16 @@
|
|||||||
'siteId' => (string) $matomoConfig['site_id_admin'],
|
'siteId' => (string) $matomoConfig['site_id_admin'],
|
||||||
]
|
]
|
||||||
: ['enabled' => false];
|
: ['enabled' => false];
|
||||||
|
$adminRuntimeConfig = [
|
||||||
|
'push' => [
|
||||||
|
'enabled' => config('push.enabled', false),
|
||||||
|
'vapidPublicKey' => config('push.vapid.public_key'),
|
||||||
|
],
|
||||||
|
];
|
||||||
@endphp
|
@endphp
|
||||||
<script nonce="{{ $cspNonce }}">
|
<script nonce="{{ $cspNonce }}">
|
||||||
window.__MATOMO_ADMIN__ = {!! json_encode($matomoAdmin) !!};
|
window.__MATOMO_ADMIN__ = {!! json_encode($matomoAdmin) !!};
|
||||||
|
window.__ADMIN_RUNTIME_CONFIG__ = {!! json_encode($adminRuntimeConfig) !!};
|
||||||
</script>
|
</script>
|
||||||
<style nonce="{{ $cspStyleNonce }}">
|
<style nonce="{{ $cspStyleNonce }}">
|
||||||
#root { min-height: 100vh; }
|
#root { min-height: 100vh; }
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ use App\Http\Controllers\Api\LegalController;
|
|||||||
use App\Http\Controllers\Api\Marketing\CouponPreviewController;
|
use App\Http\Controllers\Api\Marketing\CouponPreviewController;
|
||||||
use App\Http\Controllers\Api\PackageController;
|
use App\Http\Controllers\Api\PackageController;
|
||||||
use App\Http\Controllers\Api\SparkboothUploadController;
|
use App\Http\Controllers\Api\SparkboothUploadController;
|
||||||
|
use App\Http\Controllers\Api\Tenant\AdminPushSubscriptionController;
|
||||||
use App\Http\Controllers\Api\Tenant\DashboardController;
|
use App\Http\Controllers\Api\Tenant\DashboardController;
|
||||||
use App\Http\Controllers\Api\Tenant\EmotionController;
|
use App\Http\Controllers\Api\Tenant\EmotionController;
|
||||||
use App\Http\Controllers\Api\Tenant\EventAddonCatalogController;
|
use App\Http\Controllers\Api\Tenant\EventAddonCatalogController;
|
||||||
@@ -294,6 +295,12 @@ Route::prefix('v1')->name('api.v1.')->group(function () {
|
|||||||
Route::post('notifications/logs/mark', [NotificationLogController::class, 'mark'])
|
Route::post('notifications/logs/mark', [NotificationLogController::class, 'mark'])
|
||||||
->middleware('tenant.admin')
|
->middleware('tenant.admin')
|
||||||
->name('tenant.notifications.logs.mark');
|
->name('tenant.notifications.logs.mark');
|
||||||
|
Route::post('notifications/push-subscriptions', [AdminPushSubscriptionController::class, 'store'])
|
||||||
|
->middleware('tenant.admin')
|
||||||
|
->name('tenant.notifications.push-subscriptions.store');
|
||||||
|
Route::delete('notifications/push-subscriptions', [AdminPushSubscriptionController::class, 'destroy'])
|
||||||
|
->middleware('tenant.admin')
|
||||||
|
->name('tenant.notifications.push-subscriptions.destroy');
|
||||||
|
|
||||||
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');
|
||||||
|
|||||||
31
tests/Feature/AdminPwaAssetsTest.php
Normal file
31
tests/Feature/AdminPwaAssetsTest.php
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class AdminPwaAssetsTest extends TestCase
|
||||||
|
{
|
||||||
|
public function test_admin_shell_includes_admin_apple_touch_icon(): void
|
||||||
|
{
|
||||||
|
$response = $this->get('/event-admin');
|
||||||
|
|
||||||
|
$response->assertOk();
|
||||||
|
$response->assertSee('/admin-apple-touch-icon.png', false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_admin_manifest_exposes_required_icons(): void
|
||||||
|
{
|
||||||
|
$manifestPath = public_path('manifest.json');
|
||||||
|
$manifest = json_decode(file_get_contents($manifestPath), true, 512, JSON_THROW_ON_ERROR);
|
||||||
|
|
||||||
|
$icons = collect($manifest['icons'] ?? [])->pluck('src')->all();
|
||||||
|
|
||||||
|
$this->assertContains('/admin-icon-192.png', $icons);
|
||||||
|
$this->assertContains('/admin-icon-512.png', $icons);
|
||||||
|
$this->assertContains('/admin-icon-192-maskable.png', $icons);
|
||||||
|
$this->assertContains('/admin-icon-512-maskable.png', $icons);
|
||||||
|
}
|
||||||
|
}
|
||||||
53
tests/Feature/Tenant/AdminPushSubscriptionTest.php
Normal file
53
tests/Feature/Tenant/AdminPushSubscriptionTest.php
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature\Tenant;
|
||||||
|
|
||||||
|
use App\Models\TenantAdminPushSubscription;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
class AdminPushSubscriptionTest extends TenantTestCase
|
||||||
|
{
|
||||||
|
public function test_admin_push_subscription_can_be_registered(): void
|
||||||
|
{
|
||||||
|
$payload = [
|
||||||
|
'endpoint' => 'https://example.com/push/'.Str::random(8),
|
||||||
|
'keys' => [
|
||||||
|
'p256dh' => base64_encode(random_bytes(32)),
|
||||||
|
'auth' => base64_encode(random_bytes(16)),
|
||||||
|
],
|
||||||
|
'expiration_time' => null,
|
||||||
|
'content_encoding' => 'aes128gcm',
|
||||||
|
'device_id' => 'admin-device-123',
|
||||||
|
];
|
||||||
|
|
||||||
|
$response = $this->authenticatedRequest('POST', '/api/v1/tenant/notifications/push-subscriptions', $payload);
|
||||||
|
|
||||||
|
$response->assertCreated();
|
||||||
|
$this->assertDatabaseHas('tenant_admin_push_subscriptions', [
|
||||||
|
'tenant_id' => $this->tenant->id,
|
||||||
|
'user_id' => $this->tenantUser->id,
|
||||||
|
'endpoint' => $payload['endpoint'],
|
||||||
|
'device_id' => $payload['device_id'],
|
||||||
|
'status' => 'active',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_admin_push_subscription_can_be_revoked(): void
|
||||||
|
{
|
||||||
|
$subscription = TenantAdminPushSubscription::factory()->create([
|
||||||
|
'tenant_id' => $this->tenant->id,
|
||||||
|
'user_id' => $this->tenantUser->id,
|
||||||
|
'status' => 'active',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $this->authenticatedRequest('DELETE', '/api/v1/tenant/notifications/push-subscriptions', [
|
||||||
|
'endpoint' => $subscription->endpoint,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertOk();
|
||||||
|
$this->assertDatabaseHas('tenant_admin_push_subscriptions', [
|
||||||
|
'id' => $subscription->id,
|
||||||
|
'status' => 'revoked',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user