bd sync: 2026-01-12 17:10:05
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled

This commit is contained in:
Codex Agent
2026-01-12 17:10:05 +01:00
parent 4f4a527010
commit 3df0542013
55 changed files with 190 additions and 2974 deletions

View File

@@ -1 +1 @@
fotospiel-app-29r
fotospiel-app-9em

3
.gitignore vendored
View File

@@ -26,6 +26,3 @@ yarn-error.log
/.vscode
test-results
GEMINI.md
.beads/.sync.lock
.beads/daemon-error
.beads/sync_base.jsonl

View File

@@ -3,12 +3,9 @@
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;
@@ -17,10 +14,7 @@ use Illuminate\Validation\ValidationException;
class PackageController extends Controller
{
public function __construct(
private readonly PaddleCheckoutService $paddleCheckout,
private readonly CheckoutSessionService $sessions,
) {}
public function __construct(private readonly PaddleCheckoutService $paddleCheckout) {}
public function index(Request $request): JsonResponse
{
@@ -171,82 +165,23 @@ 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);
$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,
]);
return response()->json($checkout);
}
private function handleFreePurchase(Request $request, Package $package, $tenant): JsonResponse

View File

@@ -1,45 +0,0 @@
<?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'),
],
]);
}
}

View File

@@ -525,13 +525,13 @@ class PhotoController extends Controller
]);
// Only tenant admins can moderate
if (isset($validated['status']) && ! $this->tokenHasScope($request, 'tenant-admin')) {
if (isset($validated['status']) && ! $this->tokenHasScope($request, 'tenant:write')) {
return ApiError::response(
'insufficient_scope',
'Insufficient Scopes',
'You are not allowed to moderate photos for this event.',
Response::HTTP_FORBIDDEN,
['required_scope' => 'tenant-admin']
['required_scope' => 'tenant:write']
);
}
@@ -823,11 +823,6 @@ 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)) {

View File

@@ -1,47 +0,0 @@
<?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.');
}
}
}

View File

@@ -1,37 +0,0 @@
<?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,
]);
}
}

View File

@@ -1,28 +0,0 @@
<?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'],
];
}
}

View File

@@ -5,19 +5,11 @@ 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
*/
@@ -33,20 +25,7 @@ class SendPhotoUploadedNotification
? sprintf('%s hat gerade ein Foto gemacht 🎉', $guestLabel)
: 'Es gibt neue Fotos!';
$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(
$this->notifications->createNotification(
$event->event,
GuestNotificationType::PHOTO_ACTIVITY,
$title,
@@ -55,15 +34,11 @@ 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);
}
@@ -112,94 +87,4 @@ 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);
}
}

View File

@@ -1,25 +0,0 @@
<?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);
}
}

View File

@@ -162,10 +162,6 @@ 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'));
});

View File

@@ -126,36 +126,6 @@ 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 = [
@@ -172,9 +142,6 @@ class GuestNotificationService
$clean = array_filter([
'cta' => $cta,
'photo_id' => $photoId,
'photo_ids' => $photoIds,
'count' => $count,
]);
return $clean === [] ? null : $clean;

View File

@@ -1,80 +0,0 @@
<?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;
}
}

View File

@@ -1,18 +0,0 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.10.35013.3
MinimumVisualStudioVersion = 10.0.40219.1
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "PhotoboothUploader", "PhotoboothUploader\PhotoboothUploader.csproj", "{CDF88A75-8B20-4F54-96FC-A640B0D19A10}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{CDF88A75-8B20-4F54-96FC-A640B0D19A10}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{CDF88A75-8B20-4F54-96FC-A640B0D19A10}.Debug|Any CPU.Build.0 = Debug|Any CPU
{CDF88A75-8B20-4F54-96FC-A640B0D19A10}.Release|Any CPU.ActiveCfg = Release|Any CPU
{CDF88A75-8B20-4F54-96FC-A640B0D19A10}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
EndGlobal

View File

@@ -1,7 +0,0 @@
<Application
x:Class="PhotoboothUploader.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<Application.Resources>
</Application.Resources>
</Application>

View File

@@ -1,17 +0,0 @@
using Microsoft.UI.Xaml;
namespace PhotoboothUploader;
public partial class App : Application
{
public App()
{
InitializeComponent();
}
protected override void OnLaunched(LaunchActivatedEventArgs args)
{
var window = new MainWindow();
window.Activate();
}
}

View File

@@ -1,28 +0,0 @@
<Window
x:Class="PhotoboothUploader.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="Fotospiel Photobooth Uploader"
Height="360"
Width="520">
<Grid Padding="24">
<StackPanel Spacing="16" 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" PlaceholderText="123456">
<TextBox.InputScope>
<InputScope>
<InputScopeName NameValue="Number" />
</InputScope>
</TextBox.InputScope>
</TextBox>
<Button x:Name="ConnectButton" Content="Verbinden" Click="ConnectButton_Click" />
<StackPanel Spacing="8">
<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>
</Grid>
</Window>

View File

@@ -1,177 +0,0 @@
using Microsoft.UI.Xaml;
using PhotoboothUploader.Models;
using PhotoboothUploader.Services;
using System.Linq;
using System.IO;
using Windows.Storage;
using Windows.Storage.Pickers;
using WinRT.Interop;
namespace PhotoboothUploader;
public sealed 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 picker = new FolderPicker
{
SuggestedStartLocation = PickerLocationId.PicturesLibrary,
};
picker.FileTypeFilter.Add("*");
InitializeWithWindow.Initialize(picker, WindowNative.GetWindowHandle(this));
StorageFolder? folder = await picker.PickSingleFolderAsync();
if (folder is null)
{
return;
}
_settings.WatchFolder = folder.Path;
_settingsStore.Save(_settings);
FolderText.Text = folder.Path;
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)
{
DispatcherQueue.TryEnqueue(() => 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();
}
}

View File

@@ -1,30 +0,0 @@
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; }
}

View File

@@ -1,11 +0,0 @@
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; }
}

View File

@@ -1,19 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net8.0-windows10.0.19041.0</TargetFramework>
<UseWinUI>true</UseWinUI>
<EnableWindowsTargeting>true</EnableWindowsTargeting>
<DisableImplicitWindowsAppSDKRuntimeIdentifiers>true</DisableImplicitWindowsAppSDKRuntimeIdentifiers>
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
<RuntimeIdentifiers>win-x64</RuntimeIdentifiers>
<WindowsAppSDKRuntimeIdentifiers>win-x64</WindowsAppSDKRuntimeIdentifiers>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.WindowsAppSDK" Version="1.5.240404000" />
</ItemGroup>
</Project>

View File

@@ -1,46 +0,0 @@
using System.Net.Http.Json;
using System.Text.Json;
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;
}
}

View File

@@ -1,43 +0,0 @@
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);
}
}

View File

@@ -1,143 +0,0 @@
using System.Collections.Concurrent;
using System.Net.Http.Headers;
using System.Threading.Channels;
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",
};
}
}

View File

@@ -34,8 +34,4 @@ 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),
],
];

View File

@@ -1,29 +0,0 @@
<?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,
];
}
}

View File

@@ -1,31 +0,0 @@
<?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');
}
};

View File

@@ -1,16 +0,0 @@
<?php
namespace Database\Seeders;
use Illuminate\Database\Seeder;
class PhotoboothConnectCodeSeeder extends Seeder
{
/**
* Run the database seeds.
*/
public function run(): void
{
//
}
}

View File

@@ -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; checkout_session_id?: string }> {
): Promise<{ checkout_url: string; id: string; expires_at?: string }> {
const response = await authorizedFetch('/api/v1/tenant/packages/paddle-checkout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -2468,22 +2468,12 @@ export async function createTenantPaddleCheckout(
return_url: urls?.return_url,
}),
});
return await jsonOrThrow<{ checkout_url: string; id: string; expires_at?: string; checkout_session_id?: string }>(
return await jsonOrThrow<{ checkout_url: string; id: string; expires_at?: 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',

View File

@@ -34,27 +34,6 @@
"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",
@@ -197,8 +176,6 @@
},
"common": {
"all": "Alle",
"anonymous": "Anonym",
"error": "Etwas ist schiefgelaufen",
"loadMore": "Mehr laden",
"processing": "Verarbeite …",
"select": "Auswählen",
@@ -2898,25 +2875,16 @@
"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."
},
@@ -2925,26 +2893,6 @@
"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": {
@@ -2958,13 +2906,7 @@
},
"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": {
@@ -2976,9 +2918,7 @@
},
"badges": {
"recommended": "Empfohlen",
"active": "Aktiv",
"upgrade": "Upgrade",
"downgrade": "Downgrade"
"active": "Aktiv"
},
"confirmTitle": "Kauf bestätigen",
"confirmSubtitle": "Du upgradest auf:",
@@ -2991,7 +2931,6 @@
"payNow": "Jetzt zahlen",
"errors": {
"checkout": "Checkout fehlgeschlagen"
},
"selectDisabled": "Nicht verfügbar"
}
}
}

View File

@@ -34,27 +34,6 @@
"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",
@@ -193,8 +172,6 @@
},
"common": {
"all": "All",
"anonymous": "Anonymous",
"error": "Something went wrong",
"loadMore": "Load more",
"processing": "Processing…",
"select": "Select",
@@ -2902,25 +2879,16 @@
"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."
},
@@ -2929,26 +2897,6 @@
"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": {
@@ -2962,13 +2910,7 @@
},
"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": {
@@ -2980,9 +2922,7 @@
},
"badges": {
"recommended": "Recommended",
"active": "Active",
"upgrade": "Upgrade",
"downgrade": "Downgrade"
"active": "Active"
},
"confirmTitle": "Confirm Purchase",
"confirmSubtitle": "You are upgrading to:",
@@ -2995,7 +2935,6 @@
"payNow": "Pay Now",
"errors": {
"checkout": "Checkout failed"
},
"selectDisabled": "Not available"
}
}
}

View File

@@ -12,7 +12,6 @@ import {
createTenantBillingPortalSession,
getTenantPackagesOverview,
getTenantPaddleTransactions,
getTenantPackageCheckoutStatus,
TenantPackageSummary,
PaddleTransactionSummary,
} from '../api';
@@ -28,14 +27,6 @@ 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');
@@ -49,11 +40,6 @@ 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';
@@ -109,11 +95,6 @@ export default function MobileBillingPage() {
}
}, [portalBusy, t]);
const persistPendingCheckout = React.useCallback((next: PendingCheckout | null) => {
setPendingCheckout(next);
storePendingCheckout(next);
}, []);
React.useEffect(() => {
void load();
}, [load]);
@@ -127,115 +108,6 @@ 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"
@@ -255,109 +127,6 @@ 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">

View File

@@ -2,18 +2,17 @@ import React from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useQuery } from '@tanstack/react-query';
import { TrendingUp, Users, ListTodo, Lock, Trophy } from 'lucide-react';
import { BarChart2, TrendingUp, Users, ListTodo, Lock, Trophy, Calendar } 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, KpiTile, SkeletonCard } from './components/Primitives';
import { MobileCard, CTAButton, 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() {
@@ -98,17 +97,9 @@ 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 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;
const maxCount = Math.max(...timeline.map((p) => p.count), 1);
return (
<MobileShell
@@ -117,28 +108,6 @@ 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">
@@ -147,22 +116,12 @@ 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 / maxTimelineCount) * 100;
const heightPercent = (point.count / maxCount) * 100;
const date = parseISO(point.timestamp);
// Show label every 3rd point or if few points
const showLabel = timeline.length < 8 || index % 3 === 0;
@@ -179,7 +138,7 @@ export default function MobileEventAnalyticsPage() {
/>
{showLabel && (
<Text fontSize={10} color={muted} numberOfLines={1}>
{format(date, 'HH:mm', { locale: dateLocale })}
{format(date, 'HH:mm')}
</Text>
)}
</YStack>
@@ -191,11 +150,7 @@ export default function MobileEventAnalyticsPage() {
</Text>
</YStack>
) : (
<EmptyState
message={t('analytics.noActivity', 'No uploads yet')}
actionLabel={t('analytics.emptyActionShareQr', 'Share your QR code')}
onAction={() => slug && navigate(adminPath(`/mobile/events/${slug}/qr`))}
/>
<EmptyState message={t('analytics.noActivity', 'No uploads yet')} />
)}
</MobileCard>
@@ -241,11 +196,7 @@ export default function MobileEventAnalyticsPage() {
))}
</YStack>
) : (
<EmptyState
message={t('analytics.noContributors', 'No contributors yet')}
actionLabel={t('analytics.emptyActionInvite', 'Invite guests')}
onAction={() => slug && navigate(adminPath(`/mobile/events/${slug}/members`))}
/>
<EmptyState message={t('analytics.noContributors', 'No contributors yet')} />
)}
</MobileCard>
@@ -261,6 +212,7 @@ 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">
@@ -285,11 +237,7 @@ export default function MobileEventAnalyticsPage() {
})}
</YStack>
) : (
<EmptyState
message={t('analytics.noTasks', 'No task activity yet')}
actionLabel={t('analytics.emptyActionOpenTasks', 'Open tasks')}
onAction={() => slug && navigate(adminPath(`/mobile/events/${slug}/tasks`))}
/>
<EmptyState message={t('analytics.noTasks', 'No task activity yet')} />
)}
</MobileCard>
</YStack>
@@ -297,24 +245,13 @@ export default function MobileEventAnalyticsPage() {
);
}
function EmptyState({
message,
actionLabel,
onAction,
}: {
message: string;
actionLabel?: string;
onAction?: () => void;
}) {
function EmptyState({ message }: { message: string }) {
const { muted } = useAdminTheme();
return (
<YStack padding="$4" alignItems="center" justifyContent="center" space="$2">
<YStack padding="$4" alignItems="center" justifyContent="center">
<Text fontSize="$sm" color={muted}>
{message}
</Text>
{actionLabel && onAction ? (
<CTAButton label={actionLabel} tone="ghost" fullWidth={false} onPress={onAction} />
) : null}
</YStack>
);
}

View File

@@ -1,31 +1,25 @@
import React from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Check, ChevronRight, ShieldCheck, Sparkles, X } from 'lucide-react';
import { Check, ChevronRight, ShieldCheck, ShoppingBag, Sparkles, Star } 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, Package, getTenantPackagesOverview, TenantPackageSummary } from '../api';
import { getPackages, createTenantPaddleCheckout, Package, getTenantPackagesOverview, TenantPackageSummary } from '../api';
import { getApiErrorMessage } from '../lib/apiError';
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, accentSoft } = useAdminTheme();
const { textStrong, muted, border, primary, surface, accentSoft, warningText } = 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);
@@ -63,36 +57,19 @@ 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) => {
if (recommendedPackageId) {
if (a.id === recommendedPackageId && b.id !== recommendedPackageId) return -1;
if (b.id === recommendedPackageId && a.id !== recommendedPackageId) return 1;
}
// 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;
// 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">
@@ -116,45 +93,23 @@ export default function MobilePackageShopPage() {
</Text>
</YStack>
{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) => (
{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={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)}
key={pkg.id}
pkg={pkg}
owned={owned}
isActive={isActive}
isRecommended={isRecommended}
onSelect={() => setSelectedPackage(pkg)}
/>
))
)}
);
})}
</YStack>
</YStack>
</MobileShell>
@@ -166,34 +121,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 statusLabel = getPackageStatusLabel({ t, isActive, owned });
const isSubdued = Boolean((isDowngrade || !isUpgrade) && !isActive);
const canSelect = canSelectPackage(isUpgrade, isActive);
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;
return (
<MobileCard
onPress={canSelect ? onSelect : undefined}
onPress={onSelect}
borderColor={isRecommended ? primary : (isActive ? '$green8' : border)}
borderWidth={isRecommended || isActive ? 2 : 1}
space="$3"
pressStyle={canSelect ? { backgroundColor: accentSoft } : undefined}
pressStyle={{ backgroundColor: accentSoft }}
backgroundColor={isActive ? '$green1' : undefined}
style={{ opacity: isSubdued ? 0.6 : 1 }}
>
<XStack justifyContent="space-between" alignItems="flex-start">
<YStack space="$1">
@@ -202,8 +157,6 @@ 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>
@@ -234,25 +187,19 @@ function PackageShopCard({
) : null}
{/* Render specific feature if it was requested */}
{getEnabledPackageFeatures(pkg)
.filter((key) => !pkg.max_photos || key !== 'photos')
.slice(0, 3)
.map((key) => (
<FeatureRow key={key} label={t(`shop.features.${key}`, key)} />
))}
{Object.entries(pkg.features || {})
.filter(([key, val]) => val === true && (!pkg.max_photos || key !== 'photos'))
.slice(0, 3)
.map(([key]) => (
<FeatureRow key={key} label={t(`shop.features.${key}`, key)} />
))
}
</YStack>
<CTAButton
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}
label={isActive ? t('shop.manage', 'Manage Plan') : t('shop.select', 'Select')}
onPress={onSelect}
tone={isActive ? 'ghost' : 'primary'}
/>
</MobileCard>
);
@@ -268,224 +215,28 @@ 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 } = useAdminTheme();
const { textStrong, muted, border, primary, danger } = useAdminTheme();
const [agbAccepted, setAgbAccepted] = React.useState(false);
const [withdrawalAccepted, setWithdrawalAccepted] = React.useState(false);
const { busy, startCheckout } = usePackageCheckout();
const [busy, setBusy] = React.useState(false);
const canProceed = agbAccepted && withdrawalAccepted;
const handleCheckout = async () => {
if (!canProceed || busy) return;
await startCheckout(pkg.id);
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);
}
};
return (

View File

@@ -1,33 +0,0 @@
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);
});
});

View File

@@ -1,42 +0,0 @@
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();
});
});

View File

@@ -1,83 +0,0 @@
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']);
});
});

View File

@@ -1,143 +0,0 @@
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();
});
});

View File

@@ -1,59 +0,0 @@
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 };
}

View File

@@ -1,28 +0,0 @@
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));
}

View File

@@ -1,82 +0,0 @@
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;
}

View File

@@ -1,146 +0,0 @@
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];
}

View File

@@ -15,8 +15,7 @@ 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('{{count}}', String(options?.count ?? '{{count}}'));
.replace('{{remaining}}', String(options?.remaining ?? '{{remaining}}'));
};
describe('packageSummary helpers', () => {
@@ -54,12 +53,6 @@ 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);

View File

@@ -138,12 +138,6 @@ 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,

View File

@@ -27,6 +27,7 @@ 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';
@@ -150,6 +151,7 @@ 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);
@@ -256,6 +258,7 @@ export default function Header({ eventToken, title = '' }: { eventToken?: string
onToggle={() => setNotificationsOpen((prev) => !prev)}
panelRef={panelRef}
buttonRef={notificationButtonRef}
taskProgress={tasksEnabled && taskProgress?.hydrated ? taskProgress : undefined}
t={t}
/>
)}
@@ -282,14 +285,18 @@ 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, t }: NotificationButtonProps) {
const badgeCount = center.unreadCount;
const [activeTab, setActiveTab] = React.useState<'unread' | 'all' | 'uploads'>(center.unreadCount > 0 ? 'unread' : 'all');
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');
const [scopeFilter, setScopeFilter] = React.useState<'all' | 'tips' | 'general'>('all');
const pushState = usePushSubscription(eventToken);
@@ -314,7 +321,7 @@ function NotificationButton({ center, eventToken, open, onToggle, panelRef, butt
case 'unread':
base = unreadNotifications;
break;
case 'uploads':
case 'status':
base = uploadNotifications;
break;
default:
@@ -324,7 +331,7 @@ function NotificationButton({ center, eventToken, open, onToggle, panelRef, butt
}, [activeTab, center.notifications, unreadNotifications, uploadNotifications]);
const scopedNotifications = React.useMemo(() => {
if (activeTab === 'uploads' || scopeFilter === 'all') {
if (scopeFilter === 'all') {
return filteredNotifications;
}
return filteredNotifications.filter((item) => {
@@ -358,10 +365,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', 'Updates')}</p>
<p className="text-sm font-semibold text-slate-900">{t('header.notifications.title', 'Benachrichtigungen')}</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>
@@ -377,43 +384,67 @@ function NotificationButton({ center, eventToken, open, onToggle, panelRef, butt
</div>
<NotificationTabs
tabs={[
{ 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 },
{ 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 },
]}
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">
{(
[
{ key: 'all', label: t('header.notifications.scope.all', 'Alle') },
{ key: 'tips', label: t('header.notifications.scope.tips', 'Tipps & Achievements') },
{ key: 'general', label: t('header.notifications.scope.general', 'Allgemein') },
] as const
).map((option) => (
<button
key={option.key}
type="button"
onClick={() => {
setScopeFilter(option.key);
center.setFilters({ scope: option.key });
}}
className={`rounded-full border px-3 py-1 font-semibold transition ${
scopeFilter === option.key
? 'border-pink-200 bg-pink-50 text-pink-700'
: 'border-slate-200 bg-white text-slate-600 hover:border-pink-200 hover:text-pink-700'
}`}
>
{option.label}
</button>
))}
</div>
<div className="mt-3">
<div className="flex gap-2 overflow-x-auto text-xs whitespace-nowrap pb-1">
{(
[
{ key: 'all', label: t('header.notifications.scope.all', 'Alle') },
{ key: 'tips', label: t('header.notifications.scope.tips', 'Tipps & Achievements') },
{ key: 'general', label: t('header.notifications.scope.general', 'Allgemein') },
] as const
).map((option) => (
<button
key={option.key}
type="button"
onClick={() => {
setScopeFilter(option.key);
center.setFilters({ scope: option.key });
}}
className={`rounded-full border px-3 py-1 font-semibold transition ${
scopeFilter === option.key
? 'border-pink-200 bg-pink-50 text-pink-700'
: 'border-slate-200 bg-white text-slate-600 hover:border-pink-200 hover:text-pink-700'
}`}
>
{option.label}
</button>
))}
</div>
)}
{activeTab === 'uploads' && (center.pendingCount > 0 || center.queueCount > 0) && (
</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' && (
<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">
@@ -447,32 +478,30 @@ function NotificationButton({ center, eventToken, open, onToggle, panelRef, butt
)}
</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 === 'uploads'
? 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}
{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>
</div>
</div>
)}
<NotificationStatusBar
lastFetchedAt={center.lastFetchedAt}
isOffline={center.isOffline}

View File

@@ -38,6 +38,7 @@ vi.mock('../../context/NotificationCenterContext', () => ({
queueItems: [],
queueCount: 0,
pendingCount: 0,
totalCount: 0,
loading: false,
pendingLoading: false,
refresh: vi.fn(),
@@ -96,10 +97,10 @@ describe('Header notifications toggle', () => {
const bellButton = screen.getByLabelText('Benachrichtigungen anzeigen');
fireEvent.click(bellButton);
expect(screen.getByText('Updates')).toBeInTheDocument();
expect(screen.getByText('Benachrichtigungen')).toBeInTheDocument();
fireEvent.click(bellButton);
expect(screen.queryByText('Updates')).not.toBeInTheDocument();
expect(screen.queryByText('Benachrichtigungen')).not.toBeInTheDocument();
});
});

View File

@@ -16,6 +16,7 @@ export type NotificationCenterValue = {
queueItems: QueueItem[];
queueCount: number;
pendingCount: number;
totalCount: number;
loading: boolean;
pendingLoading: boolean;
refresh: () => Promise<void>;
@@ -263,9 +264,11 @@ export function NotificationCenterProvider({ eventToken, children }: { eventToke
}, [loadNotifications, refreshQueue, loadPendingUploads]);
const loading = loadingNotifications || queueLoading || pendingLoading;
const totalCount = unreadCount + queueCount + pendingCount;
React.useEffect(() => {
void updateAppBadge(unreadCount);
}, [unreadCount]);
void updateAppBadge(totalCount);
}, [totalCount]);
const value: NotificationCenterValue = {
notifications,
@@ -273,6 +276,7 @@ export function NotificationCenterProvider({ eventToken, children }: { eventToke
queueItems: items,
queueCount,
pendingCount,
totalCount,
loading,
pendingLoading,
refresh,

View File

@@ -42,13 +42,7 @@ export const messages: Record<LocaleCode, NestedMessages> = {
},
helpGallery: 'Hilfe zu Galerie & Teilen',
notifications: {
title: 'Updates',
unread: '{count} neu',
allRead: 'Alles gelesen',
tabUnread: 'Nachrichten',
tabUploads: 'Uploads',
tabAll: 'Alle Updates',
emptyStatus: 'Keine Upload-Hinweise oder Wartungen aktiv.',
tabStatus: 'Upload-Status',
},
},
liveShowPlayer: {
@@ -780,13 +774,7 @@ export const messages: Record<LocaleCode, NestedMessages> = {
},
helpGallery: 'Help: Gallery & sharing',
notifications: {
title: 'Updates',
unread: '{count} new',
allRead: 'All read',
tabUnread: 'Messages',
tabUploads: 'Uploads',
tabAll: 'All updates',
emptyStatus: 'No upload status or maintenance active.',
tabStatus: 'Upload status',
},
},
liveShowPlayer: {

View File

@@ -6,7 +6,6 @@ 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;
@@ -25,7 +24,6 @@ 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;
@@ -155,9 +153,6 @@ 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')
@@ -268,8 +263,6 @@ 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'])
@@ -360,8 +353,6 @@ 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'])

View File

@@ -1,100 +0,0 @@
<?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);
}
}

View File

@@ -1,26 +0,0 @@
<?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);
}
}

View File

@@ -1,46 +0,0 @@
<?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');
}
}

View File

@@ -29,10 +29,7 @@ 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('metadata', $payload)
&& is_array($payload['metadata'])
&& ! empty($payload['metadata']['checkout_session_id']);
&& array_key_exists('return_url', $payload);
})
->andReturn([
'checkout_url' => 'https://checkout.paddle.test/checkout/123',
@@ -45,8 +42,7 @@ class TenantPaddleCheckoutTest extends TenantTestCase
]);
$response->assertOk()
->assertJsonPath('checkout_url', 'https://checkout.paddle.test/checkout/123')
->assertJsonStructure(['checkout_session_id']);
->assertJsonPath('checkout_url', 'https://checkout.paddle.test/checkout/123');
}
public function test_paddle_checkout_requires_paddle_price_id(): void

View File

@@ -1,67 +0,0 @@
<?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);
}
}

View File

@@ -1,144 +0,0 @@
<?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());
}
}