Restore photobooth uploader files after sync
This commit is contained in:
@@ -1 +1 @@
|
||||
fotospiel-app-9em
|
||||
fotospiel-app-29r
|
||||
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -13,6 +13,8 @@ fotospiel-tenant-app
|
||||
/storage/*.key
|
||||
/storage/pail
|
||||
/vendor
|
||||
/clients/photobooth-uploader/**/bin
|
||||
/clients/photobooth-uploader/**/obj
|
||||
.env
|
||||
.env.backup
|
||||
.env.production
|
||||
@@ -26,3 +28,6 @@ yarn-error.log
|
||||
/.vscode
|
||||
test-results
|
||||
GEMINI.md
|
||||
.beads/.sync.lock
|
||||
.beads/daemon-error
|
||||
.beads/sync_base.jsonl
|
||||
|
||||
@@ -3,9 +3,12 @@
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Checkout\CheckoutSessionStatusRequest;
|
||||
use App\Models\CheckoutSession;
|
||||
use App\Models\Package;
|
||||
use App\Models\PackagePurchase;
|
||||
use App\Models\TenantPackage;
|
||||
use App\Services\Checkout\CheckoutSessionService;
|
||||
use App\Services\Paddle\PaddleCheckoutService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -14,7 +17,10 @@ use Illuminate\Validation\ValidationException;
|
||||
|
||||
class PackageController extends Controller
|
||||
{
|
||||
public function __construct(private readonly PaddleCheckoutService $paddleCheckout) {}
|
||||
public function __construct(
|
||||
private readonly PaddleCheckoutService $paddleCheckout,
|
||||
private readonly CheckoutSessionService $sessions,
|
||||
) {}
|
||||
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
@@ -165,23 +171,82 @@ class PackageController extends Controller
|
||||
|
||||
$package = Package::findOrFail($request->integer('package_id'));
|
||||
$tenant = $request->attributes->get('tenant');
|
||||
$user = $request->user();
|
||||
|
||||
if (! $tenant) {
|
||||
throw ValidationException::withMessages(['tenant' => 'Tenant context missing.']);
|
||||
}
|
||||
|
||||
if (! $user) {
|
||||
throw ValidationException::withMessages(['user' => 'User context missing.']);
|
||||
}
|
||||
|
||||
if (! $package->paddle_price_id) {
|
||||
throw ValidationException::withMessages(['package_id' => 'Package is not linked to a Paddle price.']);
|
||||
}
|
||||
|
||||
$session = $this->sessions->createOrResume($user, $package, [
|
||||
'tenant' => $tenant,
|
||||
]);
|
||||
|
||||
$this->sessions->selectProvider($session, CheckoutSession::PROVIDER_PADDLE);
|
||||
|
||||
$now = now();
|
||||
|
||||
$session->forceFill([
|
||||
'accepted_terms_at' => $now,
|
||||
'accepted_privacy_at' => $now,
|
||||
'accepted_withdrawal_notice_at' => $now,
|
||||
'digital_content_waiver_at' => null,
|
||||
'legal_version' => config('app.legal_version', $now->toDateString()),
|
||||
])->save();
|
||||
|
||||
$payload = [
|
||||
'success_url' => $request->input('success_url'),
|
||||
'return_url' => $request->input('return_url'),
|
||||
'metadata' => [
|
||||
'checkout_session_id' => $session->id,
|
||||
'legal_version' => $session->legal_version,
|
||||
'accepted_terms' => true,
|
||||
],
|
||||
];
|
||||
|
||||
$checkout = $this->paddleCheckout->createCheckout($tenant, $package, $payload);
|
||||
|
||||
return response()->json($checkout);
|
||||
$session->forceFill([
|
||||
'paddle_checkout_id' => $checkout['id'] ?? $session->paddle_checkout_id,
|
||||
'provider_metadata' => array_merge($session->provider_metadata ?? [], array_filter([
|
||||
'paddle_checkout_id' => $checkout['id'] ?? null,
|
||||
'paddle_checkout_url' => $checkout['checkout_url'] ?? null,
|
||||
'paddle_expires_at' => $checkout['expires_at'] ?? null,
|
||||
])),
|
||||
])->save();
|
||||
|
||||
return response()->json(array_merge($checkout, [
|
||||
'checkout_session_id' => $session->id,
|
||||
]));
|
||||
}
|
||||
|
||||
public function checkoutSessionStatus(CheckoutSessionStatusRequest $request, CheckoutSession $session): JsonResponse
|
||||
{
|
||||
$history = $session->status_history ?? [];
|
||||
$reason = null;
|
||||
|
||||
foreach (array_reverse($history) as $entry) {
|
||||
if (($entry['status'] ?? null) === $session->status) {
|
||||
$reason = $entry['reason'] ?? null;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
$checkoutUrl = data_get($session->provider_metadata ?? [], 'paddle_checkout_url');
|
||||
|
||||
return response()->json([
|
||||
'status' => $session->status,
|
||||
'completed_at' => optional($session->completed_at)->toIso8601String(),
|
||||
'reason' => $reason,
|
||||
'checkout_url' => is_string($checkoutUrl) ? $checkoutUrl : null,
|
||||
]);
|
||||
}
|
||||
|
||||
private function handleFreePurchase(Request $request, Package $package, $tenant): JsonResponse
|
||||
|
||||
45
app/Http/Controllers/Api/PhotoboothConnectController.php
Normal file
45
app/Http/Controllers/Api/PhotoboothConnectController.php
Normal file
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Photobooth\PhotoboothConnectRedeemRequest;
|
||||
use App\Services\Photobooth\PhotoboothConnectCodeService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
class PhotoboothConnectController extends Controller
|
||||
{
|
||||
public function __construct(private readonly PhotoboothConnectCodeService $service) {}
|
||||
|
||||
public function store(PhotoboothConnectRedeemRequest $request): JsonResponse
|
||||
{
|
||||
$record = $this->service->redeem($request->input('code'));
|
||||
|
||||
if (! $record) {
|
||||
return response()->json([
|
||||
'message' => __('Ungültiger oder abgelaufener Verbindungscode.'),
|
||||
], 422);
|
||||
}
|
||||
|
||||
$record->loadMissing('event.photoboothSetting');
|
||||
$event = $record->event;
|
||||
$setting = $event?->photoboothSetting;
|
||||
|
||||
if (! $event || ! $setting || ! $setting->enabled || $setting->mode !== 'sparkbooth') {
|
||||
return response()->json([
|
||||
'message' => __('Photobooth ist nicht im Sparkbooth-Modus aktiv.'),
|
||||
], 409);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'data' => [
|
||||
'upload_url' => route('api.v1.photobooth.sparkbooth.upload'),
|
||||
'username' => $setting->username,
|
||||
'password' => $setting->password,
|
||||
'expires_at' => optional($setting->expires_at)->toIso8601String(),
|
||||
'response_format' => ($setting->metadata ?? [])['sparkbooth_response_format']
|
||||
?? config('photobooth.sparkbooth.response_format', 'json'),
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -525,13 +525,13 @@ class PhotoController extends Controller
|
||||
]);
|
||||
|
||||
// Only tenant admins can moderate
|
||||
if (isset($validated['status']) && ! $this->tokenHasScope($request, 'tenant:write')) {
|
||||
if (isset($validated['status']) && ! $this->tokenHasScope($request, 'tenant-admin')) {
|
||||
return ApiError::response(
|
||||
'insufficient_scope',
|
||||
'Insufficient Scopes',
|
||||
'You are not allowed to moderate photos for this event.',
|
||||
Response::HTTP_FORBIDDEN,
|
||||
['required_scope' => 'tenant:write']
|
||||
['required_scope' => 'tenant-admin']
|
||||
);
|
||||
}
|
||||
|
||||
@@ -823,6 +823,11 @@ class PhotoController extends Controller
|
||||
|
||||
private function tokenHasScope(Request $request, string $scope): bool
|
||||
{
|
||||
$accessToken = $request->user()?->currentAccessToken();
|
||||
if ($accessToken && $accessToken->can($scope)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$scopes = $request->user()->scopes ?? ($request->attributes->get('decoded_token')['scopes'] ?? []);
|
||||
|
||||
if (! is_array($scopes)) {
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Tenant;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Tenant\PhotoboothConnectCodeStoreRequest;
|
||||
use App\Models\Event;
|
||||
use App\Services\Photobooth\PhotoboothConnectCodeService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
class PhotoboothConnectCodeController extends Controller
|
||||
{
|
||||
public function __construct(private readonly PhotoboothConnectCodeService $service) {}
|
||||
|
||||
public function store(PhotoboothConnectCodeStoreRequest $request, Event $event): JsonResponse
|
||||
{
|
||||
$this->assertEventBelongsToTenant($request, $event);
|
||||
|
||||
$event->loadMissing('photoboothSetting');
|
||||
$setting = $event->photoboothSetting;
|
||||
|
||||
if (! $setting || ! $setting->enabled || $setting->mode !== 'sparkbooth') {
|
||||
return response()->json([
|
||||
'message' => __('Photobooth muss im Sparkbooth-Modus aktiviert sein.'),
|
||||
], 409);
|
||||
}
|
||||
|
||||
$expiresInMinutes = $request->input('expires_in_minutes');
|
||||
$result = $this->service->create($event, $expiresInMinutes ? (int) $expiresInMinutes : null);
|
||||
|
||||
return response()->json([
|
||||
'data' => [
|
||||
'code' => $result['code'],
|
||||
'expires_at' => $result['expires_at']->toIso8601String(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
protected function assertEventBelongsToTenant(PhotoboothConnectCodeStoreRequest $request, Event $event): void
|
||||
{
|
||||
$tenantId = (int) $request->attributes->get('tenant_id');
|
||||
|
||||
if ($tenantId !== (int) $event->tenant_id) {
|
||||
abort(403, 'Event gehört nicht zu diesem Tenant.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Photobooth;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class PhotoboothConnectRedeemRequest 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 [
|
||||
'code' => ['required', 'string', 'size:6', 'regex:/^\d{6}$/'],
|
||||
];
|
||||
}
|
||||
|
||||
protected function prepareForValidation(): void
|
||||
{
|
||||
$code = preg_replace('/\D+/', '', (string) $this->input('code'));
|
||||
|
||||
$this->merge([
|
||||
'code' => $code,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Tenant;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class PhotoboothConnectCodeStoreRequest 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 [
|
||||
'expires_in_minutes' => ['nullable', 'integer', 'min:1', 'max:120'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -5,11 +5,19 @@ namespace App\Listeners\GuestNotifications;
|
||||
use App\Enums\GuestNotificationAudience;
|
||||
use App\Enums\GuestNotificationType;
|
||||
use App\Events\GuestPhotoUploaded;
|
||||
use App\Models\GuestNotification;
|
||||
use App\Models\Photo;
|
||||
use App\Services\GuestNotificationService;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
class SendPhotoUploadedNotification
|
||||
{
|
||||
private const DEDUPE_WINDOW_SECONDS = 30;
|
||||
|
||||
private const GROUP_WINDOW_MINUTES = 10;
|
||||
|
||||
private const MAX_GROUP_PHOTOS = 6;
|
||||
|
||||
/**
|
||||
* @param int[] $milestones
|
||||
*/
|
||||
@@ -25,7 +33,20 @@ class SendPhotoUploadedNotification
|
||||
? sprintf('%s hat gerade ein Foto gemacht 🎉', $guestLabel)
|
||||
: 'Es gibt neue Fotos!';
|
||||
|
||||
$this->notifications->createNotification(
|
||||
$recent = $this->findRecentPhotoNotification($event->event->id);
|
||||
if ($recent) {
|
||||
if ($this->shouldSkipDuplicate($recent, $event->photoId, $title)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$notification = $this->updateGroupedNotification($recent, $event->photoId);
|
||||
$this->markUploaderRead($notification, $event->guestIdentifier);
|
||||
$this->maybeCreateMilestoneNotification($event, $guestLabel);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$notification = $this->notifications->createNotification(
|
||||
$event->event,
|
||||
GuestNotificationType::PHOTO_ACTIVITY,
|
||||
$title,
|
||||
@@ -34,11 +55,15 @@ class SendPhotoUploadedNotification
|
||||
'audience_scope' => GuestNotificationAudience::ALL,
|
||||
'payload' => [
|
||||
'photo_id' => $event->photoId,
|
||||
'photo_ids' => [$event->photoId],
|
||||
'count' => 1,
|
||||
],
|
||||
'expires_at' => now()->addHours(3),
|
||||
]
|
||||
);
|
||||
|
||||
$this->markUploaderRead($notification, $event->guestIdentifier);
|
||||
|
||||
$this->maybeCreateMilestoneNotification($event, $guestLabel);
|
||||
}
|
||||
|
||||
@@ -87,4 +112,94 @@ class SendPhotoUploadedNotification
|
||||
|
||||
return $guestIdentifier;
|
||||
}
|
||||
|
||||
private function findRecentPhotoNotification(int $eventId): ?GuestNotification
|
||||
{
|
||||
$cutoff = Carbon::now()->subMinutes(self::GROUP_WINDOW_MINUTES);
|
||||
|
||||
return GuestNotification::query()
|
||||
->where('event_id', $eventId)
|
||||
->where('type', GuestNotificationType::PHOTO_ACTIVITY)
|
||||
->active()
|
||||
->notExpired()
|
||||
->where('created_at', '>=', $cutoff)
|
||||
->orderByDesc('id')
|
||||
->first();
|
||||
}
|
||||
|
||||
private function shouldSkipDuplicate(GuestNotification $notification, int $photoId, string $title): bool
|
||||
{
|
||||
$payload = $notification->payload;
|
||||
if (is_array($payload)) {
|
||||
$payloadIds = array_filter(
|
||||
array_map(
|
||||
fn ($value) => is_numeric($value) ? (int) $value : null,
|
||||
(array) ($payload['photo_ids'] ?? [])
|
||||
),
|
||||
fn ($value) => $value !== null && $value > 0
|
||||
);
|
||||
if (in_array($photoId, $payloadIds, true)) {
|
||||
return true;
|
||||
}
|
||||
if (is_numeric($payload['photo_id'] ?? null) && (int) $payload['photo_id'] === $photoId) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
$cutoff = Carbon::now()->subSeconds(self::DEDUPE_WINDOW_SECONDS);
|
||||
if ($notification->created_at instanceof Carbon && $notification->created_at->greaterThanOrEqualTo($cutoff)) {
|
||||
return $notification->title === $title;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private function updateGroupedNotification(GuestNotification $notification, int $photoId): GuestNotification
|
||||
{
|
||||
$payload = is_array($notification->payload) ? $notification->payload : [];
|
||||
$photoIds = array_filter(
|
||||
array_map(
|
||||
fn ($value) => is_numeric($value) ? (int) $value : null,
|
||||
(array) ($payload['photo_ids'] ?? [])
|
||||
),
|
||||
fn ($value) => $value !== null && $value > 0
|
||||
);
|
||||
$photoIds[] = $photoId;
|
||||
$photoIds = array_values(array_unique($photoIds));
|
||||
$photoIds = array_slice($photoIds, 0, self::MAX_GROUP_PHOTOS);
|
||||
|
||||
$existingCount = is_numeric($payload['count'] ?? null)
|
||||
? max(1, (int) $payload['count'])
|
||||
: max(1, count($photoIds) - 1);
|
||||
$newCount = $existingCount + 1;
|
||||
|
||||
$notification->forceFill([
|
||||
'title' => $this->buildGroupedTitle($newCount),
|
||||
'payload' => [
|
||||
'count' => $newCount,
|
||||
'photo_ids' => $photoIds,
|
||||
],
|
||||
])->save();
|
||||
|
||||
return $notification;
|
||||
}
|
||||
|
||||
private function buildGroupedTitle(int $count): string
|
||||
{
|
||||
if ($count <= 1) {
|
||||
return 'Es gibt neue Fotos!';
|
||||
}
|
||||
|
||||
return sprintf('Es gibt %d neue Fotos!', $count);
|
||||
}
|
||||
|
||||
private function markUploaderRead(GuestNotification $notification, string $guestIdentifier): void
|
||||
{
|
||||
$guestIdentifier = trim($guestIdentifier);
|
||||
if ($guestIdentifier === '' || $guestIdentifier === 'anonymous') {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->notifications->markAsRead($notification, $guestIdentifier);
|
||||
}
|
||||
}
|
||||
|
||||
25
app/Models/PhotoboothConnectCode.php
Normal file
25
app/Models/PhotoboothConnectCode.php
Normal file
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class PhotoboothConnectCode extends Model
|
||||
{
|
||||
/** @use HasFactory<\Database\Factories\PhotoboothConnectCodeFactory> */
|
||||
use HasFactory;
|
||||
|
||||
protected $guarded = [];
|
||||
|
||||
protected $casts = [
|
||||
'expires_at' => 'datetime',
|
||||
'redeemed_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function event(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Event::class);
|
||||
}
|
||||
}
|
||||
@@ -162,6 +162,10 @@ class AppServiceProvider extends ServiceProvider
|
||||
return Limit::perMinute(300)->by('guest-api:'.($request->ip() ?? 'unknown'));
|
||||
});
|
||||
|
||||
RateLimiter::for('photobooth-connect', function (Request $request) {
|
||||
return Limit::perMinute(30)->by('photobooth-connect:'.($request->ip() ?? 'unknown'));
|
||||
});
|
||||
|
||||
RateLimiter::for('tenant-auth', function (Request $request) {
|
||||
return Limit::perMinute(20)->by('tenant-auth:'.($request->ip() ?? 'unknown'));
|
||||
});
|
||||
|
||||
@@ -126,6 +126,36 @@ class GuestNotificationService
|
||||
return null;
|
||||
}
|
||||
|
||||
$photoId = Arr::get($payload, 'photo_id');
|
||||
if (is_numeric($photoId)) {
|
||||
$photoId = max(1, (int) $photoId);
|
||||
} else {
|
||||
$photoId = null;
|
||||
}
|
||||
|
||||
$photoIds = Arr::get($payload, 'photo_ids');
|
||||
if (is_array($photoIds)) {
|
||||
$photoIds = array_values(array_unique(array_filter(array_map(function ($value) {
|
||||
if (! is_numeric($value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$int = (int) $value;
|
||||
|
||||
return $int > 0 ? $int : null;
|
||||
}, $photoIds))));
|
||||
$photoIds = array_slice($photoIds, 0, 10);
|
||||
} else {
|
||||
$photoIds = [];
|
||||
}
|
||||
|
||||
$count = Arr::get($payload, 'count');
|
||||
if (is_numeric($count)) {
|
||||
$count = max(1, min(9999, (int) $count));
|
||||
} else {
|
||||
$count = null;
|
||||
}
|
||||
|
||||
$cta = Arr::get($payload, 'cta');
|
||||
if (is_array($cta)) {
|
||||
$cta = [
|
||||
@@ -142,6 +172,9 @@ class GuestNotificationService
|
||||
|
||||
$clean = array_filter([
|
||||
'cta' => $cta,
|
||||
'photo_id' => $photoId,
|
||||
'photo_ids' => $photoIds,
|
||||
'count' => $count,
|
||||
]);
|
||||
|
||||
return $clean === [] ? null : $clean;
|
||||
|
||||
80
app/Services/Photobooth/PhotoboothConnectCodeService.php
Normal file
80
app/Services/Photobooth/PhotoboothConnectCodeService.php
Normal file
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Photobooth;
|
||||
|
||||
use App\Models\Event;
|
||||
use App\Models\PhotoboothConnectCode;
|
||||
|
||||
class PhotoboothConnectCodeService
|
||||
{
|
||||
public function create(Event $event, ?int $expiresInMinutes = null): array
|
||||
{
|
||||
$length = (int) config('photobooth.connect_code.length', 6);
|
||||
$length = max(4, min(8, $length));
|
||||
|
||||
$expiresInMinutes = $expiresInMinutes ?: (int) config('photobooth.connect_code.expires_minutes', 10);
|
||||
$expiresInMinutes = max(1, min(120, $expiresInMinutes));
|
||||
|
||||
$code = null;
|
||||
$hash = null;
|
||||
$max = (10 ** $length) - 1;
|
||||
|
||||
for ($attempts = 0; $attempts < 5; $attempts++) {
|
||||
$candidate = str_pad((string) random_int(0, $max), $length, '0', STR_PAD_LEFT);
|
||||
$candidateHash = hash('sha256', $candidate);
|
||||
|
||||
$exists = PhotoboothConnectCode::query()
|
||||
->where('code_hash', $candidateHash)
|
||||
->whereNull('redeemed_at')
|
||||
->where('expires_at', '>=', now())
|
||||
->exists();
|
||||
|
||||
if (! $exists) {
|
||||
$code = $candidate;
|
||||
$hash = $candidateHash;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (! $code || ! $hash) {
|
||||
$code = str_pad((string) random_int(0, $max), $length, '0', STR_PAD_LEFT);
|
||||
$hash = hash('sha256', $code);
|
||||
}
|
||||
|
||||
$expiresAt = now()->addMinutes($expiresInMinutes);
|
||||
|
||||
$record = PhotoboothConnectCode::query()->create([
|
||||
'event_id' => $event->getKey(),
|
||||
'code_hash' => $hash,
|
||||
'expires_at' => $expiresAt,
|
||||
]);
|
||||
|
||||
return [
|
||||
'code' => $code,
|
||||
'record' => $record,
|
||||
'expires_at' => $expiresAt,
|
||||
];
|
||||
}
|
||||
|
||||
public function redeem(string $code): ?PhotoboothConnectCode
|
||||
{
|
||||
$hash = hash('sha256', $code);
|
||||
|
||||
/** @var PhotoboothConnectCode|null $record */
|
||||
$record = PhotoboothConnectCode::query()
|
||||
->where('code_hash', $hash)
|
||||
->whereNull('redeemed_at')
|
||||
->where('expires_at', '>=', now())
|
||||
->first();
|
||||
|
||||
if (! $record) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$record->forceFill([
|
||||
'redeemed_at' => now(),
|
||||
])->save();
|
||||
|
||||
return $record;
|
||||
}
|
||||
}
|
||||
10
clients/photobooth-uploader/PhotoboothUploader/App.axaml
Normal file
10
clients/photobooth-uploader/PhotoboothUploader/App.axaml
Normal file
@@ -0,0 +1,10 @@
|
||||
<Application xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
x:Class="PhotoboothUploader.App"
|
||||
RequestedThemeVariant="Default">
|
||||
<!-- "Default" ThemeVariant follows system theme variant. "Dark" or "Light" are other available options. -->
|
||||
|
||||
<Application.Styles>
|
||||
<FluentTheme />
|
||||
</Application.Styles>
|
||||
</Application>
|
||||
23
clients/photobooth-uploader/PhotoboothUploader/App.axaml.cs
Normal file
23
clients/photobooth-uploader/PhotoboothUploader/App.axaml.cs
Normal file
@@ -0,0 +1,23 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Controls.ApplicationLifetimes;
|
||||
using Avalonia.Markup.Xaml;
|
||||
|
||||
namespace PhotoboothUploader;
|
||||
|
||||
public partial class App : Application
|
||||
{
|
||||
public override void Initialize()
|
||||
{
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
}
|
||||
|
||||
public override void OnFrameworkInitializationCompleted()
|
||||
{
|
||||
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
||||
{
|
||||
desktop.MainWindow = new MainWindow();
|
||||
}
|
||||
|
||||
base.OnFrameworkInitializationCompleted();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
mc:Ignorable="d" d:DesignWidth="520" d:DesignHeight="360"
|
||||
x:Class="PhotoboothUploader.MainWindow"
|
||||
Width="520" Height="360"
|
||||
Title="Fotospiel Photobooth Uploader">
|
||||
<StackPanel Spacing="12" Margin="24" MaxWidth="420">
|
||||
<TextBlock Text="Fotospiel Photobooth Uploader" FontSize="20" FontWeight="SemiBold" />
|
||||
<TextBlock Text="Gib den 6-stelligen Verbindungscode ein." TextWrapping="Wrap" />
|
||||
<TextBox x:Name="CodeBox" MaxLength="6" Watermark="123456" />
|
||||
<Button x:Name="ConnectButton" Content="Verbinden" Click="ConnectButton_Click" />
|
||||
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock Text="Upload-Ordner" FontWeight="SemiBold" />
|
||||
<TextBlock x:Name="FolderText" Text="Noch nicht ausgewählt." TextWrapping="Wrap" />
|
||||
<Button x:Name="PickFolderButton" Content="Ordner auswählen" Click="PickFolderButton_Click" IsEnabled="False" />
|
||||
</StackPanel>
|
||||
|
||||
<TextBlock x:Name="StatusText" Text="Nicht verbunden." TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
</Window>
|
||||
@@ -0,0 +1,178 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Platform.Storage;
|
||||
using Avalonia.Threading;
|
||||
using PhotoboothUploader.Models;
|
||||
using PhotoboothUploader.Services;
|
||||
|
||||
namespace PhotoboothUploader;
|
||||
|
||||
public partial class MainWindow : Window
|
||||
{
|
||||
private const string DefaultBaseUrl = "https://fotospiel.app";
|
||||
private PhotoboothConnectClient _client;
|
||||
private readonly SettingsStore _settingsStore = new();
|
||||
private readonly UploadService _uploadService = new();
|
||||
private PhotoboothSettings _settings;
|
||||
private FileSystemWatcher? _watcher;
|
||||
|
||||
public MainWindow()
|
||||
{
|
||||
InitializeComponent();
|
||||
_settings = _settingsStore.Load();
|
||||
_settings.BaseUrl ??= DefaultBaseUrl;
|
||||
_client = new PhotoboothConnectClient(_settings.BaseUrl);
|
||||
_settingsStore.Save(_settings);
|
||||
ApplySettings();
|
||||
}
|
||||
|
||||
private async void ConnectButton_Click(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
var code = (CodeBox.Text ?? string.Empty).Trim();
|
||||
|
||||
if (code.Length != 6 || code.Any(ch => ch is < '0' or > '9'))
|
||||
{
|
||||
StatusText.Text = "Bitte einen gültigen 6-stelligen Code eingeben.";
|
||||
return;
|
||||
}
|
||||
|
||||
ConnectButton.IsEnabled = false;
|
||||
StatusText.Text = "Verbinde...";
|
||||
|
||||
var response = await _client.RedeemAsync(code);
|
||||
|
||||
if (response.Data is null)
|
||||
{
|
||||
StatusText.Text = response.Message ?? "Verbindung fehlgeschlagen.";
|
||||
ConnectButton.IsEnabled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
_settings.UploadUrl = ResolveUploadUrl(response.Data.UploadUrl);
|
||||
_settings.Username = response.Data.Username;
|
||||
_settings.Password = response.Data.Password;
|
||||
_settings.ResponseFormat = response.Data.ResponseFormat;
|
||||
_settingsStore.Save(_settings);
|
||||
|
||||
StatusText.Text = "Verbunden. Upload bereit.";
|
||||
PickFolderButton.IsEnabled = true;
|
||||
StartUploadPipelineIfReady();
|
||||
ConnectButton.IsEnabled = true;
|
||||
}
|
||||
|
||||
private async void PickFolderButton_Click(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
var options = new FolderPickerOpenOptions
|
||||
{
|
||||
Title = "Upload-Ordner auswählen",
|
||||
AllowMultiple = false,
|
||||
};
|
||||
|
||||
var folders = await StorageProvider.OpenFolderPickerAsync(options);
|
||||
var folder = folders.FirstOrDefault();
|
||||
var localPath = folder?.TryGetLocalPath();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(localPath))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_settings.WatchFolder = localPath;
|
||||
_settingsStore.Save(_settings);
|
||||
|
||||
FolderText.Text = localPath;
|
||||
StartUploadPipelineIfReady();
|
||||
}
|
||||
|
||||
private void ApplySettings()
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(_settings.WatchFolder))
|
||||
{
|
||||
FolderText.Text = _settings.WatchFolder;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(_settings.UploadUrl))
|
||||
{
|
||||
StatusText.Text = "Verbunden. Upload bereit.";
|
||||
PickFolderButton.IsEnabled = true;
|
||||
StartUploadPipelineIfReady();
|
||||
}
|
||||
}
|
||||
|
||||
private void StartUploadPipelineIfReady()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_settings.UploadUrl) || string.IsNullOrWhiteSpace(_settings.WatchFolder))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_uploadService.Start(_settings, UpdateStatus);
|
||||
StartWatcher(_settings.WatchFolder);
|
||||
}
|
||||
|
||||
private void StartWatcher(string folder)
|
||||
{
|
||||
_watcher?.Dispose();
|
||||
|
||||
_watcher = new FileSystemWatcher(folder)
|
||||
{
|
||||
IncludeSubdirectories = false,
|
||||
EnableRaisingEvents = true,
|
||||
};
|
||||
|
||||
_watcher.Created += OnFileChanged;
|
||||
_watcher.Changed += OnFileChanged;
|
||||
_watcher.Renamed += OnFileRenamed;
|
||||
}
|
||||
|
||||
private void OnFileChanged(object sender, FileSystemEventArgs e)
|
||||
{
|
||||
if (!IsSupportedImage(e.FullPath))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_uploadService.Enqueue(e.FullPath);
|
||||
}
|
||||
|
||||
private void OnFileRenamed(object sender, RenamedEventArgs e)
|
||||
{
|
||||
if (!IsSupportedImage(e.FullPath))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_uploadService.Enqueue(e.FullPath);
|
||||
}
|
||||
|
||||
private bool IsSupportedImage(string path)
|
||||
{
|
||||
var extension = Path.GetExtension(path)?.ToLowerInvariant();
|
||||
|
||||
return extension is ".jpg" or ".jpeg" or ".png" or ".webp";
|
||||
}
|
||||
|
||||
private void UpdateStatus(string message)
|
||||
{
|
||||
Dispatcher.UIThread.Post(() => StatusText.Text = message);
|
||||
}
|
||||
|
||||
private string? ResolveUploadUrl(string? uploadUrl)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(uploadUrl))
|
||||
{
|
||||
return uploadUrl;
|
||||
}
|
||||
|
||||
if (Uri.TryCreate(uploadUrl, UriKind.Absolute, out _))
|
||||
{
|
||||
return uploadUrl;
|
||||
}
|
||||
|
||||
var baseUri = new Uri(_settings.BaseUrl ?? DefaultBaseUrl, UriKind.Absolute);
|
||||
return new Uri(baseUri, uploadUrl).ToString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace PhotoboothUploader.Models;
|
||||
|
||||
public sealed class PhotoboothConnectResponse
|
||||
{
|
||||
[JsonPropertyName("data")]
|
||||
public PhotoboothConnectPayload? Data { get; set; }
|
||||
|
||||
[JsonPropertyName("message")]
|
||||
public string? Message { get; set; }
|
||||
}
|
||||
|
||||
public sealed class PhotoboothConnectPayload
|
||||
{
|
||||
[JsonPropertyName("upload_url")]
|
||||
public string? UploadUrl { get; set; }
|
||||
|
||||
[JsonPropertyName("username")]
|
||||
public string? Username { get; set; }
|
||||
|
||||
[JsonPropertyName("password")]
|
||||
public string? Password { get; set; }
|
||||
|
||||
[JsonPropertyName("expires_at")]
|
||||
public string? ExpiresAt { get; set; }
|
||||
|
||||
[JsonPropertyName("response_format")]
|
||||
public string? ResponseFormat { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace PhotoboothUploader.Models;
|
||||
|
||||
public sealed class PhotoboothSettings
|
||||
{
|
||||
public string? BaseUrl { get; set; }
|
||||
public string? UploadUrl { get; set; }
|
||||
public string? Username { get; set; }
|
||||
public string? Password { get; set; }
|
||||
public string? ResponseFormat { get; set; }
|
||||
public string? WatchFolder { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Avalonia" Version="11.3.10" />
|
||||
<PackageReference Include="Avalonia.Desktop" Version="11.3.10" />
|
||||
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.3.10" />
|
||||
<PackageReference Include="Avalonia.Fonts.Inter" Version="11.3.10" />
|
||||
<!--Condition below is needed to remove Avalonia.Diagnostics package from build output in Release configuration.-->
|
||||
<PackageReference Include="Avalonia.Diagnostics" Version="11.3.10">
|
||||
<IncludeAssets Condition="'$(Configuration)' != 'Debug'">None</IncludeAssets>
|
||||
<PrivateAssets Condition="'$(Configuration)' != 'Debug'">All</PrivateAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
21
clients/photobooth-uploader/PhotoboothUploader/Program.cs
Normal file
21
clients/photobooth-uploader/PhotoboothUploader/Program.cs
Normal file
@@ -0,0 +1,21 @@
|
||||
using Avalonia;
|
||||
using System;
|
||||
|
||||
namespace PhotoboothUploader;
|
||||
|
||||
class Program
|
||||
{
|
||||
// Initialization code. Don't use any Avalonia, third-party APIs or any
|
||||
// SynchronizationContext-reliant code before AppMain is called: things aren't initialized
|
||||
// yet and stuff might break.
|
||||
[STAThread]
|
||||
public static void Main(string[] args) => BuildAvaloniaApp()
|
||||
.StartWithClassicDesktopLifetime(args);
|
||||
|
||||
// Avalonia configuration, don't remove; also used by visual designer.
|
||||
public static AppBuilder BuildAvaloniaApp()
|
||||
=> AppBuilder.Configure<App>()
|
||||
.UsePlatformDetect()
|
||||
.WithInterFont()
|
||||
.LogToTrace();
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
using System;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using PhotoboothUploader.Models;
|
||||
|
||||
namespace PhotoboothUploader.Services;
|
||||
|
||||
public sealed class PhotoboothConnectClient
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly JsonSerializerOptions _jsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
};
|
||||
|
||||
public PhotoboothConnectClient(string baseUrl)
|
||||
{
|
||||
_httpClient = new HttpClient
|
||||
{
|
||||
BaseAddress = new Uri(baseUrl),
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<PhotoboothConnectResponse> RedeemAsync(string code, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var response = await _httpClient.PostAsJsonAsync("/api/v1/photobooth/connect", new { code }, cancellationToken);
|
||||
var payload = await response.Content.ReadFromJsonAsync<PhotoboothConnectResponse>(_jsonOptions, cancellationToken);
|
||||
|
||||
if (payload is null)
|
||||
{
|
||||
return new PhotoboothConnectResponse
|
||||
{
|
||||
Message = response.ReasonPhrase ?? "Verbindung fehlgeschlagen.",
|
||||
};
|
||||
}
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
return new PhotoboothConnectResponse
|
||||
{
|
||||
Message = payload.Message ?? "Verbindung fehlgeschlagen.",
|
||||
};
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using PhotoboothUploader.Models;
|
||||
|
||||
namespace PhotoboothUploader.Services;
|
||||
|
||||
public sealed class SettingsStore
|
||||
{
|
||||
private readonly JsonSerializerOptions _options = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
WriteIndented = true,
|
||||
};
|
||||
|
||||
public string SettingsPath { get; }
|
||||
|
||||
public SettingsStore()
|
||||
{
|
||||
var basePath = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"Fotospiel",
|
||||
"PhotoboothUploader");
|
||||
|
||||
Directory.CreateDirectory(basePath);
|
||||
SettingsPath = Path.Combine(basePath, "settings.json");
|
||||
}
|
||||
|
||||
public PhotoboothSettings Load()
|
||||
{
|
||||
if (!File.Exists(SettingsPath))
|
||||
{
|
||||
return new PhotoboothSettings();
|
||||
}
|
||||
|
||||
var json = File.ReadAllText(SettingsPath);
|
||||
return JsonSerializer.Deserialize<PhotoboothSettings>(json, _options) ?? new PhotoboothSettings();
|
||||
}
|
||||
|
||||
public void Save(PhotoboothSettings settings)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(settings, _options);
|
||||
File.WriteAllText(SettingsPath, json);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.IO;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Threading;
|
||||
using System.Threading.Channels;
|
||||
using System.Threading.Tasks;
|
||||
using PhotoboothUploader.Models;
|
||||
|
||||
namespace PhotoboothUploader.Services;
|
||||
|
||||
public sealed class UploadService
|
||||
{
|
||||
private readonly Channel<string> _queue = Channel.CreateUnbounded<string>();
|
||||
private readonly ConcurrentDictionary<string, byte> _pending = new(StringComparer.OrdinalIgnoreCase);
|
||||
private CancellationTokenSource? _cts;
|
||||
|
||||
public void Start(PhotoboothSettings settings, Action<string> setStatus)
|
||||
{
|
||||
Stop();
|
||||
|
||||
_cts = new CancellationTokenSource();
|
||||
_ = Task.Run(() => WorkerAsync(settings, setStatus, _cts.Token));
|
||||
}
|
||||
|
||||
public void Stop()
|
||||
{
|
||||
_cts?.Cancel();
|
||||
_cts = null;
|
||||
_pending.Clear();
|
||||
}
|
||||
|
||||
public void Enqueue(string path)
|
||||
{
|
||||
if (!_pending.TryAdd(path, 0))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_queue.Writer.TryWrite(path);
|
||||
}
|
||||
|
||||
private async Task WorkerAsync(PhotoboothSettings settings, Action<string> setStatus, CancellationToken token)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(settings.UploadUrl))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
using var client = new HttpClient();
|
||||
|
||||
while (await _queue.Reader.WaitToReadAsync(token))
|
||||
{
|
||||
while (_queue.Reader.TryRead(out var path))
|
||||
{
|
||||
try
|
||||
{
|
||||
await WaitForFileReadyAsync(path, token);
|
||||
await UploadAsync(client, settings, path, token);
|
||||
setStatus($"Hochgeladen: {Path.GetFileName(path)}");
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return;
|
||||
}
|
||||
catch
|
||||
{
|
||||
setStatus($"Upload fehlgeschlagen: {Path.GetFileName(path)}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
_pending.TryRemove(path, out _);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task WaitForFileReadyAsync(string path, CancellationToken token)
|
||||
{
|
||||
var lastSize = -1L;
|
||||
|
||||
for (var attempts = 0; attempts < 10; attempts++)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
await Task.Delay(500, token);
|
||||
continue;
|
||||
}
|
||||
|
||||
var info = new FileInfo(path);
|
||||
var size = info.Length;
|
||||
|
||||
if (size > 0 && size == lastSize)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
lastSize = size;
|
||||
await Task.Delay(700, token);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task UploadAsync(HttpClient client, PhotoboothSettings settings, string path, CancellationToken token)
|
||||
{
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
using var content = new MultipartFormDataContent();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(settings.Username))
|
||||
{
|
||||
content.Add(new StringContent(settings.Username), "username");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(settings.Password))
|
||||
{
|
||||
content.Add(new StringContent(settings.Password), "password");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(settings.ResponseFormat))
|
||||
{
|
||||
content.Add(new StringContent(settings.ResponseFormat), "format");
|
||||
}
|
||||
|
||||
var stream = File.OpenRead(path);
|
||||
var fileContent = new StreamContent(stream);
|
||||
fileContent.Headers.ContentType = new MediaTypeHeaderValue(ResolveContentType(path));
|
||||
content.Add(fileContent, "media", Path.GetFileName(path));
|
||||
|
||||
var response = await client.PostAsync(settings.UploadUrl, content, token);
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
private static string ResolveContentType(string path)
|
||||
{
|
||||
return Path.GetExtension(path)?.ToLowerInvariant() switch
|
||||
{
|
||||
".png" => "image/png",
|
||||
".webp" => "image/webp",
|
||||
_ => "image/jpeg",
|
||||
};
|
||||
}
|
||||
}
|
||||
18
clients/photobooth-uploader/PhotoboothUploader/app.manifest
Normal file
18
clients/photobooth-uploader/PhotoboothUploader/app.manifest
Normal file
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
|
||||
<!-- This manifest is used on Windows only.
|
||||
Don't remove it as it might cause problems with window transparency and embedded controls.
|
||||
For more details visit https://learn.microsoft.com/en-us/windows/win32/sbscs/application-manifests -->
|
||||
<assemblyIdentity version="1.0.0.0" name="PhotoboothUploader.Desktop"/>
|
||||
|
||||
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
|
||||
<application>
|
||||
<!-- A list of the Windows versions that this application has been tested on
|
||||
and is designed to work with. Uncomment the appropriate elements
|
||||
and Windows will automatically select the most compatible environment. -->
|
||||
|
||||
<!-- Windows 10 -->
|
||||
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
|
||||
</application>
|
||||
</compatibility>
|
||||
</assembly>
|
||||
@@ -34,4 +34,8 @@ return [
|
||||
'rate_limit_per_minute' => (int) env('SPARKBOOTH_RATE_LIMIT_PER_MINUTE', env('PHOTOBOOTH_RATE_LIMIT_PER_MINUTE', 20)),
|
||||
'response_format' => env('SPARKBOOTH_RESPONSE_FORMAT', 'json'),
|
||||
],
|
||||
'connect_code' => [
|
||||
'length' => (int) env('PHOTOBOOTH_CONNECT_CODE_LENGTH', 6),
|
||||
'expires_minutes' => (int) env('PHOTOBOOTH_CONNECT_CODE_EXPIRES_MINUTES', 10),
|
||||
],
|
||||
];
|
||||
|
||||
29
database/factories/PhotoboothConnectCodeFactory.php
Normal file
29
database/factories/PhotoboothConnectCodeFactory.php
Normal file
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\Event;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\PhotoboothConnectCode>
|
||||
*/
|
||||
class PhotoboothConnectCodeFactory extends Factory
|
||||
{
|
||||
/**
|
||||
* Define the model's default state.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
$rawCode = str_pad((string) $this->faker->numberBetween(0, 999999), 6, '0', STR_PAD_LEFT);
|
||||
|
||||
return [
|
||||
'event_id' => Event::factory(),
|
||||
'code_hash' => hash('sha256', $rawCode),
|
||||
'expires_at' => now()->addMinutes(10),
|
||||
'redeemed_at' => null,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?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('photobooth_connect_codes', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('event_id')->constrained()->cascadeOnDelete();
|
||||
$table->string('code_hash', 64)->unique();
|
||||
$table->timestamp('expires_at');
|
||||
$table->timestamp('redeemed_at')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('photobooth_connect_codes');
|
||||
}
|
||||
};
|
||||
16
database/seeders/PhotoboothConnectCodeSeeder.php
Normal file
16
database/seeders/PhotoboothConnectCodeSeeder.php
Normal file
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
class PhotoboothConnectCodeSeeder extends Seeder
|
||||
{
|
||||
/**
|
||||
* Run the database seeds.
|
||||
*/
|
||||
public function run(): void
|
||||
{
|
||||
//
|
||||
}
|
||||
}
|
||||
@@ -2458,7 +2458,7 @@ export async function getTenantPaddleTransactions(cursor?: string): Promise<{
|
||||
export async function createTenantPaddleCheckout(
|
||||
packageId: number,
|
||||
urls?: { success_url?: string; return_url?: string }
|
||||
): Promise<{ checkout_url: string; id: string; expires_at?: string }> {
|
||||
): Promise<{ checkout_url: string; id: string; expires_at?: string; checkout_session_id?: string }> {
|
||||
const response = await authorizedFetch('/api/v1/tenant/packages/paddle-checkout', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -2468,12 +2468,22 @@ export async function createTenantPaddleCheckout(
|
||||
return_url: urls?.return_url,
|
||||
}),
|
||||
});
|
||||
return await jsonOrThrow<{ checkout_url: string; id: string; expires_at?: string }>(
|
||||
return await jsonOrThrow<{ checkout_url: string; id: string; expires_at?: string; checkout_session_id?: string }>(
|
||||
response,
|
||||
'Failed to create checkout'
|
||||
);
|
||||
}
|
||||
|
||||
export async function getTenantPackageCheckoutStatus(
|
||||
checkoutSessionId: string,
|
||||
): Promise<{ status: string; completed_at?: string | null; reason?: string | null; checkout_url?: string | null }> {
|
||||
const response = await authorizedFetch(`/api/v1/tenant/packages/checkout-session/${checkoutSessionId}/status`);
|
||||
return await jsonOrThrow<{ status: string; completed_at?: string | null; reason?: string | null; checkout_url?: string | null }>(
|
||||
response,
|
||||
'Failed to load checkout status'
|
||||
);
|
||||
}
|
||||
|
||||
export async function createTenantBillingPortalSession(): Promise<{ url: string }> {
|
||||
const response = await authorizedFetch('/api/v1/tenant/billing/portal', {
|
||||
method: 'POST',
|
||||
|
||||
@@ -34,6 +34,27 @@
|
||||
"more": "Weitere Einträge konnten nicht geladen werden.",
|
||||
"portal": "Paddle-Portal konnte nicht geöffnet werden."
|
||||
},
|
||||
"checkoutSuccess": "Checkout abgeschlossen. Dein Paket wird in Kürze aktiviert.",
|
||||
"checkoutCancelled": "Checkout wurde abgebrochen.",
|
||||
"checkoutActivated": "Dein Paket ist jetzt aktiv.",
|
||||
"checkoutPendingTitle": "Paket wird aktiviert",
|
||||
"checkoutPendingBody": "Das kann ein paar Minuten dauern. Wir aktualisieren den Status, sobald das Paket aktiv ist.",
|
||||
"checkoutPendingBadge": "Ausstehend",
|
||||
"checkoutPendingRefresh": "Aktualisieren",
|
||||
"checkoutPendingDismiss": "Ausblenden",
|
||||
"checkoutFailedTitle": "Checkout fehlgeschlagen",
|
||||
"checkoutFailedBody": "Die Zahlung wurde nicht abgeschlossen. Du kannst es erneut versuchen oder den Support kontaktieren.",
|
||||
"checkoutFailedBadge": "Fehlgeschlagen",
|
||||
"checkoutFailedRetry": "Erneut versuchen",
|
||||
"checkoutFailedDismiss": "Ausblenden",
|
||||
"checkoutActionTitle": "Aktion erforderlich",
|
||||
"checkoutActionBody": "Schließe die Zahlung ab, um das Paket zu aktivieren.",
|
||||
"checkoutActionBadge": "Aktion nötig",
|
||||
"checkoutActionButton": "Checkout fortsetzen",
|
||||
"checkoutFailureReasons": {
|
||||
"paddle_failed": "Die Zahlung wurde abgelehnt.",
|
||||
"paddle_cancelled": "Der Checkout wurde abgebrochen."
|
||||
},
|
||||
"sections": {
|
||||
"invoices": {
|
||||
"title": "Rechnungen & Zahlungen",
|
||||
@@ -176,6 +197,8 @@
|
||||
},
|
||||
"common": {
|
||||
"all": "Alle",
|
||||
"anonymous": "Anonym",
|
||||
"error": "Etwas ist schiefgelaufen",
|
||||
"loadMore": "Mehr laden",
|
||||
"processing": "Verarbeite …",
|
||||
"select": "Auswählen",
|
||||
@@ -2875,16 +2898,25 @@
|
||||
"analytics": {
|
||||
"title": "Analytics",
|
||||
"upgradeAction": "Upgrade auf Premium",
|
||||
"kpiTitle": "Event-Überblick",
|
||||
"kpiUploads": "Uploads",
|
||||
"kpiContributors": "Beitragende",
|
||||
"kpiLikes": "Likes",
|
||||
"activityTitle": "Aktivitäts-Zeitachse",
|
||||
"timeframe": "Letzte {{hours}} Stunden",
|
||||
"timeframeHint": "Ältere Aktivität ausgeblendet",
|
||||
"uploadsPerHour": "Uploads pro Stunde",
|
||||
"noActivity": "Noch keine Uploads",
|
||||
"emptyActionShareQr": "QR-Code teilen",
|
||||
"contributorsTitle": "Top-Beitragende",
|
||||
"likesCount": "{{count}} Likes",
|
||||
"likesCount_one": "{{count}} Like",
|
||||
"likesCount_other": "{{count}} Likes",
|
||||
"noContributors": "Noch keine Beitragenden",
|
||||
"emptyActionInvite": "Gäste einladen",
|
||||
"tasksTitle": "Beliebte Aufgaben",
|
||||
"noTasks": "Noch keine Aufgabenaktivität",
|
||||
"emptyActionOpenTasks": "Aufgaben öffnen",
|
||||
"lockedTitle": "Analytics freischalten",
|
||||
"lockedBody": "Erhalte tiefe Einblicke in die Interaktionen deines Events mit dem Premium-Paket."
|
||||
},
|
||||
@@ -2893,6 +2925,26 @@
|
||||
"subtitle": "Wähle ein Paket, um mehr Funktionen und Limits freizuschalten.",
|
||||
"recommendationTitle": "Empfohlen für dich",
|
||||
"recommendationBody": "Das hervorgehobene Paket enthält das gewünschte Feature.",
|
||||
"compare": {
|
||||
"title": "Pakete vergleichen",
|
||||
"helper": "Wische, um Pakete nebeneinander zu vergleichen.",
|
||||
"toggleCards": "Karten",
|
||||
"toggleCompare": "Vergleichen",
|
||||
"headers": {
|
||||
"plan": "Paket",
|
||||
"price": "Preis"
|
||||
},
|
||||
"rows": {
|
||||
"photos": "Fotos",
|
||||
"guests": "Gäste",
|
||||
"days": "Galerietage"
|
||||
},
|
||||
"values": {
|
||||
"included": "Enthalten",
|
||||
"notIncluded": "Nicht enthalten",
|
||||
"unlimited": "Unbegrenzt"
|
||||
}
|
||||
},
|
||||
"select": "Auswählen",
|
||||
"manage": "Paket verwalten",
|
||||
"limits": {
|
||||
@@ -2906,7 +2958,13 @@
|
||||
},
|
||||
"features": {
|
||||
"advanced_analytics": "Erweiterte Analytics",
|
||||
"basic_uploads": "Basis-Uploads",
|
||||
"custom_branding": "Eigenes Branding",
|
||||
"custom_tasks": "Benutzerdefinierte Aufgaben",
|
||||
"limited_sharing": "Begrenztes Teilen",
|
||||
"live_slideshow": "Live-Slideshow",
|
||||
"priority_support": "Priorisierter Support",
|
||||
"unlimited_sharing": "Unbegrenztes Teilen",
|
||||
"watermark_removal": "Kein Wasserzeichen"
|
||||
},
|
||||
"status": {
|
||||
@@ -2918,7 +2976,9 @@
|
||||
},
|
||||
"badges": {
|
||||
"recommended": "Empfohlen",
|
||||
"active": "Aktiv"
|
||||
"active": "Aktiv",
|
||||
"upgrade": "Upgrade",
|
||||
"downgrade": "Downgrade"
|
||||
},
|
||||
"confirmTitle": "Kauf bestätigen",
|
||||
"confirmSubtitle": "Du upgradest auf:",
|
||||
@@ -2931,6 +2991,7 @@
|
||||
"payNow": "Jetzt zahlen",
|
||||
"errors": {
|
||||
"checkout": "Checkout fehlgeschlagen"
|
||||
}
|
||||
},
|
||||
"selectDisabled": "Nicht verfügbar"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +34,27 @@
|
||||
"more": "Unable to load more entries.",
|
||||
"portal": "Unable to open the Paddle portal."
|
||||
},
|
||||
"checkoutSuccess": "Checkout completed. Your package will activate shortly.",
|
||||
"checkoutCancelled": "Checkout was cancelled.",
|
||||
"checkoutActivated": "Your package is now active.",
|
||||
"checkoutPendingTitle": "Activating your package",
|
||||
"checkoutPendingBody": "This can take a few minutes. We will update this screen once the package is active.",
|
||||
"checkoutPendingBadge": "Pending",
|
||||
"checkoutPendingRefresh": "Refresh",
|
||||
"checkoutPendingDismiss": "Dismiss",
|
||||
"checkoutFailedTitle": "Checkout failed",
|
||||
"checkoutFailedBody": "The payment did not complete. You can try again or contact support.",
|
||||
"checkoutFailedBadge": "Failed",
|
||||
"checkoutFailedRetry": "Try again",
|
||||
"checkoutFailedDismiss": "Dismiss",
|
||||
"checkoutActionTitle": "Action required",
|
||||
"checkoutActionBody": "Complete your payment to activate the package.",
|
||||
"checkoutActionBadge": "Action needed",
|
||||
"checkoutActionButton": "Continue checkout",
|
||||
"checkoutFailureReasons": {
|
||||
"paddle_failed": "The payment was declined.",
|
||||
"paddle_cancelled": "The checkout was cancelled."
|
||||
},
|
||||
"sections": {
|
||||
"invoices": {
|
||||
"title": "Invoices & payments",
|
||||
@@ -172,6 +193,8 @@
|
||||
},
|
||||
"common": {
|
||||
"all": "All",
|
||||
"anonymous": "Anonymous",
|
||||
"error": "Something went wrong",
|
||||
"loadMore": "Load more",
|
||||
"processing": "Processing…",
|
||||
"select": "Select",
|
||||
@@ -2879,16 +2902,25 @@
|
||||
"analytics": {
|
||||
"title": "Analytics",
|
||||
"upgradeAction": "Upgrade to Premium",
|
||||
"kpiTitle": "Event snapshot",
|
||||
"kpiUploads": "Uploads",
|
||||
"kpiContributors": "Contributors",
|
||||
"kpiLikes": "Likes",
|
||||
"activityTitle": "Activity Timeline",
|
||||
"timeframe": "Last {{hours}} hours",
|
||||
"timeframeHint": "Older activity hidden",
|
||||
"uploadsPerHour": "Uploads per hour",
|
||||
"noActivity": "No uploads yet",
|
||||
"emptyActionShareQr": "Share your QR code",
|
||||
"contributorsTitle": "Top Contributors",
|
||||
"likesCount": "{{count}} likes",
|
||||
"likesCount_one": "{{count}} like",
|
||||
"likesCount_other": "{{count}} likes",
|
||||
"noContributors": "No contributors yet",
|
||||
"emptyActionInvite": "Invite guests",
|
||||
"tasksTitle": "Popular Tasks",
|
||||
"noTasks": "No task activity yet",
|
||||
"emptyActionOpenTasks": "Open tasks",
|
||||
"lockedTitle": "Unlock Analytics",
|
||||
"lockedBody": "Get deep insights into your event engagement with the Premium package."
|
||||
},
|
||||
@@ -2897,6 +2929,26 @@
|
||||
"subtitle": "Choose a package to unlock more features and limits.",
|
||||
"recommendationTitle": "Recommended for you",
|
||||
"recommendationBody": "The highlighted package includes the feature you requested.",
|
||||
"compare": {
|
||||
"title": "Compare plans",
|
||||
"helper": "Swipe to compare packages side by side.",
|
||||
"toggleCards": "Cards",
|
||||
"toggleCompare": "Compare",
|
||||
"headers": {
|
||||
"plan": "Plan",
|
||||
"price": "Price"
|
||||
},
|
||||
"rows": {
|
||||
"photos": "Photos",
|
||||
"guests": "Guests",
|
||||
"days": "Gallery days"
|
||||
},
|
||||
"values": {
|
||||
"included": "Included",
|
||||
"notIncluded": "Not included",
|
||||
"unlimited": "Unlimited"
|
||||
}
|
||||
},
|
||||
"select": "Select",
|
||||
"manage": "Manage Plan",
|
||||
"limits": {
|
||||
@@ -2910,7 +2962,13 @@
|
||||
},
|
||||
"features": {
|
||||
"advanced_analytics": "Advanced Analytics",
|
||||
"basic_uploads": "Basic uploads",
|
||||
"custom_branding": "Custom Branding",
|
||||
"custom_tasks": "Custom tasks",
|
||||
"limited_sharing": "Limited sharing",
|
||||
"live_slideshow": "Live slideshow",
|
||||
"priority_support": "Priority support",
|
||||
"unlimited_sharing": "Unlimited sharing",
|
||||
"watermark_removal": "No Watermark"
|
||||
},
|
||||
"status": {
|
||||
@@ -2922,7 +2980,9 @@
|
||||
},
|
||||
"badges": {
|
||||
"recommended": "Recommended",
|
||||
"active": "Active"
|
||||
"active": "Active",
|
||||
"upgrade": "Upgrade",
|
||||
"downgrade": "Downgrade"
|
||||
},
|
||||
"confirmTitle": "Confirm Purchase",
|
||||
"confirmSubtitle": "You are upgrading to:",
|
||||
@@ -2935,6 +2995,7 @@
|
||||
"payNow": "Pay Now",
|
||||
"errors": {
|
||||
"checkout": "Checkout failed"
|
||||
}
|
||||
},
|
||||
"selectDisabled": "Not available"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
createTenantBillingPortalSession,
|
||||
getTenantPackagesOverview,
|
||||
getTenantPaddleTransactions,
|
||||
getTenantPackageCheckoutStatus,
|
||||
TenantPackageSummary,
|
||||
PaddleTransactionSummary,
|
||||
} from '../api';
|
||||
@@ -27,6 +28,14 @@ import {
|
||||
getPackageFeatureLabel,
|
||||
getPackageLimitEntries,
|
||||
} from './lib/packageSummary';
|
||||
import {
|
||||
PendingCheckout,
|
||||
loadPendingCheckout,
|
||||
shouldClearPendingCheckout,
|
||||
storePendingCheckout,
|
||||
} from './lib/billingCheckout';
|
||||
|
||||
const CHECKOUT_POLL_INTERVAL_MS = 10000;
|
||||
|
||||
export default function MobileBillingPage() {
|
||||
const { t } = useTranslation('management');
|
||||
@@ -40,6 +49,11 @@ export default function MobileBillingPage() {
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const [portalBusy, setPortalBusy] = React.useState(false);
|
||||
const [pendingCheckout, setPendingCheckout] = React.useState<PendingCheckout | null>(() => loadPendingCheckout());
|
||||
const [checkoutStatus, setCheckoutStatus] = React.useState<string | null>(null);
|
||||
const [checkoutStatusReason, setCheckoutStatusReason] = React.useState<string | null>(null);
|
||||
const [checkoutActionUrl, setCheckoutActionUrl] = React.useState<string | null>(null);
|
||||
const lastCheckoutStatusRef = React.useRef<string | null>(null);
|
||||
const packagesRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const invoicesRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const supportEmail = 'support@fotospiel.de';
|
||||
@@ -95,6 +109,11 @@ export default function MobileBillingPage() {
|
||||
}
|
||||
}, [portalBusy, t]);
|
||||
|
||||
const persistPendingCheckout = React.useCallback((next: PendingCheckout | null) => {
|
||||
setPendingCheckout(next);
|
||||
storePendingCheckout(next);
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
@@ -108,6 +127,115 @@ export default function MobileBillingPage() {
|
||||
}
|
||||
}, [location.hash, loading]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!location.search) {
|
||||
return;
|
||||
}
|
||||
|
||||
const params = new URLSearchParams(location.search);
|
||||
const checkout = params.get('checkout');
|
||||
const packageId = params.get('package_id');
|
||||
if (!checkout) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (checkout === 'success') {
|
||||
const packageIdNumber = packageId ? Number(packageId) : null;
|
||||
const existingSessionId = pendingCheckout?.checkoutSessionId ?? null;
|
||||
const pendingEntry = {
|
||||
packageId: Number.isFinite(packageIdNumber) ? packageIdNumber : null,
|
||||
checkoutSessionId: existingSessionId,
|
||||
startedAt: Date.now(),
|
||||
};
|
||||
persistPendingCheckout(pendingEntry);
|
||||
toast.success(t('billing.checkoutSuccess', 'Checkout completed. Your package will activate shortly.'));
|
||||
} else if (checkout === 'cancel') {
|
||||
persistPendingCheckout(null);
|
||||
toast(t('billing.checkoutCancelled', 'Checkout was cancelled.'));
|
||||
}
|
||||
|
||||
params.delete('checkout');
|
||||
params.delete('package_id');
|
||||
navigate(
|
||||
{
|
||||
pathname: location.pathname,
|
||||
search: params.toString(),
|
||||
hash: location.hash,
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
}, [location.hash, location.pathname, location.search, navigate, pendingCheckout?.checkoutSessionId, persistPendingCheckout, t]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!pendingCheckout) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (shouldClearPendingCheckout(pendingCheckout, activePackage?.package_id ?? null)) {
|
||||
persistPendingCheckout(null);
|
||||
}
|
||||
}, [activePackage?.package_id, pendingCheckout, persistPendingCheckout]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!pendingCheckout?.checkoutSessionId) {
|
||||
setCheckoutStatus(null);
|
||||
setCheckoutStatusReason(null);
|
||||
setCheckoutActionUrl(null);
|
||||
lastCheckoutStatusRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
let active = true;
|
||||
let intervalId: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
const poll = async () => {
|
||||
try {
|
||||
const result = await getTenantPackageCheckoutStatus(pendingCheckout.checkoutSessionId as string);
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
setCheckoutStatus(result.status);
|
||||
setCheckoutStatusReason(result.reason ?? null);
|
||||
setCheckoutActionUrl(typeof result.checkout_url === 'string' ? result.checkout_url : null);
|
||||
|
||||
const lastStatus = lastCheckoutStatusRef.current;
|
||||
lastCheckoutStatusRef.current = result.status;
|
||||
|
||||
if (result.status === 'completed') {
|
||||
persistPendingCheckout(null);
|
||||
if (lastStatus !== 'completed') {
|
||||
toast.success(t('billing.checkoutActivated', 'Your package is now active.'));
|
||||
}
|
||||
await load();
|
||||
if (intervalId) {
|
||||
clearInterval(intervalId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.status === 'failed' || result.status === 'cancelled') {
|
||||
if (intervalId) {
|
||||
clearInterval(intervalId);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
void poll();
|
||||
intervalId = setInterval(poll, CHECKOUT_POLL_INTERVAL_MS);
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
if (intervalId) {
|
||||
clearInterval(intervalId);
|
||||
}
|
||||
};
|
||||
}, [load, pendingCheckout?.checkoutSessionId, persistPendingCheckout, t]);
|
||||
|
||||
return (
|
||||
<MobileShell
|
||||
activeTab="profile"
|
||||
@@ -127,6 +255,109 @@ export default function MobileBillingPage() {
|
||||
<CTAButton label={t('billing.actions.refresh', 'Refresh')} tone="ghost" onPress={load} />
|
||||
</MobileCard>
|
||||
) : null}
|
||||
{pendingCheckout && (checkoutStatus === 'failed' || checkoutStatus === 'cancelled') ? (
|
||||
<MobileCard borderColor={danger} backgroundColor="$red1" space="$2">
|
||||
<XStack alignItems="center" justifyContent="space-between">
|
||||
<YStack space="$0.5" flex={1}>
|
||||
<Text fontSize="$sm" fontWeight="800" color={danger}>
|
||||
{t('billing.checkoutFailedTitle', 'Checkout failed')}
|
||||
</Text>
|
||||
<Text fontSize="$xs" color={muted}>
|
||||
{t(
|
||||
'billing.checkoutFailedBody',
|
||||
'The payment did not complete. You can try again or contact support.'
|
||||
)}
|
||||
</Text>
|
||||
{checkoutStatusReason ? (
|
||||
<Text fontSize="$xs" color={muted}>
|
||||
{t(`billing.checkoutFailureReasons.${checkoutStatusReason}`, checkoutStatusReason)}
|
||||
</Text>
|
||||
) : null}
|
||||
</YStack>
|
||||
<PillBadge tone="danger">
|
||||
{t('billing.checkoutFailedBadge', 'Failed')}
|
||||
</PillBadge>
|
||||
</XStack>
|
||||
<XStack space="$2">
|
||||
<CTAButton
|
||||
label={t('billing.checkoutFailedRetry', 'Try again')}
|
||||
onPress={() => navigate(adminPath('/mobile/billing/shop'))}
|
||||
fullWidth={false}
|
||||
/>
|
||||
<CTAButton
|
||||
label={t('billing.checkoutFailedDismiss', 'Dismiss')}
|
||||
tone="ghost"
|
||||
onPress={() => persistPendingCheckout(null)}
|
||||
fullWidth={false}
|
||||
/>
|
||||
</XStack>
|
||||
</MobileCard>
|
||||
) : null}
|
||||
{pendingCheckout && checkoutStatus === 'requires_customer_action' ? (
|
||||
<MobileCard borderColor={accentSoft} backgroundColor={accentSoft} space="$2">
|
||||
<XStack alignItems="center" justifyContent="space-between">
|
||||
<YStack space="$0.5" flex={1}>
|
||||
<Text fontSize="$sm" fontWeight="800" color={textStrong}>
|
||||
{t('billing.checkoutActionTitle', 'Action required')}
|
||||
</Text>
|
||||
<Text fontSize="$xs" color={muted}>
|
||||
{t('billing.checkoutActionBody', 'Complete your payment to activate the package.')}
|
||||
</Text>
|
||||
</YStack>
|
||||
<PillBadge tone="warning">
|
||||
{t('billing.checkoutActionBadge', 'Action needed')}
|
||||
</PillBadge>
|
||||
</XStack>
|
||||
<XStack space="$2">
|
||||
<CTAButton
|
||||
label={t('billing.checkoutActionButton', 'Continue checkout')}
|
||||
onPress={() => {
|
||||
if (checkoutActionUrl && typeof window !== 'undefined') {
|
||||
window.open(checkoutActionUrl, '_blank', 'noopener');
|
||||
return;
|
||||
}
|
||||
navigate(adminPath('/mobile/billing/shop'));
|
||||
}}
|
||||
fullWidth={false}
|
||||
/>
|
||||
<CTAButton
|
||||
label={t('billing.checkoutFailedDismiss', 'Dismiss')}
|
||||
tone="ghost"
|
||||
onPress={() => persistPendingCheckout(null)}
|
||||
fullWidth={false}
|
||||
/>
|
||||
</XStack>
|
||||
</MobileCard>
|
||||
) : null}
|
||||
{pendingCheckout && checkoutStatus !== 'failed' && checkoutStatus !== 'cancelled' && checkoutStatus !== 'requires_customer_action' ? (
|
||||
<MobileCard borderColor={accentSoft} backgroundColor={accentSoft} space="$2">
|
||||
<XStack alignItems="center" justifyContent="space-between">
|
||||
<YStack space="$0.5" flex={1}>
|
||||
<Text fontSize="$sm" fontWeight="800" color={textStrong}>
|
||||
{t('billing.checkoutPendingTitle', 'Activating your package')}
|
||||
</Text>
|
||||
<Text fontSize="$xs" color={muted}>
|
||||
{t(
|
||||
'billing.checkoutPendingBody',
|
||||
'This can take a few minutes. We will update this screen once the package is active.'
|
||||
)}
|
||||
</Text>
|
||||
</YStack>
|
||||
<PillBadge tone="warning">
|
||||
{t('billing.checkoutPendingBadge', 'Pending')}
|
||||
</PillBadge>
|
||||
</XStack>
|
||||
<XStack space="$2">
|
||||
<CTAButton label={t('billing.checkoutPendingRefresh', 'Refresh')} onPress={load} fullWidth={false} />
|
||||
<CTAButton
|
||||
label={t('billing.checkoutPendingDismiss', 'Dismiss')}
|
||||
tone="ghost"
|
||||
onPress={() => persistPendingCheckout(null)}
|
||||
fullWidth={false}
|
||||
/>
|
||||
</XStack>
|
||||
</MobileCard>
|
||||
) : null}
|
||||
|
||||
<MobileCard space="$2" ref={packagesRef as any}>
|
||||
<XStack alignItems="center" space="$2">
|
||||
|
||||
@@ -2,17 +2,18 @@ import React from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { BarChart2, TrendingUp, Users, ListTodo, Lock, Trophy, Calendar } from 'lucide-react';
|
||||
import { TrendingUp, Users, ListTodo, Lock, Trophy } from 'lucide-react';
|
||||
import { YStack, XStack } from '@tamagui/stacks';
|
||||
import { SizableText as Text } from '@tamagui/text';
|
||||
import { format, parseISO } from 'date-fns';
|
||||
import { de, enGB } from 'date-fns/locale';
|
||||
|
||||
import { MobileShell } from './components/MobileShell';
|
||||
import { MobileCard, CTAButton, SkeletonCard } from './components/Primitives';
|
||||
import { MobileCard, CTAButton, KpiTile, SkeletonCard } from './components/Primitives';
|
||||
import { getEventAnalytics, EventAnalytics } from '../api';
|
||||
import { ApiError } from '../lib/apiError';
|
||||
import { useAdminTheme } from './theme';
|
||||
import { resolveMaxCount, resolveTimelineHours } from './lib/analytics';
|
||||
import { adminPath } from '../constants';
|
||||
|
||||
export default function MobileEventAnalyticsPage() {
|
||||
@@ -97,9 +98,17 @@ export default function MobileEventAnalyticsPage() {
|
||||
const hasTimeline = timeline.length > 0;
|
||||
const hasContributors = contributors.length > 0;
|
||||
const hasTasks = tasks.length > 0;
|
||||
const fallbackHours = 12;
|
||||
const rawTimelineHours = resolveTimelineHours(timeline.map((point) => point.timestamp), fallbackHours);
|
||||
const timeframeHours = Math.min(rawTimelineHours, fallbackHours);
|
||||
const isTimeframeCapped = rawTimelineHours > fallbackHours;
|
||||
|
||||
// Prepare chart data
|
||||
const maxCount = Math.max(...timeline.map((p) => p.count), 1);
|
||||
const maxTimelineCount = resolveMaxCount(timeline.map((point) => point.count));
|
||||
const maxTaskCount = resolveMaxCount(tasks.map((task) => task.count));
|
||||
const totalUploads = timeline.reduce((total, point) => total + point.count, 0);
|
||||
const totalLikes = contributors.reduce((total, contributor) => total + contributor.likes, 0);
|
||||
const totalContributors = contributors.length;
|
||||
|
||||
return (
|
||||
<MobileShell
|
||||
@@ -108,6 +117,28 @@ export default function MobileEventAnalyticsPage() {
|
||||
onBack={() => navigate(-1)}
|
||||
>
|
||||
<YStack space="$4">
|
||||
<YStack space="$2">
|
||||
<Text fontSize="$sm" fontWeight="800" color={textStrong}>
|
||||
{t('analytics.kpiTitle', 'Event snapshot')}
|
||||
</Text>
|
||||
<XStack space="$2" flexWrap="wrap">
|
||||
<KpiTile
|
||||
icon={TrendingUp}
|
||||
label={t('analytics.kpiUploads', 'Uploads')}
|
||||
value={totalUploads}
|
||||
/>
|
||||
<KpiTile
|
||||
icon={Users}
|
||||
label={t('analytics.kpiContributors', 'Contributors')}
|
||||
value={totalContributors}
|
||||
/>
|
||||
<KpiTile
|
||||
icon={Trophy}
|
||||
label={t('analytics.kpiLikes', 'Likes')}
|
||||
value={totalLikes}
|
||||
/>
|
||||
</XStack>
|
||||
</YStack>
|
||||
{/* Activity Timeline */}
|
||||
<MobileCard space="$3" borderColor={border} backgroundColor={surface}>
|
||||
<XStack alignItems="center" space="$2">
|
||||
@@ -116,12 +147,22 @@ export default function MobileEventAnalyticsPage() {
|
||||
{t('analytics.activityTitle', 'Activity Timeline')}
|
||||
</Text>
|
||||
</XStack>
|
||||
<YStack space="$0.5">
|
||||
<Text fontSize="$xs" color={muted}>
|
||||
{t('analytics.timeframe', 'Last {{hours}} hours', { hours: timeframeHours })}
|
||||
</Text>
|
||||
{isTimeframeCapped ? (
|
||||
<Text fontSize="$xs" color={muted}>
|
||||
{t('analytics.timeframeHint', 'Older activity hidden')}
|
||||
</Text>
|
||||
) : null}
|
||||
</YStack>
|
||||
|
||||
{hasTimeline ? (
|
||||
<YStack height={180} justifyContent="flex-end" space="$2">
|
||||
<XStack alignItems="flex-end" justifyContent="space-between" height={150} gap="$1">
|
||||
{timeline.map((point, index) => {
|
||||
const heightPercent = (point.count / maxCount) * 100;
|
||||
const heightPercent = (point.count / maxTimelineCount) * 100;
|
||||
const date = parseISO(point.timestamp);
|
||||
// Show label every 3rd point or if few points
|
||||
const showLabel = timeline.length < 8 || index % 3 === 0;
|
||||
@@ -138,7 +179,7 @@ export default function MobileEventAnalyticsPage() {
|
||||
/>
|
||||
{showLabel && (
|
||||
<Text fontSize={10} color={muted} numberOfLines={1}>
|
||||
{format(date, 'HH:mm')}
|
||||
{format(date, 'HH:mm', { locale: dateLocale })}
|
||||
</Text>
|
||||
)}
|
||||
</YStack>
|
||||
@@ -150,7 +191,11 @@ export default function MobileEventAnalyticsPage() {
|
||||
</Text>
|
||||
</YStack>
|
||||
) : (
|
||||
<EmptyState message={t('analytics.noActivity', 'No uploads yet')} />
|
||||
<EmptyState
|
||||
message={t('analytics.noActivity', 'No uploads yet')}
|
||||
actionLabel={t('analytics.emptyActionShareQr', 'Share your QR code')}
|
||||
onAction={() => slug && navigate(adminPath(`/mobile/events/${slug}/qr`))}
|
||||
/>
|
||||
)}
|
||||
</MobileCard>
|
||||
|
||||
@@ -196,7 +241,11 @@ export default function MobileEventAnalyticsPage() {
|
||||
))}
|
||||
</YStack>
|
||||
) : (
|
||||
<EmptyState message={t('analytics.noContributors', 'No contributors yet')} />
|
||||
<EmptyState
|
||||
message={t('analytics.noContributors', 'No contributors yet')}
|
||||
actionLabel={t('analytics.emptyActionInvite', 'Invite guests')}
|
||||
onAction={() => slug && navigate(adminPath(`/mobile/events/${slug}/members`))}
|
||||
/>
|
||||
)}
|
||||
</MobileCard>
|
||||
|
||||
@@ -212,7 +261,6 @@ export default function MobileEventAnalyticsPage() {
|
||||
{hasTasks ? (
|
||||
<YStack space="$3">
|
||||
{tasks.map((task) => {
|
||||
const maxTaskCount = Math.max(...tasks.map(t => t.count), 1);
|
||||
const percent = (task.count / maxTaskCount) * 100;
|
||||
return (
|
||||
<YStack key={task.task_id} space="$1">
|
||||
@@ -237,7 +285,11 @@ export default function MobileEventAnalyticsPage() {
|
||||
})}
|
||||
</YStack>
|
||||
) : (
|
||||
<EmptyState message={t('analytics.noTasks', 'No task activity yet')} />
|
||||
<EmptyState
|
||||
message={t('analytics.noTasks', 'No task activity yet')}
|
||||
actionLabel={t('analytics.emptyActionOpenTasks', 'Open tasks')}
|
||||
onAction={() => slug && navigate(adminPath(`/mobile/events/${slug}/tasks`))}
|
||||
/>
|
||||
)}
|
||||
</MobileCard>
|
||||
</YStack>
|
||||
@@ -245,13 +297,24 @@ export default function MobileEventAnalyticsPage() {
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyState({ message }: { message: string }) {
|
||||
function EmptyState({
|
||||
message,
|
||||
actionLabel,
|
||||
onAction,
|
||||
}: {
|
||||
message: string;
|
||||
actionLabel?: string;
|
||||
onAction?: () => void;
|
||||
}) {
|
||||
const { muted } = useAdminTheme();
|
||||
return (
|
||||
<YStack padding="$4" alignItems="center" justifyContent="center">
|
||||
<YStack padding="$4" alignItems="center" justifyContent="center" space="$2">
|
||||
<Text fontSize="$sm" color={muted}>
|
||||
{message}
|
||||
</Text>
|
||||
{actionLabel && onAction ? (
|
||||
<CTAButton label={actionLabel} tone="ghost" fullWidth={false} onPress={onAction} />
|
||||
) : null}
|
||||
</YStack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,25 +1,31 @@
|
||||
import React from 'react';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Check, ChevronRight, ShieldCheck, ShoppingBag, Sparkles, Star } from 'lucide-react';
|
||||
import { Check, ChevronRight, ShieldCheck, Sparkles, X } from 'lucide-react';
|
||||
import { YStack, XStack } from '@tamagui/stacks';
|
||||
import { SizableText as Text } from '@tamagui/text';
|
||||
import { Checkbox } from '@tamagui/checkbox';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
import { MobileShell } from './components/MobileShell';
|
||||
import { MobileCard, CTAButton, PillBadge, SkeletonCard } from './components/Primitives';
|
||||
import { useAdminTheme } from './theme';
|
||||
import { getPackages, createTenantPaddleCheckout, Package, getTenantPackagesOverview, TenantPackageSummary } from '../api';
|
||||
import { getApiErrorMessage } from '../lib/apiError';
|
||||
import { getPackages, Package, getTenantPackagesOverview, TenantPackageSummary } from '../api';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
buildPackageComparisonRows,
|
||||
classifyPackageChange,
|
||||
getEnabledPackageFeatures,
|
||||
selectRecommendedPackageId,
|
||||
} from './lib/packageShop';
|
||||
import { usePackageCheckout } from './hooks/usePackageCheckout';
|
||||
|
||||
export default function MobilePackageShopPage() {
|
||||
const { t } = useTranslation('management');
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { textStrong, muted, border, primary, surface, accentSoft, warningText } = useAdminTheme();
|
||||
const { textStrong, muted, border, primary, accentSoft } = useAdminTheme();
|
||||
const [selectedPackage, setSelectedPackage] = React.useState<Package | null>(null);
|
||||
const [viewMode, setViewMode] = React.useState<'cards' | 'compare'>('cards');
|
||||
|
||||
// Extract recommended feature from URL
|
||||
const searchParams = new URLSearchParams(location.search);
|
||||
@@ -57,19 +63,36 @@ export default function MobilePackageShopPage() {
|
||||
);
|
||||
}
|
||||
|
||||
const activePackageId = inventory?.activePackage?.package_id ?? null;
|
||||
const activeCatalogPackage = (catalog ?? []).find((pkg) => pkg.id === activePackageId) ?? null;
|
||||
const recommendedPackageId = selectRecommendedPackageId(catalog ?? [], recommendedFeature, activeCatalogPackage);
|
||||
|
||||
// Merge and sort packages
|
||||
const sortedPackages = [...(catalog || [])].sort((a, b) => {
|
||||
// 1. Recommended feature first
|
||||
const aHasFeature = recommendedFeature && a.features?.[recommendedFeature];
|
||||
const bHasFeature = recommendedFeature && b.features?.[recommendedFeature];
|
||||
if (aHasFeature && !bHasFeature) return -1;
|
||||
if (!aHasFeature && bHasFeature) return 1;
|
||||
if (recommendedPackageId) {
|
||||
if (a.id === recommendedPackageId && b.id !== recommendedPackageId) return -1;
|
||||
if (b.id === recommendedPackageId && a.id !== recommendedPackageId) return 1;
|
||||
}
|
||||
|
||||
// 2. Inventory status (Owned packages later if they are fully used, but usually we want to show active stuff)
|
||||
// Actually, let's keep price sorting as secondary
|
||||
return a.price - b.price;
|
||||
});
|
||||
|
||||
const packageEntries = sortedPackages.map((pkg) => {
|
||||
const owned = inventory?.packages?.find((entry) => entry.package_id === pkg.id);
|
||||
const isActive = inventory?.activePackage?.package_id === pkg.id;
|
||||
const isRecommended = recommendedPackageId ? pkg.id === recommendedPackageId : false;
|
||||
const { isUpgrade, isDowngrade } = classifyPackageChange(pkg, activeCatalogPackage);
|
||||
|
||||
return {
|
||||
pkg,
|
||||
owned,
|
||||
isActive,
|
||||
isRecommended,
|
||||
isUpgrade,
|
||||
isDowngrade,
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<MobileShell title={t('shop.title', 'Upgrade Package')} onBack={() => navigate(-1)} activeTab="profile">
|
||||
<YStack space="$4">
|
||||
@@ -93,23 +116,45 @@ export default function MobilePackageShopPage() {
|
||||
</Text>
|
||||
</YStack>
|
||||
|
||||
<YStack space="$3">
|
||||
{sortedPackages.map((pkg) => {
|
||||
const owned = inventory?.packages?.find(p => p.package_id === pkg.id);
|
||||
const isActive = inventory?.activePackage?.package_id === pkg.id;
|
||||
const isRecommended = recommendedFeature && pkg.features?.[recommendedFeature];
|
||||
|
||||
return (
|
||||
<PackageShopCard
|
||||
key={pkg.id}
|
||||
pkg={pkg}
|
||||
owned={owned}
|
||||
isActive={isActive}
|
||||
isRecommended={isRecommended}
|
||||
onSelect={() => setSelectedPackage(pkg)}
|
||||
{packageEntries.length > 1 ? (
|
||||
<XStack space="$2" paddingHorizontal="$2">
|
||||
<CTAButton
|
||||
label={t('shop.compare.toggleCards', 'Cards')}
|
||||
tone={viewMode === 'cards' ? 'primary' : 'ghost'}
|
||||
fullWidth={false}
|
||||
onPress={() => setViewMode('cards')}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<CTAButton
|
||||
label={t('shop.compare.toggleCompare', 'Compare')}
|
||||
tone={viewMode === 'compare' ? 'primary' : 'ghost'}
|
||||
fullWidth={false}
|
||||
onPress={() => setViewMode('compare')}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
</XStack>
|
||||
) : null}
|
||||
|
||||
<YStack space="$3">
|
||||
{viewMode === 'compare' ? (
|
||||
<PackageShopCompareView
|
||||
entries={packageEntries}
|
||||
onSelect={(pkg) => setSelectedPackage(pkg)}
|
||||
/>
|
||||
) : (
|
||||
packageEntries.map((entry) => (
|
||||
<PackageShopCard
|
||||
key={entry.pkg.id}
|
||||
pkg={entry.pkg}
|
||||
owned={entry.owned}
|
||||
isActive={entry.isActive}
|
||||
isRecommended={entry.isRecommended}
|
||||
isUpgrade={entry.isUpgrade}
|
||||
isDowngrade={entry.isDowngrade}
|
||||
onSelect={() => setSelectedPackage(entry.pkg)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</YStack>
|
||||
</YStack>
|
||||
</MobileShell>
|
||||
@@ -121,34 +166,34 @@ function PackageShopCard({
|
||||
owned,
|
||||
isActive,
|
||||
isRecommended,
|
||||
isUpgrade,
|
||||
isDowngrade,
|
||||
onSelect
|
||||
}: {
|
||||
pkg: Package;
|
||||
owned?: TenantPackageSummary;
|
||||
isActive?: boolean;
|
||||
isRecommended?: any;
|
||||
isUpgrade?: boolean;
|
||||
isDowngrade?: boolean;
|
||||
onSelect: () => void
|
||||
}) {
|
||||
const { textStrong, muted, border, primary, accentSoft } = useAdminTheme();
|
||||
const { t } = useTranslation('management');
|
||||
|
||||
const hasRemainingEvents = owned && (owned.remaining_events === null || owned.remaining_events > 0);
|
||||
const statusLabel = isActive
|
||||
? t('shop.status.active', 'Active Plan')
|
||||
: owned
|
||||
? (owned.remaining_events !== null
|
||||
? t('shop.status.remaining', '{{count}} Events left', { count: owned.remaining_events })
|
||||
: t('shop.status.owned', 'Purchased'))
|
||||
: null;
|
||||
const statusLabel = getPackageStatusLabel({ t, isActive, owned });
|
||||
const isSubdued = Boolean((isDowngrade || !isUpgrade) && !isActive);
|
||||
const canSelect = canSelectPackage(isUpgrade, isActive);
|
||||
|
||||
return (
|
||||
<MobileCard
|
||||
onPress={onSelect}
|
||||
onPress={canSelect ? onSelect : undefined}
|
||||
borderColor={isRecommended ? primary : (isActive ? '$green8' : border)}
|
||||
borderWidth={isRecommended || isActive ? 2 : 1}
|
||||
space="$3"
|
||||
pressStyle={{ backgroundColor: accentSoft }}
|
||||
pressStyle={canSelect ? { backgroundColor: accentSoft } : undefined}
|
||||
backgroundColor={isActive ? '$green1' : undefined}
|
||||
style={{ opacity: isSubdued ? 0.6 : 1 }}
|
||||
>
|
||||
<XStack justifyContent="space-between" alignItems="flex-start">
|
||||
<YStack space="$1">
|
||||
@@ -157,6 +202,8 @@ function PackageShopCard({
|
||||
{pkg.name}
|
||||
</Text>
|
||||
{isRecommended && <PillBadge tone="warning">{t('shop.badges.recommended', 'Recommended')}</PillBadge>}
|
||||
{isUpgrade && !isActive ? <PillBadge tone="success">{t('shop.badges.upgrade', 'Upgrade')}</PillBadge> : null}
|
||||
{isDowngrade && !isActive ? <PillBadge tone="muted">{t('shop.badges.downgrade', 'Downgrade')}</PillBadge> : null}
|
||||
{isActive && <PillBadge tone="success">{t('shop.badges.active', 'Active')}</PillBadge>}
|
||||
</XStack>
|
||||
|
||||
@@ -187,19 +234,25 @@ function PackageShopCard({
|
||||
) : null}
|
||||
|
||||
{/* Render specific feature if it was requested */}
|
||||
{Object.entries(pkg.features || {})
|
||||
.filter(([key, val]) => val === true && (!pkg.max_photos || key !== 'photos'))
|
||||
{getEnabledPackageFeatures(pkg)
|
||||
.filter((key) => !pkg.max_photos || key !== 'photos')
|
||||
.slice(0, 3)
|
||||
.map(([key]) => (
|
||||
.map((key) => (
|
||||
<FeatureRow key={key} label={t(`shop.features.${key}`, key)} />
|
||||
))
|
||||
}
|
||||
))}
|
||||
</YStack>
|
||||
|
||||
<CTAButton
|
||||
label={isActive ? t('shop.manage', 'Manage Plan') : t('shop.select', 'Select')}
|
||||
onPress={onSelect}
|
||||
tone={isActive ? 'ghost' : 'primary'}
|
||||
label={
|
||||
isActive
|
||||
? t('shop.manage', 'Manage Plan')
|
||||
: isUpgrade
|
||||
? t('shop.select', 'Select')
|
||||
: t('shop.selectDisabled', 'Not available')
|
||||
}
|
||||
onPress={canSelect ? onSelect : undefined}
|
||||
tone={isActive || !isUpgrade ? 'ghost' : 'primary'}
|
||||
disabled={!canSelect}
|
||||
/>
|
||||
</MobileCard>
|
||||
);
|
||||
@@ -215,28 +268,224 @@ function FeatureRow({ label }: { label: string }) {
|
||||
)
|
||||
}
|
||||
|
||||
type PackageEntry = {
|
||||
pkg: Package;
|
||||
owned?: TenantPackageSummary;
|
||||
isActive: boolean;
|
||||
isRecommended: boolean;
|
||||
isUpgrade: boolean;
|
||||
isDowngrade: boolean;
|
||||
};
|
||||
|
||||
function PackageShopCompareView({
|
||||
entries,
|
||||
onSelect,
|
||||
}: {
|
||||
entries: PackageEntry[];
|
||||
onSelect: (pkg: Package) => void;
|
||||
}) {
|
||||
const { t } = useTranslation('management');
|
||||
const { textStrong, muted, border, primary, accentSoft } = useAdminTheme();
|
||||
const comparisonRows = buildPackageComparisonRows(entries.map((entry) => entry.pkg));
|
||||
const labelWidth = 140;
|
||||
const columnWidth = 150;
|
||||
|
||||
const rows = [
|
||||
{ id: 'meta.plan', type: 'meta' as const, label: t('shop.compare.headers.plan', 'Plan') },
|
||||
{ id: 'meta.price', type: 'meta' as const, label: t('shop.compare.headers.price', 'Price') },
|
||||
...comparisonRows,
|
||||
];
|
||||
|
||||
const renderRowLabel = (row: typeof rows[number]) => {
|
||||
if (row.type === 'meta') {
|
||||
return row.label;
|
||||
}
|
||||
|
||||
if (row.type === 'limit') {
|
||||
if (row.limitKey === 'max_photos') {
|
||||
return t('shop.compare.rows.photos', 'Photos');
|
||||
}
|
||||
if (row.limitKey === 'max_guests') {
|
||||
return t('shop.compare.rows.guests', 'Guests');
|
||||
}
|
||||
return t('shop.compare.rows.days', 'Gallery days');
|
||||
}
|
||||
|
||||
return t(`shop.features.${row.featureKey}`, row.featureKey);
|
||||
};
|
||||
|
||||
const formatLimitValue = (value: number | null) => {
|
||||
if (value === null) {
|
||||
return t('shop.compare.values.unlimited', 'Unlimited');
|
||||
}
|
||||
return new Intl.NumberFormat().format(value);
|
||||
};
|
||||
|
||||
return (
|
||||
<MobileCard space="$3" borderColor={border}>
|
||||
<YStack space="$1">
|
||||
<Text fontSize="$md" fontWeight="700" color={textStrong}>
|
||||
{t('shop.compare.title', 'Compare plans')}
|
||||
</Text>
|
||||
<Text fontSize="$xs" color={muted}>
|
||||
{t('shop.compare.helper', 'Swipe to compare packages side by side.')}
|
||||
</Text>
|
||||
</YStack>
|
||||
|
||||
<XStack style={{ overflowX: 'auto' }}>
|
||||
<YStack space="$1.5" minWidth={labelWidth + columnWidth * entries.length}>
|
||||
{rows.map((row) => (
|
||||
<XStack key={row.id} borderBottomWidth={1} borderColor={border}>
|
||||
<YStack
|
||||
width={labelWidth}
|
||||
paddingVertical="$2"
|
||||
paddingRight="$3"
|
||||
justifyContent="center"
|
||||
>
|
||||
<Text fontSize="$xs" fontWeight="700" color={muted}>
|
||||
{renderRowLabel(row)}
|
||||
</Text>
|
||||
</YStack>
|
||||
{entries.map((entry) => {
|
||||
const cellBackground = entry.isRecommended ? accentSoft : entry.isActive ? '$green1' : undefined;
|
||||
let content: React.ReactNode = null;
|
||||
|
||||
if (row.type === 'meta') {
|
||||
if (row.id === 'meta.plan') {
|
||||
const statusLabel = getPackageStatusLabel({ t, isActive: entry.isActive, owned: entry.owned });
|
||||
content = (
|
||||
<YStack space="$1">
|
||||
<Text fontSize="$sm" fontWeight="800" color={textStrong}>
|
||||
{entry.pkg.name}
|
||||
</Text>
|
||||
<XStack space="$1.5" flexWrap="wrap">
|
||||
{entry.isRecommended ? (
|
||||
<PillBadge tone="warning">{t('shop.badges.recommended', 'Recommended')}</PillBadge>
|
||||
) : null}
|
||||
{entry.isUpgrade && !entry.isActive ? (
|
||||
<PillBadge tone="success">{t('shop.badges.upgrade', 'Upgrade')}</PillBadge>
|
||||
) : null}
|
||||
{entry.isDowngrade && !entry.isActive ? (
|
||||
<PillBadge tone="muted">{t('shop.badges.downgrade', 'Downgrade')}</PillBadge>
|
||||
) : null}
|
||||
{entry.isActive ? <PillBadge tone="success">{t('shop.badges.active', 'Active')}</PillBadge> : null}
|
||||
</XStack>
|
||||
{statusLabel ? (
|
||||
<Text fontSize="$xs" color={muted}>
|
||||
{statusLabel}
|
||||
</Text>
|
||||
) : null}
|
||||
</YStack>
|
||||
);
|
||||
} else if (row.id === 'meta.price') {
|
||||
content = (
|
||||
<Text fontSize="$sm" fontWeight="700" color={primary}>
|
||||
{new Intl.NumberFormat(undefined, { style: 'currency', currency: 'EUR' }).format(entry.pkg.price)}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
} else if (row.type === 'limit') {
|
||||
const value = entry.pkg[row.limitKey] ?? null;
|
||||
content = (
|
||||
<Text fontSize="$sm" fontWeight="600" color={textStrong}>
|
||||
{formatLimitValue(value)}
|
||||
</Text>
|
||||
);
|
||||
} else if (row.type === 'feature') {
|
||||
const enabled = getEnabledPackageFeatures(entry.pkg).includes(row.featureKey);
|
||||
content = (
|
||||
<XStack alignItems="center" space="$1.5">
|
||||
{enabled ? (
|
||||
<Check size={16} color={primary} />
|
||||
) : (
|
||||
<X size={14} color={muted} />
|
||||
)}
|
||||
<Text fontSize="$sm" color={enabled ? textStrong : muted}>
|
||||
{enabled ? t('shop.compare.values.included', 'Included') : t('shop.compare.values.notIncluded', 'Not included')}
|
||||
</Text>
|
||||
</XStack>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<YStack
|
||||
key={`${row.id}-${entry.pkg.id}`}
|
||||
width={columnWidth}
|
||||
paddingVertical="$2"
|
||||
paddingHorizontal="$2"
|
||||
justifyContent="center"
|
||||
backgroundColor={cellBackground}
|
||||
>
|
||||
{content}
|
||||
</YStack>
|
||||
);
|
||||
})}
|
||||
</XStack>
|
||||
))}
|
||||
<XStack paddingTop="$2">
|
||||
<YStack width={labelWidth} />
|
||||
{entries.map((entry) => {
|
||||
const canSelect = canSelectPackage(entry.isUpgrade, entry.isActive);
|
||||
const label = entry.isActive
|
||||
? t('shop.manage', 'Manage Plan')
|
||||
: entry.isUpgrade
|
||||
? t('shop.select', 'Select')
|
||||
: t('shop.selectDisabled', 'Not available');
|
||||
|
||||
return (
|
||||
<YStack key={`cta-${entry.pkg.id}`} width={columnWidth} paddingHorizontal="$2">
|
||||
<CTAButton
|
||||
label={label}
|
||||
onPress={canSelect ? () => onSelect(entry.pkg) : undefined}
|
||||
disabled={!canSelect}
|
||||
tone={entry.isActive || entry.isDowngrade ? 'ghost' : 'primary'}
|
||||
/>
|
||||
</YStack>
|
||||
);
|
||||
})}
|
||||
</XStack>
|
||||
</YStack>
|
||||
</XStack>
|
||||
</MobileCard>
|
||||
);
|
||||
}
|
||||
|
||||
function getPackageStatusLabel({
|
||||
t,
|
||||
isActive,
|
||||
owned,
|
||||
}: {
|
||||
t: (key: string, fallback?: string, options?: Record<string, unknown>) => string;
|
||||
isActive?: boolean;
|
||||
owned?: TenantPackageSummary;
|
||||
}): string | null {
|
||||
if (isActive) {
|
||||
return t('shop.status.active', 'Active Plan');
|
||||
}
|
||||
if (owned) {
|
||||
return owned.remaining_events !== null
|
||||
? t('shop.status.remaining', '{{count}} Events left', { count: owned.remaining_events })
|
||||
: t('shop.status.owned', 'Purchased');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function canSelectPackage(isUpgrade?: boolean, isActive?: boolean): boolean {
|
||||
return Boolean(isActive || isUpgrade);
|
||||
}
|
||||
|
||||
function CheckoutConfirmation({ pkg, onCancel }: { pkg: Package; onCancel: () => void }) {
|
||||
const { t } = useTranslation('management');
|
||||
const { textStrong, muted, border, primary, danger } = useAdminTheme();
|
||||
const { textStrong, muted, border, primary } = useAdminTheme();
|
||||
const [agbAccepted, setAgbAccepted] = React.useState(false);
|
||||
const [withdrawalAccepted, setWithdrawalAccepted] = React.useState(false);
|
||||
const [busy, setBusy] = React.useState(false);
|
||||
const { busy, startCheckout } = usePackageCheckout();
|
||||
|
||||
const canProceed = agbAccepted && withdrawalAccepted;
|
||||
|
||||
const handleCheckout = async () => {
|
||||
if (!canProceed || busy) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const { checkout_url } = await createTenantPaddleCheckout(pkg.id, {
|
||||
success_url: window.location.href,
|
||||
return_url: window.location.href,
|
||||
});
|
||||
window.location.href = checkout_url;
|
||||
} catch (err) {
|
||||
toast.error(getApiErrorMessage(err, t('shop.errors.checkout', 'Checkout failed')));
|
||||
setBusy(false);
|
||||
}
|
||||
await startCheckout(pkg.id);
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
33
resources/js/admin/mobile/__tests__/analytics.test.ts
Normal file
33
resources/js/admin/mobile/__tests__/analytics.test.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { resolveMaxCount, resolveTimelineHours } from '../lib/analytics';
|
||||
|
||||
describe('resolveMaxCount', () => {
|
||||
it('defaults to 1 for empty input', () => {
|
||||
expect(resolveMaxCount([])).toBe(1);
|
||||
});
|
||||
|
||||
it('returns the highest count', () => {
|
||||
expect(resolveMaxCount([2, 5, 3])).toBe(5);
|
||||
});
|
||||
|
||||
it('never returns less than 1', () => {
|
||||
expect(resolveMaxCount([0])).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveTimelineHours', () => {
|
||||
it('uses fallback when data is missing', () => {
|
||||
expect(resolveTimelineHours([], 12)).toBe(12);
|
||||
});
|
||||
|
||||
it('calculates rounded hours from timestamps', () => {
|
||||
const start = new Date('2024-01-01T10:00:00Z').toISOString();
|
||||
const end = new Date('2024-01-01T21:00:00Z').toISOString();
|
||||
expect(resolveTimelineHours([start, end], 12)).toBe(11);
|
||||
});
|
||||
|
||||
it('never returns less than 1', () => {
|
||||
const start = new Date('2024-01-01T10:00:00Z').toISOString();
|
||||
expect(resolveTimelineHours([start, start], 12)).toBe(1);
|
||||
});
|
||||
});
|
||||
42
resources/js/admin/mobile/__tests__/billingCheckout.test.ts
Normal file
42
resources/js/admin/mobile/__tests__/billingCheckout.test.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import {
|
||||
CHECKOUT_STORAGE_KEY,
|
||||
PENDING_CHECKOUT_TTL_MS,
|
||||
isCheckoutExpired,
|
||||
loadPendingCheckout,
|
||||
shouldClearPendingCheckout,
|
||||
storePendingCheckout,
|
||||
} from '../lib/billingCheckout';
|
||||
|
||||
describe('billingCheckout helpers', () => {
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear();
|
||||
});
|
||||
it('detects expired pending checkout', () => {
|
||||
const pending = { packageId: 12, startedAt: 0 };
|
||||
expect(isCheckoutExpired(pending, PENDING_CHECKOUT_TTL_MS + 1)).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps pending checkout when active package differs', () => {
|
||||
const pending = { packageId: 12, startedAt: Date.now() };
|
||||
expect(shouldClearPendingCheckout(pending, 18, pending.startedAt)).toBe(false);
|
||||
});
|
||||
|
||||
it('clears pending checkout when active package matches', () => {
|
||||
const now = Date.now();
|
||||
const pending = { packageId: 12, startedAt: now };
|
||||
expect(shouldClearPendingCheckout(pending, 12, now)).toBe(true);
|
||||
});
|
||||
|
||||
it('stores and loads pending checkout from session storage', () => {
|
||||
const pending = { packageId: 7, checkoutSessionId: 'sess_123', startedAt: Date.now() };
|
||||
storePendingCheckout(pending);
|
||||
expect(loadPendingCheckout(pending.startedAt)).toEqual(pending);
|
||||
});
|
||||
|
||||
it('clears pending checkout storage', () => {
|
||||
storePendingCheckout({ packageId: 7, checkoutSessionId: 'sess_123', startedAt: Date.now() });
|
||||
storePendingCheckout(null);
|
||||
expect(sessionStorage.getItem(CHECKOUT_STORAGE_KEY)).toBeNull();
|
||||
});
|
||||
});
|
||||
83
resources/js/admin/mobile/__tests__/packageShop.test.ts
Normal file
83
resources/js/admin/mobile/__tests__/packageShop.test.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
buildPackageComparisonRows,
|
||||
classifyPackageChange,
|
||||
getEnabledPackageFeatures,
|
||||
selectRecommendedPackageId,
|
||||
} from '../lib/packageShop';
|
||||
|
||||
describe('classifyPackageChange', () => {
|
||||
const active = {
|
||||
id: 1,
|
||||
price: 200,
|
||||
max_photos: 100,
|
||||
max_guests: 50,
|
||||
gallery_days: 30,
|
||||
features: { advanced_analytics: false },
|
||||
} as any;
|
||||
|
||||
it('returns neutral when no active package', () => {
|
||||
expect(classifyPackageChange(active, null)).toEqual({ isUpgrade: false, isDowngrade: false });
|
||||
});
|
||||
|
||||
it('marks upgrade when candidate adds features', () => {
|
||||
const candidate = { ...active, id: 2, price: 150, features: { advanced_analytics: true } } as any;
|
||||
expect(classifyPackageChange(candidate, active)).toEqual({ isUpgrade: true, isDowngrade: false });
|
||||
});
|
||||
|
||||
it('marks downgrade when candidate removes features or limits', () => {
|
||||
const candidate = { ...active, id: 3, max_photos: 50, features: { advanced_analytics: false } } as any;
|
||||
expect(classifyPackageChange(candidate, active)).toEqual({ isUpgrade: false, isDowngrade: true });
|
||||
});
|
||||
|
||||
it('treats mixed changes as downgrade', () => {
|
||||
const candidate = { ...active, id: 4, max_photos: 200, gallery_days: 10, features: { advanced_analytics: false } } as any;
|
||||
expect(classifyPackageChange(candidate, active)).toEqual({ isUpgrade: false, isDowngrade: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('selectRecommendedPackageId', () => {
|
||||
const packages = [
|
||||
{ id: 1, price: 100, features: { advanced_analytics: false } },
|
||||
{ id: 2, price: 150, features: { advanced_analytics: true } },
|
||||
{ id: 3, price: 200, features: { advanced_analytics: true } },
|
||||
] as any;
|
||||
|
||||
it('returns null when no feature is requested', () => {
|
||||
expect(selectRecommendedPackageId(packages, null, 100)).toBeNull();
|
||||
});
|
||||
|
||||
it('selects the cheapest upgrade with the feature', () => {
|
||||
const active = { id: 10, price: 120, max_photos: 100, max_guests: 50, gallery_days: 30, features: {} } as any;
|
||||
expect(selectRecommendedPackageId(packages, 'advanced_analytics', active)).toBe(2);
|
||||
});
|
||||
|
||||
it('falls back to cheapest feature package if no upgrades exist', () => {
|
||||
const active = { id: 10, price: 250, max_photos: 999, max_guests: 999, gallery_days: 365, features: { advanced_analytics: true } } as any;
|
||||
expect(selectRecommendedPackageId(packages, 'advanced_analytics', active)).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildPackageComparisonRows', () => {
|
||||
it('includes limit rows and enabled feature rows', () => {
|
||||
const rows = buildPackageComparisonRows([
|
||||
{ features: { advanced_analytics: true, custom_branding: false } },
|
||||
{ features: { custom_branding: true, watermark_removal: true } },
|
||||
] as any);
|
||||
|
||||
expect(rows.map((row) => row.id)).toEqual([
|
||||
'limit.max_photos',
|
||||
'limit.max_guests',
|
||||
'limit.gallery_days',
|
||||
'feature.advanced_analytics',
|
||||
'feature.custom_branding',
|
||||
'feature.watermark_removal',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getEnabledPackageFeatures', () => {
|
||||
it('accepts array payloads', () => {
|
||||
expect(getEnabledPackageFeatures({ features: ['custom_branding', ''] } as any)).toEqual(['custom_branding']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,143 @@
|
||||
import React from 'react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act, render, screen } from '@testing-library/react';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({ t: (key: string, fallback?: string) => fallback ?? key, i18n: { language: 'en-GB' } }),
|
||||
}));
|
||||
|
||||
vi.mock('@tamagui/core', () => ({
|
||||
useTheme: () => ({
|
||||
background: { val: '#FFF8F5' },
|
||||
surface: { val: '#ffffff' },
|
||||
borderColor: { val: '#e5e7eb' },
|
||||
color: { val: '#1f2937' },
|
||||
gray: { val: '#6b7280' },
|
||||
red10: { val: '#b91c1c' },
|
||||
shadowColor: { val: 'rgba(0,0,0,0.12)' },
|
||||
primary: { val: '#FF5A5F' },
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@tamagui/stacks', () => ({
|
||||
YStack: ({ children, ...props }: { children: React.ReactNode }) => <div {...props}>{children}</div>,
|
||||
XStack: ({ children, ...props }: { children: React.ReactNode }) => <div {...props}>{children}</div>,
|
||||
}));
|
||||
|
||||
vi.mock('@tamagui/text', () => ({
|
||||
SizableText: ({ children, ...props }: { children: React.ReactNode }) => <span {...props}>{children}</span>,
|
||||
}));
|
||||
|
||||
vi.mock('@tamagui/react-native-web-lite', () => ({
|
||||
Pressable: ({ children, onPress, ...props }: { children: React.ReactNode; onPress?: () => void }) => (
|
||||
<button type="button" onClick={onPress} {...props}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('../BottomNav', () => ({
|
||||
BottomNav: () => <div data-testid="bottom-nav" />,
|
||||
NavKey: {},
|
||||
}));
|
||||
|
||||
vi.mock('../../../context/EventContext', () => ({
|
||||
useEventContext: () => ({
|
||||
events: [],
|
||||
activeEvent: { slug: 'event-1', name: 'Test Event', event_date: '2024-01-01', status: 'active', settings: {} },
|
||||
hasMultipleEvents: false,
|
||||
hasEvents: true,
|
||||
selectEvent: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../../hooks/useMobileNav', () => ({
|
||||
useMobileNav: () => ({ go: vi.fn(), slug: 'event-1' }),
|
||||
}));
|
||||
|
||||
vi.mock('../../hooks/useNotificationsBadge', () => ({
|
||||
useNotificationsBadge: () => ({ count: 0 }),
|
||||
}));
|
||||
|
||||
vi.mock('../../hooks/useOnlineStatus', () => ({
|
||||
useOnlineStatus: () => true,
|
||||
}));
|
||||
|
||||
vi.mock('../../../api', () => ({
|
||||
getEvents: vi.fn().mockResolvedValue([]),
|
||||
}));
|
||||
|
||||
vi.mock('../../lib/tabHistory', () => ({
|
||||
setTabHistory: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../lib/photoModerationQueue', () => ({
|
||||
loadPhotoQueue: vi.fn(() => []),
|
||||
}));
|
||||
|
||||
vi.mock('../../lib/queueStatus', () => ({
|
||||
countQueuedPhotoActions: vi.fn(() => 0),
|
||||
}));
|
||||
|
||||
vi.mock('../../theme', () => ({
|
||||
useAdminTheme: () => ({
|
||||
background: '#FFF8F5',
|
||||
surface: '#ffffff',
|
||||
border: '#e5e7eb',
|
||||
text: '#1f2937',
|
||||
muted: '#6b7280',
|
||||
warningBg: '#fff7ed',
|
||||
warningText: '#92400e',
|
||||
primary: '#FF5A5F',
|
||||
danger: '#b91c1c',
|
||||
shadow: 'rgba(0,0,0,0.12)',
|
||||
}),
|
||||
}));
|
||||
|
||||
import { MobileShell } from '../MobileShell';
|
||||
|
||||
describe('MobileShell', () => {
|
||||
beforeEach(() => {
|
||||
window.matchMedia = vi.fn().mockReturnValue({
|
||||
matches: false,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
});
|
||||
});
|
||||
|
||||
it('renders quick QR as icon-only button', async () => {
|
||||
await act(async () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<MobileShell activeTab="home">
|
||||
<div>Body</div>
|
||||
</MobileShell>
|
||||
</MemoryRouter>
|
||||
);
|
||||
});
|
||||
|
||||
expect(screen.getByLabelText('Quick QR')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Quick QR')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides the event context on compact headers', async () => {
|
||||
window.matchMedia = vi.fn().mockReturnValue({
|
||||
matches: true,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<MobileShell activeTab="home">
|
||||
<div>Body</div>
|
||||
</MobileShell>
|
||||
</MemoryRouter>
|
||||
);
|
||||
});
|
||||
|
||||
expect(screen.queryByText('Test Event')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
59
resources/js/admin/mobile/hooks/usePackageCheckout.ts
Normal file
59
resources/js/admin/mobile/hooks/usePackageCheckout.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
import { createTenantPaddleCheckout } from '../../api';
|
||||
import { adminPath } from '../../constants';
|
||||
import { getApiErrorMessage } from '../../lib/apiError';
|
||||
import { storePendingCheckout } from '../lib/billingCheckout';
|
||||
|
||||
export function usePackageCheckout(): {
|
||||
busy: boolean;
|
||||
startCheckout: (packageId: number) => Promise<void>;
|
||||
} {
|
||||
const { t } = useTranslation('management');
|
||||
const [busy, setBusy] = React.useState(false);
|
||||
|
||||
const startCheckout = React.useCallback(
|
||||
async (packageId: number) => {
|
||||
if (busy) {
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
if (typeof window === 'undefined') {
|
||||
throw new Error('Checkout is only available in the browser.');
|
||||
}
|
||||
|
||||
const billingUrl = new URL(adminPath('/mobile/billing'), window.location.origin);
|
||||
const successUrl = new URL(billingUrl);
|
||||
successUrl.searchParams.set('checkout', 'success');
|
||||
successUrl.searchParams.set('package_id', String(packageId));
|
||||
const cancelUrl = new URL(billingUrl);
|
||||
cancelUrl.searchParams.set('checkout', 'cancel');
|
||||
cancelUrl.searchParams.set('package_id', String(packageId));
|
||||
|
||||
const { checkout_url, checkout_session_id } = await createTenantPaddleCheckout(packageId, {
|
||||
success_url: successUrl.toString(),
|
||||
return_url: cancelUrl.toString(),
|
||||
});
|
||||
|
||||
if (checkout_session_id) {
|
||||
storePendingCheckout({
|
||||
packageId,
|
||||
checkoutSessionId: checkout_session_id,
|
||||
startedAt: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
window.location.href = checkout_url;
|
||||
} catch (err) {
|
||||
toast.error(getApiErrorMessage(err, t('shop.errors.checkout', 'Checkout failed')));
|
||||
setBusy(false);
|
||||
}
|
||||
},
|
||||
[busy, t],
|
||||
);
|
||||
|
||||
return { busy, startCheckout };
|
||||
}
|
||||
28
resources/js/admin/mobile/lib/analytics.ts
Normal file
28
resources/js/admin/mobile/lib/analytics.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
export function resolveMaxCount(values: number[]): number {
|
||||
if (!Array.isArray(values) || values.length === 0) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return Math.max(...values, 1);
|
||||
}
|
||||
|
||||
export function resolveTimelineHours(timestamps: string[], fallbackHours = 12): number {
|
||||
if (!Array.isArray(timestamps) || timestamps.length < 2) {
|
||||
return fallbackHours;
|
||||
}
|
||||
|
||||
const times = timestamps
|
||||
.map((value) => new Date(value).getTime())
|
||||
.filter((value) => Number.isFinite(value));
|
||||
|
||||
if (times.length < 2) {
|
||||
return fallbackHours;
|
||||
}
|
||||
|
||||
const min = Math.min(...times);
|
||||
const max = Math.max(...times);
|
||||
const diff = Math.max(0, max - min);
|
||||
const hours = diff / (1000 * 60 * 60);
|
||||
|
||||
return Math.max(1, Math.round(hours));
|
||||
}
|
||||
82
resources/js/admin/mobile/lib/billingCheckout.ts
Normal file
82
resources/js/admin/mobile/lib/billingCheckout.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
export type PendingCheckout = {
|
||||
packageId: number | null;
|
||||
checkoutSessionId?: string | null;
|
||||
startedAt: number;
|
||||
};
|
||||
|
||||
export const PENDING_CHECKOUT_TTL_MS = 1000 * 60 * 30;
|
||||
export const CHECKOUT_STORAGE_KEY = 'admin.billing.checkout.pending.v1';
|
||||
|
||||
export function isCheckoutExpired(
|
||||
pending: PendingCheckout,
|
||||
now = Date.now(),
|
||||
ttl = PENDING_CHECKOUT_TTL_MS,
|
||||
): boolean {
|
||||
return now - pending.startedAt > ttl;
|
||||
}
|
||||
|
||||
export function loadPendingCheckout(
|
||||
now = Date.now(),
|
||||
ttl = PENDING_CHECKOUT_TTL_MS,
|
||||
): PendingCheckout | null {
|
||||
if (typeof window === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const raw = window.sessionStorage.getItem(CHECKOUT_STORAGE_KEY);
|
||||
if (! raw) {
|
||||
return null;
|
||||
}
|
||||
const parsed = JSON.parse(raw) as PendingCheckout;
|
||||
if (typeof parsed?.startedAt !== 'number') {
|
||||
return null;
|
||||
}
|
||||
const packageId =
|
||||
typeof parsed.packageId === 'number' && Number.isFinite(parsed.packageId)
|
||||
? parsed.packageId
|
||||
: null;
|
||||
const checkoutSessionId = typeof parsed.checkoutSessionId === 'string' ? parsed.checkoutSessionId : null;
|
||||
if (now - parsed.startedAt > ttl) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
packageId,
|
||||
checkoutSessionId,
|
||||
startedAt: parsed.startedAt,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function storePendingCheckout(next: PendingCheckout | null): void {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (! next) {
|
||||
window.sessionStorage.removeItem(CHECKOUT_STORAGE_KEY);
|
||||
} else {
|
||||
window.sessionStorage.setItem(CHECKOUT_STORAGE_KEY, JSON.stringify(next));
|
||||
}
|
||||
} catch {
|
||||
// Ignore storage errors.
|
||||
}
|
||||
}
|
||||
|
||||
export function shouldClearPendingCheckout(
|
||||
pending: PendingCheckout,
|
||||
activePackageId: number | null,
|
||||
now = Date.now(),
|
||||
ttl = PENDING_CHECKOUT_TTL_MS,
|
||||
): boolean {
|
||||
if (isCheckoutExpired(pending, now, ttl)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (pending.packageId && activePackageId && pending.packageId === activePackageId) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
146
resources/js/admin/mobile/lib/packageShop.ts
Normal file
146
resources/js/admin/mobile/lib/packageShop.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import type { Package } from '../../api';
|
||||
|
||||
type PackageChange = {
|
||||
isUpgrade: boolean;
|
||||
isDowngrade: boolean;
|
||||
};
|
||||
|
||||
export type PackageComparisonRow =
|
||||
| {
|
||||
id: string;
|
||||
type: 'limit';
|
||||
limitKey: 'max_photos' | 'max_guests' | 'gallery_days';
|
||||
}
|
||||
| {
|
||||
id: string;
|
||||
type: 'feature';
|
||||
featureKey: string;
|
||||
};
|
||||
|
||||
function normalizePackageFeatures(pkg: Package | null): string[] {
|
||||
if (!pkg?.features) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (Array.isArray(pkg.features)) {
|
||||
return pkg.features.filter((feature): feature is string => typeof feature === 'string' && feature.trim().length > 0);
|
||||
}
|
||||
|
||||
if (typeof pkg.features === 'object') {
|
||||
return Object.entries(pkg.features)
|
||||
.filter(([, enabled]) => enabled)
|
||||
.map(([key]) => key);
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
export function getEnabledPackageFeatures(pkg: Package): string[] {
|
||||
return normalizePackageFeatures(pkg);
|
||||
}
|
||||
|
||||
function collectFeatures(pkg: Package | null): Set<string> {
|
||||
return new Set(normalizePackageFeatures(pkg));
|
||||
}
|
||||
|
||||
function compareLimit(candidate: number | null, active: number | null): number {
|
||||
if (active === null) {
|
||||
return candidate === null ? 0 : -1;
|
||||
}
|
||||
|
||||
if (candidate === null) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (candidate > active) return 1;
|
||||
if (candidate < active) return -1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function classifyPackageChange(pkg: Package, active: Package | null): PackageChange {
|
||||
if (!active) {
|
||||
return { isUpgrade: false, isDowngrade: false };
|
||||
}
|
||||
|
||||
const activeFeatures = collectFeatures(active);
|
||||
const candidateFeatures = collectFeatures(pkg);
|
||||
|
||||
const hasFeatureUpgrade = Array.from(candidateFeatures).some((feature) => !activeFeatures.has(feature));
|
||||
const hasFeatureDowngrade = Array.from(activeFeatures).some((feature) => !candidateFeatures.has(feature));
|
||||
|
||||
const limitKeys: Array<keyof Package> = ['max_photos', 'max_guests', 'gallery_days'];
|
||||
let hasLimitUpgrade = false;
|
||||
let hasLimitDowngrade = false;
|
||||
|
||||
limitKeys.forEach((key) => {
|
||||
const candidateLimit = pkg[key] ?? null;
|
||||
const activeLimit = active[key] ?? null;
|
||||
const delta = compareLimit(candidateLimit, activeLimit);
|
||||
if (delta > 0) {
|
||||
hasLimitUpgrade = true;
|
||||
} else if (delta < 0) {
|
||||
hasLimitDowngrade = true;
|
||||
}
|
||||
});
|
||||
|
||||
const hasUpgrade = hasFeatureUpgrade || hasLimitUpgrade;
|
||||
const hasDowngrade = hasFeatureDowngrade || hasLimitDowngrade;
|
||||
|
||||
if (hasUpgrade && !hasDowngrade) {
|
||||
return { isUpgrade: true, isDowngrade: false };
|
||||
}
|
||||
|
||||
if (hasDowngrade) {
|
||||
return { isUpgrade: false, isDowngrade: true };
|
||||
}
|
||||
|
||||
return { isUpgrade: false, isDowngrade: false };
|
||||
}
|
||||
|
||||
export function selectRecommendedPackageId(
|
||||
packages: Package[],
|
||||
feature: string | null,
|
||||
activePackage: Package | null
|
||||
): number | null {
|
||||
if (!feature) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const candidates = packages.filter((pkg) => normalizePackageFeatures(pkg).includes(feature));
|
||||
if (candidates.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const upgrades = candidates.filter((pkg) => classifyPackageChange(pkg, activePackage).isUpgrade);
|
||||
const pool = upgrades.length ? upgrades : candidates;
|
||||
const sorted = [...pool].sort((a, b) => a.price - b.price);
|
||||
|
||||
return sorted[0]?.id ?? null;
|
||||
}
|
||||
|
||||
export function buildPackageComparisonRows(packages: Package[]): PackageComparisonRow[] {
|
||||
const limitRows: PackageComparisonRow[] = [
|
||||
{ id: 'limit.max_photos', type: 'limit', limitKey: 'max_photos' },
|
||||
{ id: 'limit.max_guests', type: 'limit', limitKey: 'max_guests' },
|
||||
{ id: 'limit.gallery_days', type: 'limit', limitKey: 'gallery_days' },
|
||||
];
|
||||
|
||||
const featureKeys = new Set<string>();
|
||||
packages.forEach((pkg) => {
|
||||
normalizePackageFeatures(pkg).forEach((key) => {
|
||||
if (key !== 'photos') {
|
||||
featureKeys.add(key);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const featureRows = Array.from(featureKeys)
|
||||
.sort((a, b) => a.localeCompare(b))
|
||||
.map((featureKey) => ({
|
||||
id: `feature.${featureKey}`,
|
||||
type: 'feature' as const,
|
||||
featureKey,
|
||||
}));
|
||||
|
||||
return [...limitRows, ...featureRows];
|
||||
}
|
||||
@@ -15,7 +15,8 @@ const t = (key: string, options?: Record<string, unknown> | string) => {
|
||||
return template
|
||||
.replace('{{used}}', String(options?.used ?? '{{used}}'))
|
||||
.replace('{{limit}}', String(options?.limit ?? '{{limit}}'))
|
||||
.replace('{{remaining}}', String(options?.remaining ?? '{{remaining}}'));
|
||||
.replace('{{remaining}}', String(options?.remaining ?? '{{remaining}}'))
|
||||
.replace('{{count}}', String(options?.count ?? '{{count}}'));
|
||||
};
|
||||
|
||||
describe('packageSummary helpers', () => {
|
||||
@@ -53,6 +54,12 @@ describe('packageSummary helpers', () => {
|
||||
expect(result[0].value).toBe('30 of 120 remaining');
|
||||
});
|
||||
|
||||
it('falls back to remaining count when remaining exceeds limit', () => {
|
||||
const result = getPackageLimitEntries({ max_photos: 120, remaining_photos: 180 }, t);
|
||||
|
||||
expect(result[0].value).toBe('Remaining 180');
|
||||
});
|
||||
|
||||
it('formats event usage copy', () => {
|
||||
const result = formatEventUsage(3, 10, t);
|
||||
|
||||
|
||||
@@ -138,6 +138,12 @@ const formatLimitWithRemaining = (limit: number | null, remaining: number | null
|
||||
|
||||
if (remaining !== null && remaining >= 0) {
|
||||
const normalizedRemaining = Number.isFinite(remaining) ? Math.max(0, Math.round(remaining)) : remaining;
|
||||
if (normalizedRemaining > limit) {
|
||||
return t('mobileBilling.usage.remaining', {
|
||||
count: normalizedRemaining,
|
||||
defaultValue: 'Remaining {{count}}',
|
||||
});
|
||||
}
|
||||
return t('mobileBilling.usage.remainingOf', {
|
||||
remaining: normalizedRemaining,
|
||||
limit,
|
||||
|
||||
@@ -27,7 +27,6 @@ import { SettingsSheet } from './settings-sheet';
|
||||
import { useTranslation, type TranslateFn } from '../i18n/useTranslation';
|
||||
import { DEFAULT_EVENT_BRANDING, useOptionalEventBranding } from '../context/EventBrandingContext';
|
||||
import { useOptionalNotificationCenter, type NotificationCenterValue } from '../context/NotificationCenterContext';
|
||||
import { useGuestTaskProgress, TASK_BADGE_TARGET } from '../hooks/useGuestTaskProgress';
|
||||
import { usePushSubscription } from '../hooks/usePushSubscription';
|
||||
import { getContrastingTextColor, relativeLuminance } from '../lib/color';
|
||||
import { isTaskModeEnabled } from '../lib/engagement';
|
||||
@@ -151,7 +150,6 @@ export default function Header({ eventToken, title = '' }: { eventToken?: string
|
||||
const { event, status } = useEventData();
|
||||
const notificationCenter = useOptionalNotificationCenter();
|
||||
const [notificationsOpen, setNotificationsOpen] = React.useState(false);
|
||||
const taskProgress = useGuestTaskProgress(eventToken);
|
||||
const tasksEnabled = isTaskModeEnabled(event);
|
||||
const panelRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const notificationButtonRef = React.useRef<HTMLButtonElement | null>(null);
|
||||
@@ -258,7 +256,6 @@ export default function Header({ eventToken, title = '' }: { eventToken?: string
|
||||
onToggle={() => setNotificationsOpen((prev) => !prev)}
|
||||
panelRef={panelRef}
|
||||
buttonRef={notificationButtonRef}
|
||||
taskProgress={tasksEnabled && taskProgress?.hydrated ? taskProgress : undefined}
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
@@ -285,18 +282,14 @@ type NotificationButtonProps = {
|
||||
onToggle: () => void;
|
||||
panelRef: React.RefObject<HTMLDivElement | null>;
|
||||
buttonRef: React.RefObject<HTMLButtonElement | null>;
|
||||
taskProgress?: ReturnType<typeof useGuestTaskProgress>;
|
||||
t: TranslateFn;
|
||||
};
|
||||
|
||||
type PushState = ReturnType<typeof usePushSubscription>;
|
||||
|
||||
function NotificationButton({ center, eventToken, open, onToggle, panelRef, buttonRef, taskProgress, t }: NotificationButtonProps) {
|
||||
const badgeCount = center.unreadCount + center.pendingCount + center.queueCount;
|
||||
const progressRatio = taskProgress
|
||||
? Math.min(1, taskProgress.completedCount / TASK_BADGE_TARGET)
|
||||
: 0;
|
||||
const [activeTab, setActiveTab] = React.useState<'unread' | 'all' | 'status'>(center.unreadCount > 0 ? 'unread' : 'all');
|
||||
function NotificationButton({ center, eventToken, open, onToggle, panelRef, buttonRef, t }: NotificationButtonProps) {
|
||||
const badgeCount = center.unreadCount;
|
||||
const [activeTab, setActiveTab] = React.useState<'unread' | 'all' | 'uploads'>(center.unreadCount > 0 ? 'unread' : 'all');
|
||||
const [scopeFilter, setScopeFilter] = React.useState<'all' | 'tips' | 'general'>('all');
|
||||
const pushState = usePushSubscription(eventToken);
|
||||
|
||||
@@ -321,7 +314,7 @@ function NotificationButton({ center, eventToken, open, onToggle, panelRef, butt
|
||||
case 'unread':
|
||||
base = unreadNotifications;
|
||||
break;
|
||||
case 'status':
|
||||
case 'uploads':
|
||||
base = uploadNotifications;
|
||||
break;
|
||||
default:
|
||||
@@ -331,7 +324,7 @@ function NotificationButton({ center, eventToken, open, onToggle, panelRef, butt
|
||||
}, [activeTab, center.notifications, unreadNotifications, uploadNotifications]);
|
||||
|
||||
const scopedNotifications = React.useMemo(() => {
|
||||
if (scopeFilter === 'all') {
|
||||
if (activeTab === 'uploads' || scopeFilter === 'all') {
|
||||
return filteredNotifications;
|
||||
}
|
||||
return filteredNotifications.filter((item) => {
|
||||
@@ -365,10 +358,10 @@ function NotificationButton({ center, eventToken, open, onToggle, panelRef, butt
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-slate-900">{t('header.notifications.title', 'Benachrichtigungen')}</p>
|
||||
<p className="text-sm font-semibold text-slate-900">{t('header.notifications.title', 'Updates')}</p>
|
||||
<p className="text-xs text-slate-500">
|
||||
{center.unreadCount > 0
|
||||
? t('header.notifications.unread', { defaultValue: '{{count}} neu', count: center.unreadCount })
|
||||
? t('header.notifications.unread', { defaultValue: '{count} neu', count: center.unreadCount })
|
||||
: t('header.notifications.allRead', 'Alles gelesen')}
|
||||
</p>
|
||||
</div>
|
||||
@@ -384,13 +377,14 @@ function NotificationButton({ center, eventToken, open, onToggle, panelRef, butt
|
||||
</div>
|
||||
<NotificationTabs
|
||||
tabs={[
|
||||
{ key: 'unread', label: t('header.notifications.tabUnread', 'Neu'), badge: unreadNotifications.length },
|
||||
{ key: 'status', label: t('header.notifications.tabStatus', 'Uploads/Status'), badge: uploadNotifications.length },
|
||||
{ key: 'all', label: t('header.notifications.tabAll', 'Alle'), badge: center.notifications.length },
|
||||
{ key: 'unread', label: t('header.notifications.tabUnread', 'Nachrichten'), badge: unreadNotifications.length },
|
||||
{ key: 'uploads', label: t('header.notifications.tabUploads', 'Uploads'), badge: uploadNotifications.length },
|
||||
{ key: 'all', label: t('header.notifications.tabAll', 'Alle Updates'), badge: center.notifications.length },
|
||||
]}
|
||||
activeTab={activeTab}
|
||||
onTabChange={(next) => setActiveTab(next as typeof activeTab)}
|
||||
/>
|
||||
{activeTab !== 'uploads' && (
|
||||
<div className="mt-3">
|
||||
<div className="flex gap-2 overflow-x-auto text-xs whitespace-nowrap pb-1">
|
||||
{(
|
||||
@@ -418,33 +412,8 @@ function NotificationButton({ center, eventToken, open, onToggle, panelRef, butt
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 max-h-80 space-y-2 overflow-y-auto pr-1">
|
||||
{center.loading ? (
|
||||
<NotificationSkeleton />
|
||||
) : scopedNotifications.length === 0 ? (
|
||||
<NotificationEmptyState
|
||||
t={t}
|
||||
message={
|
||||
activeTab === 'unread'
|
||||
? t('header.notifications.emptyUnread', 'Du bist auf dem neuesten Stand!')
|
||||
: activeTab === 'status'
|
||||
? t('header.notifications.emptyStatus', 'Keine Upload-Hinweise oder Wartungen aktiv.')
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
scopedNotifications.map((item) => (
|
||||
<NotificationListItem
|
||||
key={item.id}
|
||||
item={item}
|
||||
onMarkRead={() => center.markAsRead(item.id)}
|
||||
onDismiss={() => center.dismiss(item.id)}
|
||||
t={t}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
{activeTab === 'status' && (
|
||||
{activeTab === 'uploads' && (center.pendingCount > 0 || center.queueCount > 0) && (
|
||||
<div className="mt-3 space-y-2">
|
||||
{center.pendingCount > 0 && (
|
||||
<div className="flex items-center justify-between rounded-xl bg-amber-50/90 px-3 py-2 text-xs text-amber-900">
|
||||
@@ -478,30 +447,32 @@ function NotificationButton({ center, eventToken, open, onToggle, panelRef, butt
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{taskProgress && (
|
||||
<div className="mt-3 rounded-2xl border border-slate-200 bg-slate-50/90 p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.3em] text-slate-400">{t('header.notifications.badgeLabel', 'Badge-Fortschritt')}</p>
|
||||
<p className="text-lg font-semibold text-slate-900">
|
||||
{taskProgress.completedCount}/{TASK_BADGE_TARGET}
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
to={`/e/${encodeURIComponent(eventToken)}/tasks`}
|
||||
className="rounded-full border border-slate-200 px-3 py-1 text-xs font-semibold text-pink-600 transition hover:border-pink-300"
|
||||
>
|
||||
{t('header.notifications.tasksCta', 'Weiter')}
|
||||
</Link>
|
||||
</div>
|
||||
<div className="mt-3 h-1.5 w-full rounded-full bg-slate-100">
|
||||
<div
|
||||
className="h-full rounded-full bg-pink-500"
|
||||
style={{ width: `${progressRatio * 100}%` }}
|
||||
<div className="mt-3 max-h-80 space-y-2 overflow-y-auto pr-1">
|
||||
{center.loading ? (
|
||||
<NotificationSkeleton />
|
||||
) : scopedNotifications.length === 0 ? (
|
||||
<NotificationEmptyState
|
||||
t={t}
|
||||
message={
|
||||
activeTab === 'unread'
|
||||
? t('header.notifications.emptyUnread', 'Du bist auf dem neuesten Stand!')
|
||||
: activeTab === 'uploads'
|
||||
? t('header.notifications.emptyStatus', 'Keine Upload-Hinweise oder Wartungen aktiv.')
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
scopedNotifications.map((item) => (
|
||||
<NotificationListItem
|
||||
key={item.id}
|
||||
item={item}
|
||||
onMarkRead={() => center.markAsRead(item.id)}
|
||||
onDismiss={() => center.dismiss(item.id)}
|
||||
t={t}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<NotificationStatusBar
|
||||
lastFetchedAt={center.lastFetchedAt}
|
||||
isOffline={center.isOffline}
|
||||
|
||||
@@ -38,7 +38,6 @@ vi.mock('../../context/NotificationCenterContext', () => ({
|
||||
queueItems: [],
|
||||
queueCount: 0,
|
||||
pendingCount: 0,
|
||||
totalCount: 0,
|
||||
loading: false,
|
||||
pendingLoading: false,
|
||||
refresh: vi.fn(),
|
||||
@@ -97,10 +96,10 @@ describe('Header notifications toggle', () => {
|
||||
const bellButton = screen.getByLabelText('Benachrichtigungen anzeigen');
|
||||
fireEvent.click(bellButton);
|
||||
|
||||
expect(screen.getByText('Benachrichtigungen')).toBeInTheDocument();
|
||||
expect(screen.getByText('Updates')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(bellButton);
|
||||
|
||||
expect(screen.queryByText('Benachrichtigungen')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Updates')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,7 +16,6 @@ export type NotificationCenterValue = {
|
||||
queueItems: QueueItem[];
|
||||
queueCount: number;
|
||||
pendingCount: number;
|
||||
totalCount: number;
|
||||
loading: boolean;
|
||||
pendingLoading: boolean;
|
||||
refresh: () => Promise<void>;
|
||||
@@ -264,11 +263,9 @@ export function NotificationCenterProvider({ eventToken, children }: { eventToke
|
||||
}, [loadNotifications, refreshQueue, loadPendingUploads]);
|
||||
|
||||
const loading = loadingNotifications || queueLoading || pendingLoading;
|
||||
const totalCount = unreadCount + queueCount + pendingCount;
|
||||
|
||||
React.useEffect(() => {
|
||||
void updateAppBadge(totalCount);
|
||||
}, [totalCount]);
|
||||
void updateAppBadge(unreadCount);
|
||||
}, [unreadCount]);
|
||||
|
||||
const value: NotificationCenterValue = {
|
||||
notifications,
|
||||
@@ -276,7 +273,6 @@ export function NotificationCenterProvider({ eventToken, children }: { eventToke
|
||||
queueItems: items,
|
||||
queueCount,
|
||||
pendingCount,
|
||||
totalCount,
|
||||
loading,
|
||||
pendingLoading,
|
||||
refresh,
|
||||
|
||||
@@ -42,7 +42,13 @@ export const messages: Record<LocaleCode, NestedMessages> = {
|
||||
},
|
||||
helpGallery: 'Hilfe zu Galerie & Teilen',
|
||||
notifications: {
|
||||
tabStatus: 'Upload-Status',
|
||||
title: 'Updates',
|
||||
unread: '{count} neu',
|
||||
allRead: 'Alles gelesen',
|
||||
tabUnread: 'Nachrichten',
|
||||
tabUploads: 'Uploads',
|
||||
tabAll: 'Alle Updates',
|
||||
emptyStatus: 'Keine Upload-Hinweise oder Wartungen aktiv.',
|
||||
},
|
||||
},
|
||||
liveShowPlayer: {
|
||||
@@ -774,7 +780,13 @@ export const messages: Record<LocaleCode, NestedMessages> = {
|
||||
},
|
||||
helpGallery: 'Help: Gallery & sharing',
|
||||
notifications: {
|
||||
tabStatus: 'Upload status',
|
||||
title: 'Updates',
|
||||
unread: '{count} new',
|
||||
allRead: 'All read',
|
||||
tabUnread: 'Messages',
|
||||
tabUploads: 'Uploads',
|
||||
tabAll: 'All updates',
|
||||
emptyStatus: 'No upload status or maintenance active.',
|
||||
},
|
||||
},
|
||||
liveShowPlayer: {
|
||||
|
||||
@@ -6,6 +6,7 @@ use App\Http\Controllers\Api\LegalController;
|
||||
use App\Http\Controllers\Api\LiveShowController;
|
||||
use App\Http\Controllers\Api\Marketing\CouponPreviewController;
|
||||
use App\Http\Controllers\Api\PackageController;
|
||||
use App\Http\Controllers\Api\PhotoboothConnectController;
|
||||
use App\Http\Controllers\Api\SparkboothUploadController;
|
||||
use App\Http\Controllers\Api\Tenant\AdminPushSubscriptionController;
|
||||
use App\Http\Controllers\Api\Tenant\DashboardController;
|
||||
@@ -24,6 +25,7 @@ use App\Http\Controllers\Api\Tenant\LiveShowLinkController;
|
||||
use App\Http\Controllers\Api\Tenant\LiveShowPhotoController;
|
||||
use App\Http\Controllers\Api\Tenant\NotificationLogController;
|
||||
use App\Http\Controllers\Api\Tenant\OnboardingController;
|
||||
use App\Http\Controllers\Api\Tenant\PhotoboothConnectCodeController;
|
||||
use App\Http\Controllers\Api\Tenant\PhotoboothController;
|
||||
use App\Http\Controllers\Api\Tenant\PhotoController;
|
||||
use App\Http\Controllers\Api\Tenant\ProfileController;
|
||||
@@ -153,6 +155,9 @@ Route::prefix('v1')->name('api.v1.')->group(function () {
|
||||
|
||||
Route::post('/photobooth/sparkbooth/upload', [SparkboothUploadController::class, 'store'])
|
||||
->name('photobooth.sparkbooth.upload');
|
||||
Route::post('/photobooth/connect', [PhotoboothConnectController::class, 'store'])
|
||||
->middleware('throttle:photobooth-connect')
|
||||
->name('photobooth.connect');
|
||||
|
||||
Route::get('/tenant/events/{event:slug}/photos/{photo}/{variant}/asset', [PhotoController::class, 'asset'])
|
||||
->whereNumber('photo')
|
||||
@@ -263,6 +268,8 @@ Route::prefix('v1')->name('api.v1.')->group(function () {
|
||||
Route::post('/enable', [PhotoboothController::class, 'enable'])->name('tenant.events.photobooth.enable');
|
||||
Route::post('/rotate', [PhotoboothController::class, 'rotate'])->name('tenant.events.photobooth.rotate');
|
||||
Route::post('/disable', [PhotoboothController::class, 'disable'])->name('tenant.events.photobooth.disable');
|
||||
Route::post('/connect-codes', [PhotoboothConnectCodeController::class, 'store'])
|
||||
->name('tenant.events.photobooth.connect-codes.store');
|
||||
});
|
||||
|
||||
Route::get('members', [EventMemberController::class, 'index'])
|
||||
@@ -353,6 +360,8 @@ Route::prefix('v1')->name('api.v1.')->group(function () {
|
||||
Route::post('/complete', [PackageController::class, 'completePurchase'])->name('packages.complete');
|
||||
Route::post('/free', [PackageController::class, 'assignFree'])->name('packages.free');
|
||||
Route::post('/paddle-checkout', [PackageController::class, 'createPaddleCheckout'])->name('packages.paddle-checkout');
|
||||
Route::get('/checkout-session/{session}/status', [PackageController::class, 'checkoutSessionStatus'])
|
||||
->name('packages.checkout-session.status');
|
||||
});
|
||||
|
||||
Route::get('addons/catalog', [EventAddonCatalogController::class, 'index'])
|
||||
|
||||
100
tests/Feature/Photobooth/PhotoboothConnectCodeTest.php
Normal file
100
tests/Feature/Photobooth/PhotoboothConnectCodeTest.php
Normal file
@@ -0,0 +1,100 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Photobooth;
|
||||
|
||||
use App\Models\Event;
|
||||
use App\Models\EventPhotoboothSetting;
|
||||
use App\Models\PhotoboothConnectCode;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\Feature\Tenant\TenantTestCase;
|
||||
|
||||
class PhotoboothConnectCodeTest extends TenantTestCase
|
||||
{
|
||||
#[Test]
|
||||
public function it_creates_a_connect_code_for_sparkbooth(): void
|
||||
{
|
||||
$event = Event::factory()->for($this->tenant)->create([
|
||||
'slug' => 'connect-code-event',
|
||||
]);
|
||||
|
||||
EventPhotoboothSetting::factory()
|
||||
->for($event)
|
||||
->activeSparkbooth()
|
||||
->create([
|
||||
'username' => 'pbconnect',
|
||||
'password' => 'SECRET12',
|
||||
]);
|
||||
|
||||
$response = $this->authenticatedRequest('POST', "/api/v1/tenant/events/{$event->slug}/photobooth/connect-codes");
|
||||
|
||||
$response->assertOk()
|
||||
->assertJsonPath('data.code', fn ($value) => is_string($value) && strlen($value) === 6)
|
||||
->assertJsonPath('data.expires_at', fn ($value) => is_string($value) && $value !== '');
|
||||
|
||||
$this->assertDatabaseCount('photobooth_connect_codes', 1);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_redeems_a_connect_code_and_returns_upload_credentials(): void
|
||||
{
|
||||
$event = Event::factory()->for($this->tenant)->create([
|
||||
'slug' => 'connect-code-redeem',
|
||||
]);
|
||||
|
||||
EventPhotoboothSetting::factory()
|
||||
->for($event)
|
||||
->activeSparkbooth()
|
||||
->create([
|
||||
'username' => 'pbconnect',
|
||||
'password' => 'SECRET12',
|
||||
]);
|
||||
|
||||
$codeResponse = $this->authenticatedRequest('POST', "/api/v1/tenant/events/{$event->slug}/photobooth/connect-codes");
|
||||
$codeResponse->assertOk();
|
||||
|
||||
$code = (string) $codeResponse->json('data.code');
|
||||
|
||||
$redeem = $this->postJson('/api/v1/photobooth/connect', [
|
||||
'code' => $code,
|
||||
]);
|
||||
|
||||
$redeem->assertOk()
|
||||
->assertJsonPath('data.upload_url', fn ($value) => is_string($value) && $value !== '')
|
||||
->assertJsonPath('data.username', 'pbconnect')
|
||||
->assertJsonPath('data.password', 'SECRET12');
|
||||
|
||||
$this->assertDatabaseHas('photobooth_connect_codes', [
|
||||
'event_id' => $event->id,
|
||||
]);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_rejects_expired_connect_codes(): void
|
||||
{
|
||||
$event = Event::factory()->for($this->tenant)->create([
|
||||
'slug' => 'connect-code-expired',
|
||||
]);
|
||||
|
||||
EventPhotoboothSetting::factory()
|
||||
->for($event)
|
||||
->activeSparkbooth()
|
||||
->create([
|
||||
'username' => 'pbconnect',
|
||||
'password' => 'SECRET12',
|
||||
]);
|
||||
|
||||
$code = '123456';
|
||||
|
||||
PhotoboothConnectCode::query()->create([
|
||||
'event_id' => $event->id,
|
||||
'code_hash' => hash('sha256', $code),
|
||||
'expires_at' => now()->subMinute(),
|
||||
]);
|
||||
|
||||
$response = $this->postJson('/api/v1/photobooth/connect', [
|
||||
'code' => $code,
|
||||
]);
|
||||
|
||||
$response->assertStatus(422);
|
||||
}
|
||||
}
|
||||
26
tests/Feature/Tenant/PhotoModerationControllerTest.php
Normal file
26
tests/Feature/Tenant/PhotoModerationControllerTest.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Tenant;
|
||||
|
||||
use App\Models\Event;
|
||||
use App\Models\Photo;
|
||||
|
||||
class PhotoModerationControllerTest extends TenantTestCase
|
||||
{
|
||||
public function test_tenant_admin_can_approve_photo(): void
|
||||
{
|
||||
$event = Event::factory()->for($this->tenant)->create([
|
||||
'slug' => 'moderation-event',
|
||||
]);
|
||||
$photo = Photo::factory()->for($event)->create([
|
||||
'status' => 'pending',
|
||||
]);
|
||||
|
||||
$response = $this->authenticatedRequest('PATCH', "/api/v1/tenant/events/{$event->slug}/photos/{$photo->id}", [
|
||||
'status' => 'approved',
|
||||
]);
|
||||
|
||||
$response->assertOk();
|
||||
$this->assertSame('approved', $photo->refresh()->status);
|
||||
}
|
||||
}
|
||||
46
tests/Feature/Tenant/TenantCheckoutSessionStatusTest.php
Normal file
46
tests/Feature/Tenant/TenantCheckoutSessionStatusTest.php
Normal file
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Tenant;
|
||||
|
||||
use App\Models\CheckoutSession;
|
||||
use App\Models\Package;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class TenantCheckoutSessionStatusTest extends TenantTestCase
|
||||
{
|
||||
public function test_tenant_can_fetch_checkout_session_status(): void
|
||||
{
|
||||
$package = Package::factory()->create([
|
||||
'price' => 129,
|
||||
]);
|
||||
|
||||
$session = CheckoutSession::create([
|
||||
'id' => (string) Str::uuid(),
|
||||
'user_id' => $this->tenantUser->id,
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'package_id' => $package->id,
|
||||
'status' => CheckoutSession::STATUS_FAILED,
|
||||
'provider' => CheckoutSession::PROVIDER_PADDLE,
|
||||
'provider_metadata' => [
|
||||
'paddle_checkout_url' => 'https://checkout.paddle.test/checkout/123',
|
||||
],
|
||||
'status_history' => [
|
||||
[
|
||||
'status' => CheckoutSession::STATUS_FAILED,
|
||||
'reason' => 'paddle_failed',
|
||||
'at' => now()->toIso8601String(),
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$response = $this->authenticatedRequest(
|
||||
'GET',
|
||||
"/api/v1/tenant/packages/checkout-session/{$session->id}/status"
|
||||
);
|
||||
|
||||
$response->assertOk()
|
||||
->assertJsonPath('status', CheckoutSession::STATUS_FAILED)
|
||||
->assertJsonPath('reason', 'paddle_failed')
|
||||
->assertJsonPath('checkout_url', 'https://checkout.paddle.test/checkout/123');
|
||||
}
|
||||
}
|
||||
@@ -29,7 +29,10 @@ class TenantPaddleCheckoutTest extends TenantTestCase
|
||||
return $tenant->is($this->tenant)
|
||||
&& $payloadPackage->is($package)
|
||||
&& array_key_exists('success_url', $payload)
|
||||
&& array_key_exists('return_url', $payload);
|
||||
&& array_key_exists('return_url', $payload)
|
||||
&& array_key_exists('metadata', $payload)
|
||||
&& is_array($payload['metadata'])
|
||||
&& ! empty($payload['metadata']['checkout_session_id']);
|
||||
})
|
||||
->andReturn([
|
||||
'checkout_url' => 'https://checkout.paddle.test/checkout/123',
|
||||
@@ -42,7 +45,8 @@ class TenantPaddleCheckoutTest extends TenantTestCase
|
||||
]);
|
||||
|
||||
$response->assertOk()
|
||||
->assertJsonPath('checkout_url', 'https://checkout.paddle.test/checkout/123');
|
||||
->assertJsonPath('checkout_url', 'https://checkout.paddle.test/checkout/123')
|
||||
->assertJsonStructure(['checkout_session_id']);
|
||||
}
|
||||
|
||||
public function test_paddle_checkout_requires_paddle_price_id(): void
|
||||
|
||||
67
tests/Unit/RateLimitConfigTest.php
Normal file
67
tests/Unit/RateLimitConfigTest.php
Normal file
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use App\Models\GuestPolicySetting;
|
||||
use Illuminate\Cache\RateLimiting\Limit;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Tests\TestCase;
|
||||
|
||||
class RateLimitConfigTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_tenant_api_rate_limiter_allows_higher_throughput(): void
|
||||
{
|
||||
$request = Request::create('/api/v1/tenant/events', 'GET', [], [], [], [
|
||||
'REMOTE_ADDR' => '10.0.0.1',
|
||||
]);
|
||||
$request->attributes->set('tenant_id', 42);
|
||||
|
||||
$limiter = RateLimiter::limiter('tenant-api');
|
||||
|
||||
$this->assertNotNull($limiter);
|
||||
|
||||
$limit = $limiter($request);
|
||||
|
||||
$this->assertInstanceOf(Limit::class, $limit);
|
||||
$this->assertSame(600, $limit->maxAttempts);
|
||||
}
|
||||
|
||||
public function test_guest_api_rate_limiter_allows_higher_throughput(): void
|
||||
{
|
||||
$request = Request::create('/api/v1/events/sample', 'GET', [], [], [], [
|
||||
'REMOTE_ADDR' => '10.0.0.2',
|
||||
]);
|
||||
|
||||
$limiter = RateLimiter::limiter('guest-api');
|
||||
|
||||
$this->assertNotNull($limiter);
|
||||
|
||||
$limit = $limiter($request);
|
||||
|
||||
$this->assertInstanceOf(Limit::class, $limit);
|
||||
$this->assertSame(300, $limit->maxAttempts);
|
||||
}
|
||||
|
||||
public function test_guest_policy_defaults_follow_join_token_limits(): void
|
||||
{
|
||||
$accessLimit = 300;
|
||||
$downloadLimit = 120;
|
||||
|
||||
config([
|
||||
'join_tokens.access_limit' => $accessLimit,
|
||||
'join_tokens.download_limit' => $downloadLimit,
|
||||
]);
|
||||
|
||||
GuestPolicySetting::query()->delete();
|
||||
GuestPolicySetting::flushCache();
|
||||
|
||||
$settings = GuestPolicySetting::current();
|
||||
|
||||
$this->assertSame($accessLimit, $settings->join_token_access_limit);
|
||||
$this->assertSame($downloadLimit, $settings->join_token_download_limit);
|
||||
}
|
||||
}
|
||||
144
tests/Unit/SendPhotoUploadedNotificationTest.php
Normal file
144
tests/Unit/SendPhotoUploadedNotificationTest.php
Normal file
@@ -0,0 +1,144 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use App\Enums\GuestNotificationType;
|
||||
use App\Events\GuestPhotoUploaded;
|
||||
use App\Listeners\GuestNotifications\SendPhotoUploadedNotification;
|
||||
use App\Models\Event;
|
||||
use App\Models\GuestNotification;
|
||||
use App\Models\GuestNotificationReceipt;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Tests\TestCase;
|
||||
|
||||
class SendPhotoUploadedNotificationTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_it_dedupes_recent_photo_activity_notifications(): void
|
||||
{
|
||||
Carbon::setTestNow('2026-01-12 13:48:01');
|
||||
|
||||
$event = Event::factory()->create();
|
||||
$listener = $this->app->make(SendPhotoUploadedNotification::class);
|
||||
|
||||
GuestNotification::factory()->create([
|
||||
'tenant_id' => $event->tenant_id,
|
||||
'event_id' => $event->id,
|
||||
'type' => GuestNotificationType::PHOTO_ACTIVITY,
|
||||
'title' => 'Fotospiel-Test hat gerade ein Foto gemacht 🎉',
|
||||
'payload' => [
|
||||
'photo_id' => 123,
|
||||
'photo_ids' => [123],
|
||||
'count' => 1,
|
||||
],
|
||||
'created_at' => now()->subSeconds(5),
|
||||
'updated_at' => now()->subSeconds(5),
|
||||
]);
|
||||
|
||||
$listener->handle(new GuestPhotoUploaded(
|
||||
$event,
|
||||
123,
|
||||
'device-123',
|
||||
'Fotospiel-Test'
|
||||
));
|
||||
|
||||
$notification = GuestNotification::query()
|
||||
->where('event_id', $event->id)
|
||||
->where('type', GuestNotificationType::PHOTO_ACTIVITY)
|
||||
->first();
|
||||
|
||||
$this->assertSame(1, GuestNotification::query()
|
||||
->where('event_id', $event->id)
|
||||
->where('type', GuestNotificationType::PHOTO_ACTIVITY)
|
||||
->count());
|
||||
$this->assertSame(1, (int) ($notification?->payload['count'] ?? 0));
|
||||
}
|
||||
|
||||
public function test_it_groups_recent_photo_activity_notifications(): void
|
||||
{
|
||||
Carbon::setTestNow('2026-01-12 13:48:01');
|
||||
|
||||
$event = Event::factory()->create();
|
||||
$listener = $this->app->make(SendPhotoUploadedNotification::class);
|
||||
|
||||
GuestNotification::factory()->create([
|
||||
'tenant_id' => $event->tenant_id,
|
||||
'event_id' => $event->id,
|
||||
'type' => GuestNotificationType::PHOTO_ACTIVITY,
|
||||
'title' => 'Fotospiel-Test hat gerade ein Foto gemacht 🎉',
|
||||
'payload' => [
|
||||
'photo_id' => 122,
|
||||
'photo_ids' => [122],
|
||||
'count' => 1,
|
||||
],
|
||||
'created_at' => now()->subMinutes(5),
|
||||
'updated_at' => now()->subMinutes(5),
|
||||
]);
|
||||
|
||||
$listener->handle(new GuestPhotoUploaded(
|
||||
$event,
|
||||
123,
|
||||
'device-123',
|
||||
'Fotospiel-Test'
|
||||
));
|
||||
|
||||
$this->assertSame(1, GuestNotification::query()
|
||||
->where('event_id', $event->id)
|
||||
->where('type', GuestNotificationType::PHOTO_ACTIVITY)
|
||||
->count());
|
||||
|
||||
$notification = GuestNotification::query()
|
||||
->where('event_id', $event->id)
|
||||
->where('type', GuestNotificationType::PHOTO_ACTIVITY)
|
||||
->first();
|
||||
|
||||
$this->assertSame('Es gibt 2 neue Fotos!', $notification?->title);
|
||||
$this->assertSame(2, (int) ($notification?->payload['count'] ?? 0));
|
||||
|
||||
$this->assertSame(1, GuestNotificationReceipt::query()
|
||||
->where('guest_identifier', 'device-123')
|
||||
->where('status', 'read')
|
||||
->count());
|
||||
}
|
||||
|
||||
public function test_it_creates_notification_outside_group_window(): void
|
||||
{
|
||||
Carbon::setTestNow('2026-01-12 13:48:01');
|
||||
|
||||
$event = Event::factory()->create();
|
||||
$listener = $this->app->make(SendPhotoUploadedNotification::class);
|
||||
|
||||
GuestNotification::factory()->create([
|
||||
'tenant_id' => $event->tenant_id,
|
||||
'event_id' => $event->id,
|
||||
'type' => GuestNotificationType::PHOTO_ACTIVITY,
|
||||
'title' => 'Fotospiel-Test hat gerade ein Foto gemacht 🎉',
|
||||
'payload' => [
|
||||
'photo_id' => 122,
|
||||
'photo_ids' => [122],
|
||||
'count' => 1,
|
||||
],
|
||||
'created_at' => now()->subMinutes(20),
|
||||
'updated_at' => now()->subMinutes(20),
|
||||
]);
|
||||
|
||||
$listener->handle(new GuestPhotoUploaded(
|
||||
$event,
|
||||
123,
|
||||
'device-123',
|
||||
'Fotospiel-Test'
|
||||
));
|
||||
|
||||
$this->assertSame(2, GuestNotification::query()
|
||||
->where('event_id', $event->id)
|
||||
->where('type', GuestNotificationType::PHOTO_ACTIVITY)
|
||||
->count());
|
||||
|
||||
$this->assertSame(1, GuestNotificationReceipt::query()
|
||||
->where('guest_identifier', 'device-123')
|
||||
->where('status', 'read')
|
||||
->count());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user