bd sync: 2026-01-12 16:57:37
This commit is contained in:
@@ -1 +1 @@
|
|||||||
fotospiel-app-29r
|
fotospiel-app-9em
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -26,6 +26,3 @@ yarn-error.log
|
|||||||
/.vscode
|
/.vscode
|
||||||
test-results
|
test-results
|
||||||
GEMINI.md
|
GEMINI.md
|
||||||
.beads/.sync.lock
|
|
||||||
.beads/daemon-error
|
|
||||||
.beads/sync_base.jsonl
|
|
||||||
|
|||||||
@@ -3,12 +3,9 @@
|
|||||||
namespace App\Http\Controllers\Api;
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Http\Requests\Checkout\CheckoutSessionStatusRequest;
|
|
||||||
use App\Models\CheckoutSession;
|
|
||||||
use App\Models\Package;
|
use App\Models\Package;
|
||||||
use App\Models\PackagePurchase;
|
use App\Models\PackagePurchase;
|
||||||
use App\Models\TenantPackage;
|
use App\Models\TenantPackage;
|
||||||
use App\Services\Checkout\CheckoutSessionService;
|
|
||||||
use App\Services\Paddle\PaddleCheckoutService;
|
use App\Services\Paddle\PaddleCheckoutService;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
@@ -17,10 +14,7 @@ use Illuminate\Validation\ValidationException;
|
|||||||
|
|
||||||
class PackageController extends Controller
|
class PackageController extends Controller
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(private readonly PaddleCheckoutService $paddleCheckout) {}
|
||||||
private readonly PaddleCheckoutService $paddleCheckout,
|
|
||||||
private readonly CheckoutSessionService $sessions,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
public function index(Request $request): JsonResponse
|
public function index(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
@@ -171,82 +165,23 @@ class PackageController extends Controller
|
|||||||
|
|
||||||
$package = Package::findOrFail($request->integer('package_id'));
|
$package = Package::findOrFail($request->integer('package_id'));
|
||||||
$tenant = $request->attributes->get('tenant');
|
$tenant = $request->attributes->get('tenant');
|
||||||
$user = $request->user();
|
|
||||||
|
|
||||||
if (! $tenant) {
|
if (! $tenant) {
|
||||||
throw ValidationException::withMessages(['tenant' => 'Tenant context missing.']);
|
throw ValidationException::withMessages(['tenant' => 'Tenant context missing.']);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! $user) {
|
|
||||||
throw ValidationException::withMessages(['user' => 'User context missing.']);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $package->paddle_price_id) {
|
if (! $package->paddle_price_id) {
|
||||||
throw ValidationException::withMessages(['package_id' => 'Package is not linked to a Paddle price.']);
|
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 = [
|
$payload = [
|
||||||
'success_url' => $request->input('success_url'),
|
'success_url' => $request->input('success_url'),
|
||||||
'return_url' => $request->input('return_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);
|
$checkout = $this->paddleCheckout->createCheckout($tenant, $package, $payload);
|
||||||
|
|
||||||
$session->forceFill([
|
return response()->json($checkout);
|
||||||
'paddle_checkout_id' => $checkout['id'] ?? $session->paddle_checkout_id,
|
|
||||||
'provider_metadata' => array_merge($session->provider_metadata ?? [], array_filter([
|
|
||||||
'paddle_checkout_id' => $checkout['id'] ?? null,
|
|
||||||
'paddle_checkout_url' => $checkout['checkout_url'] ?? null,
|
|
||||||
'paddle_expires_at' => $checkout['expires_at'] ?? null,
|
|
||||||
])),
|
|
||||||
])->save();
|
|
||||||
|
|
||||||
return response()->json(array_merge($checkout, [
|
|
||||||
'checkout_session_id' => $session->id,
|
|
||||||
]));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function checkoutSessionStatus(CheckoutSessionStatusRequest $request, CheckoutSession $session): JsonResponse
|
|
||||||
{
|
|
||||||
$history = $session->status_history ?? [];
|
|
||||||
$reason = null;
|
|
||||||
|
|
||||||
foreach (array_reverse($history) as $entry) {
|
|
||||||
if (($entry['status'] ?? null) === $session->status) {
|
|
||||||
$reason = $entry['reason'] ?? null;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$checkoutUrl = data_get($session->provider_metadata ?? [], 'paddle_checkout_url');
|
|
||||||
|
|
||||||
return response()->json([
|
|
||||||
'status' => $session->status,
|
|
||||||
'completed_at' => optional($session->completed_at)->toIso8601String(),
|
|
||||||
'reason' => $reason,
|
|
||||||
'checkout_url' => is_string($checkoutUrl) ? $checkoutUrl : null,
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private function handleFreePurchase(Request $request, Package $package, $tenant): JsonResponse
|
private function handleFreePurchase(Request $request, Package $package, $tenant): JsonResponse
|
||||||
|
|||||||
@@ -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'),
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -525,13 +525,13 @@ class PhotoController extends Controller
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
// Only tenant admins can moderate
|
// 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(
|
return ApiError::response(
|
||||||
'insufficient_scope',
|
'insufficient_scope',
|
||||||
'Insufficient Scopes',
|
'Insufficient Scopes',
|
||||||
'You are not allowed to moderate photos for this event.',
|
'You are not allowed to moderate photos for this event.',
|
||||||
Response::HTTP_FORBIDDEN,
|
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
|
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'] ?? []);
|
$scopes = $request->user()->scopes ?? ($request->attributes->get('decoded_token')['scopes'] ?? []);
|
||||||
|
|
||||||
if (! is_array($scopes)) {
|
if (! is_array($scopes)) {
|
||||||
|
|||||||
@@ -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.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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'],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -5,19 +5,11 @@ namespace App\Listeners\GuestNotifications;
|
|||||||
use App\Enums\GuestNotificationAudience;
|
use App\Enums\GuestNotificationAudience;
|
||||||
use App\Enums\GuestNotificationType;
|
use App\Enums\GuestNotificationType;
|
||||||
use App\Events\GuestPhotoUploaded;
|
use App\Events\GuestPhotoUploaded;
|
||||||
use App\Models\GuestNotification;
|
|
||||||
use App\Models\Photo;
|
use App\Models\Photo;
|
||||||
use App\Services\GuestNotificationService;
|
use App\Services\GuestNotificationService;
|
||||||
use Illuminate\Support\Carbon;
|
|
||||||
|
|
||||||
class SendPhotoUploadedNotification
|
class SendPhotoUploadedNotification
|
||||||
{
|
{
|
||||||
private const DEDUPE_WINDOW_SECONDS = 30;
|
|
||||||
|
|
||||||
private const GROUP_WINDOW_MINUTES = 10;
|
|
||||||
|
|
||||||
private const MAX_GROUP_PHOTOS = 6;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param int[] $milestones
|
* @param int[] $milestones
|
||||||
*/
|
*/
|
||||||
@@ -33,20 +25,7 @@ class SendPhotoUploadedNotification
|
|||||||
? sprintf('%s hat gerade ein Foto gemacht 🎉', $guestLabel)
|
? sprintf('%s hat gerade ein Foto gemacht 🎉', $guestLabel)
|
||||||
: 'Es gibt neue Fotos!';
|
: 'Es gibt neue Fotos!';
|
||||||
|
|
||||||
$recent = $this->findRecentPhotoNotification($event->event->id);
|
$this->notifications->createNotification(
|
||||||
if ($recent) {
|
|
||||||
if ($this->shouldSkipDuplicate($recent, $event->photoId, $title)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$notification = $this->updateGroupedNotification($recent, $event->photoId);
|
|
||||||
$this->markUploaderRead($notification, $event->guestIdentifier);
|
|
||||||
$this->maybeCreateMilestoneNotification($event, $guestLabel);
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$notification = $this->notifications->createNotification(
|
|
||||||
$event->event,
|
$event->event,
|
||||||
GuestNotificationType::PHOTO_ACTIVITY,
|
GuestNotificationType::PHOTO_ACTIVITY,
|
||||||
$title,
|
$title,
|
||||||
@@ -55,15 +34,11 @@ class SendPhotoUploadedNotification
|
|||||||
'audience_scope' => GuestNotificationAudience::ALL,
|
'audience_scope' => GuestNotificationAudience::ALL,
|
||||||
'payload' => [
|
'payload' => [
|
||||||
'photo_id' => $event->photoId,
|
'photo_id' => $event->photoId,
|
||||||
'photo_ids' => [$event->photoId],
|
|
||||||
'count' => 1,
|
|
||||||
],
|
],
|
||||||
'expires_at' => now()->addHours(3),
|
'expires_at' => now()->addHours(3),
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
$this->markUploaderRead($notification, $event->guestIdentifier);
|
|
||||||
|
|
||||||
$this->maybeCreateMilestoneNotification($event, $guestLabel);
|
$this->maybeCreateMilestoneNotification($event, $guestLabel);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,94 +87,4 @@ class SendPhotoUploadedNotification
|
|||||||
|
|
||||||
return $guestIdentifier;
|
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -162,10 +162,6 @@ class AppServiceProvider extends ServiceProvider
|
|||||||
return Limit::perMinute(300)->by('guest-api:'.($request->ip() ?? 'unknown'));
|
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) {
|
RateLimiter::for('tenant-auth', function (Request $request) {
|
||||||
return Limit::perMinute(20)->by('tenant-auth:'.($request->ip() ?? 'unknown'));
|
return Limit::perMinute(20)->by('tenant-auth:'.($request->ip() ?? 'unknown'));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -126,36 +126,6 @@ class GuestNotificationService
|
|||||||
return null;
|
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');
|
$cta = Arr::get($payload, 'cta');
|
||||||
if (is_array($cta)) {
|
if (is_array($cta)) {
|
||||||
$cta = [
|
$cta = [
|
||||||
@@ -172,9 +142,6 @@ class GuestNotificationService
|
|||||||
|
|
||||||
$clean = array_filter([
|
$clean = array_filter([
|
||||||
'cta' => $cta,
|
'cta' => $cta,
|
||||||
'photo_id' => $photoId,
|
|
||||||
'photo_ids' => $photoIds,
|
|
||||||
'count' => $count,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return $clean === [] ? null : $clean;
|
return $clean === [] ? null : $clean;
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
@@ -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>
|
|
||||||
@@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
@@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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; }
|
|
||||||
}
|
|
||||||
@@ -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; }
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
|
||||||
<PropertyGroup>
|
|
||||||
<OutputType>WinExe</OutputType>
|
|
||||||
<TargetFramework>net8.0-windows10.0.19041.0</TargetFramework>
|
|
||||||
<UseWinUI>true</UseWinUI>
|
|
||||||
<Nullable>enable</Nullable>
|
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
|
||||||
</PropertyGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<PackageReference Include="Microsoft.WindowsAppSDK" Version="1.5.240404000" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<ApplicationDefinition Include="App.xaml" />
|
|
||||||
<Page Include="MainWindow.xaml" />
|
|
||||||
</ItemGroup>
|
|
||||||
</Project>
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -34,8 +34,4 @@ return [
|
|||||||
'rate_limit_per_minute' => (int) env('SPARKBOOTH_RATE_LIMIT_PER_MINUTE', env('PHOTOBOOTH_RATE_LIMIT_PER_MINUTE', 20)),
|
'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'),
|
'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),
|
|
||||||
],
|
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace Database\Seeders;
|
|
||||||
|
|
||||||
use Illuminate\Database\Seeder;
|
|
||||||
|
|
||||||
class PhotoboothConnectCodeSeeder extends Seeder
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Run the database seeds.
|
|
||||||
*/
|
|
||||||
public function run(): void
|
|
||||||
{
|
|
||||||
//
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2458,7 +2458,7 @@ export async function getTenantPaddleTransactions(cursor?: string): Promise<{
|
|||||||
export async function createTenantPaddleCheckout(
|
export async function createTenantPaddleCheckout(
|
||||||
packageId: number,
|
packageId: number,
|
||||||
urls?: { success_url?: string; return_url?: string }
|
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', {
|
const response = await authorizedFetch('/api/v1/tenant/packages/paddle-checkout', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
@@ -2468,22 +2468,12 @@ export async function createTenantPaddleCheckout(
|
|||||||
return_url: urls?.return_url,
|
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,
|
response,
|
||||||
'Failed to create checkout'
|
'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 }> {
|
export async function createTenantBillingPortalSession(): Promise<{ url: string }> {
|
||||||
const response = await authorizedFetch('/api/v1/tenant/billing/portal', {
|
const response = await authorizedFetch('/api/v1/tenant/billing/portal', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
@@ -34,27 +34,6 @@
|
|||||||
"more": "Weitere Einträge konnten nicht geladen werden.",
|
"more": "Weitere Einträge konnten nicht geladen werden.",
|
||||||
"portal": "Paddle-Portal konnte nicht geöffnet 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": {
|
"sections": {
|
||||||
"invoices": {
|
"invoices": {
|
||||||
"title": "Rechnungen & Zahlungen",
|
"title": "Rechnungen & Zahlungen",
|
||||||
@@ -197,8 +176,6 @@
|
|||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"all": "Alle",
|
"all": "Alle",
|
||||||
"anonymous": "Anonym",
|
|
||||||
"error": "Etwas ist schiefgelaufen",
|
|
||||||
"loadMore": "Mehr laden",
|
"loadMore": "Mehr laden",
|
||||||
"processing": "Verarbeite …",
|
"processing": "Verarbeite …",
|
||||||
"select": "Auswählen",
|
"select": "Auswählen",
|
||||||
@@ -2898,25 +2875,16 @@
|
|||||||
"analytics": {
|
"analytics": {
|
||||||
"title": "Analytics",
|
"title": "Analytics",
|
||||||
"upgradeAction": "Upgrade auf Premium",
|
"upgradeAction": "Upgrade auf Premium",
|
||||||
"kpiTitle": "Event-Überblick",
|
|
||||||
"kpiUploads": "Uploads",
|
|
||||||
"kpiContributors": "Beitragende",
|
|
||||||
"kpiLikes": "Likes",
|
|
||||||
"activityTitle": "Aktivitäts-Zeitachse",
|
"activityTitle": "Aktivitäts-Zeitachse",
|
||||||
"timeframe": "Letzte {{hours}} Stunden",
|
|
||||||
"timeframeHint": "Ältere Aktivität ausgeblendet",
|
|
||||||
"uploadsPerHour": "Uploads pro Stunde",
|
"uploadsPerHour": "Uploads pro Stunde",
|
||||||
"noActivity": "Noch keine Uploads",
|
"noActivity": "Noch keine Uploads",
|
||||||
"emptyActionShareQr": "QR-Code teilen",
|
|
||||||
"contributorsTitle": "Top-Beitragende",
|
"contributorsTitle": "Top-Beitragende",
|
||||||
"likesCount": "{{count}} Likes",
|
"likesCount": "{{count}} Likes",
|
||||||
"likesCount_one": "{{count}} Like",
|
"likesCount_one": "{{count}} Like",
|
||||||
"likesCount_other": "{{count}} Likes",
|
"likesCount_other": "{{count}} Likes",
|
||||||
"noContributors": "Noch keine Beitragenden",
|
"noContributors": "Noch keine Beitragenden",
|
||||||
"emptyActionInvite": "Gäste einladen",
|
|
||||||
"tasksTitle": "Beliebte Aufgaben",
|
"tasksTitle": "Beliebte Aufgaben",
|
||||||
"noTasks": "Noch keine Aufgabenaktivität",
|
"noTasks": "Noch keine Aufgabenaktivität",
|
||||||
"emptyActionOpenTasks": "Aufgaben öffnen",
|
|
||||||
"lockedTitle": "Analytics freischalten",
|
"lockedTitle": "Analytics freischalten",
|
||||||
"lockedBody": "Erhalte tiefe Einblicke in die Interaktionen deines Events mit dem Premium-Paket."
|
"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.",
|
"subtitle": "Wähle ein Paket, um mehr Funktionen und Limits freizuschalten.",
|
||||||
"recommendationTitle": "Empfohlen für dich",
|
"recommendationTitle": "Empfohlen für dich",
|
||||||
"recommendationBody": "Das hervorgehobene Paket enthält das gewünschte Feature.",
|
"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",
|
"select": "Auswählen",
|
||||||
"manage": "Paket verwalten",
|
"manage": "Paket verwalten",
|
||||||
"limits": {
|
"limits": {
|
||||||
@@ -2958,13 +2906,7 @@
|
|||||||
},
|
},
|
||||||
"features": {
|
"features": {
|
||||||
"advanced_analytics": "Erweiterte Analytics",
|
"advanced_analytics": "Erweiterte Analytics",
|
||||||
"basic_uploads": "Basis-Uploads",
|
|
||||||
"custom_branding": "Eigenes Branding",
|
"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"
|
"watermark_removal": "Kein Wasserzeichen"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
@@ -2976,9 +2918,7 @@
|
|||||||
},
|
},
|
||||||
"badges": {
|
"badges": {
|
||||||
"recommended": "Empfohlen",
|
"recommended": "Empfohlen",
|
||||||
"active": "Aktiv",
|
"active": "Aktiv"
|
||||||
"upgrade": "Upgrade",
|
|
||||||
"downgrade": "Downgrade"
|
|
||||||
},
|
},
|
||||||
"confirmTitle": "Kauf bestätigen",
|
"confirmTitle": "Kauf bestätigen",
|
||||||
"confirmSubtitle": "Du upgradest auf:",
|
"confirmSubtitle": "Du upgradest auf:",
|
||||||
@@ -2991,7 +2931,6 @@
|
|||||||
"payNow": "Jetzt zahlen",
|
"payNow": "Jetzt zahlen",
|
||||||
"errors": {
|
"errors": {
|
||||||
"checkout": "Checkout fehlgeschlagen"
|
"checkout": "Checkout fehlgeschlagen"
|
||||||
},
|
}
|
||||||
"selectDisabled": "Nicht verfügbar"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,27 +34,6 @@
|
|||||||
"more": "Unable to load more entries.",
|
"more": "Unable to load more entries.",
|
||||||
"portal": "Unable to open the Paddle portal."
|
"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": {
|
"sections": {
|
||||||
"invoices": {
|
"invoices": {
|
||||||
"title": "Invoices & payments",
|
"title": "Invoices & payments",
|
||||||
@@ -193,8 +172,6 @@
|
|||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"all": "All",
|
"all": "All",
|
||||||
"anonymous": "Anonymous",
|
|
||||||
"error": "Something went wrong",
|
|
||||||
"loadMore": "Load more",
|
"loadMore": "Load more",
|
||||||
"processing": "Processing…",
|
"processing": "Processing…",
|
||||||
"select": "Select",
|
"select": "Select",
|
||||||
@@ -2902,25 +2879,16 @@
|
|||||||
"analytics": {
|
"analytics": {
|
||||||
"title": "Analytics",
|
"title": "Analytics",
|
||||||
"upgradeAction": "Upgrade to Premium",
|
"upgradeAction": "Upgrade to Premium",
|
||||||
"kpiTitle": "Event snapshot",
|
|
||||||
"kpiUploads": "Uploads",
|
|
||||||
"kpiContributors": "Contributors",
|
|
||||||
"kpiLikes": "Likes",
|
|
||||||
"activityTitle": "Activity Timeline",
|
"activityTitle": "Activity Timeline",
|
||||||
"timeframe": "Last {{hours}} hours",
|
|
||||||
"timeframeHint": "Older activity hidden",
|
|
||||||
"uploadsPerHour": "Uploads per hour",
|
"uploadsPerHour": "Uploads per hour",
|
||||||
"noActivity": "No uploads yet",
|
"noActivity": "No uploads yet",
|
||||||
"emptyActionShareQr": "Share your QR code",
|
|
||||||
"contributorsTitle": "Top Contributors",
|
"contributorsTitle": "Top Contributors",
|
||||||
"likesCount": "{{count}} likes",
|
"likesCount": "{{count}} likes",
|
||||||
"likesCount_one": "{{count}} like",
|
"likesCount_one": "{{count}} like",
|
||||||
"likesCount_other": "{{count}} likes",
|
"likesCount_other": "{{count}} likes",
|
||||||
"noContributors": "No contributors yet",
|
"noContributors": "No contributors yet",
|
||||||
"emptyActionInvite": "Invite guests",
|
|
||||||
"tasksTitle": "Popular Tasks",
|
"tasksTitle": "Popular Tasks",
|
||||||
"noTasks": "No task activity yet",
|
"noTasks": "No task activity yet",
|
||||||
"emptyActionOpenTasks": "Open tasks",
|
|
||||||
"lockedTitle": "Unlock Analytics",
|
"lockedTitle": "Unlock Analytics",
|
||||||
"lockedBody": "Get deep insights into your event engagement with the Premium package."
|
"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.",
|
"subtitle": "Choose a package to unlock more features and limits.",
|
||||||
"recommendationTitle": "Recommended for you",
|
"recommendationTitle": "Recommended for you",
|
||||||
"recommendationBody": "The highlighted package includes the feature you requested.",
|
"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",
|
"select": "Select",
|
||||||
"manage": "Manage Plan",
|
"manage": "Manage Plan",
|
||||||
"limits": {
|
"limits": {
|
||||||
@@ -2962,13 +2910,7 @@
|
|||||||
},
|
},
|
||||||
"features": {
|
"features": {
|
||||||
"advanced_analytics": "Advanced Analytics",
|
"advanced_analytics": "Advanced Analytics",
|
||||||
"basic_uploads": "Basic uploads",
|
|
||||||
"custom_branding": "Custom Branding",
|
"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"
|
"watermark_removal": "No Watermark"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
@@ -2980,9 +2922,7 @@
|
|||||||
},
|
},
|
||||||
"badges": {
|
"badges": {
|
||||||
"recommended": "Recommended",
|
"recommended": "Recommended",
|
||||||
"active": "Active",
|
"active": "Active"
|
||||||
"upgrade": "Upgrade",
|
|
||||||
"downgrade": "Downgrade"
|
|
||||||
},
|
},
|
||||||
"confirmTitle": "Confirm Purchase",
|
"confirmTitle": "Confirm Purchase",
|
||||||
"confirmSubtitle": "You are upgrading to:",
|
"confirmSubtitle": "You are upgrading to:",
|
||||||
@@ -2995,7 +2935,6 @@
|
|||||||
"payNow": "Pay Now",
|
"payNow": "Pay Now",
|
||||||
"errors": {
|
"errors": {
|
||||||
"checkout": "Checkout failed"
|
"checkout": "Checkout failed"
|
||||||
},
|
}
|
||||||
"selectDisabled": "Not available"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import {
|
|||||||
createTenantBillingPortalSession,
|
createTenantBillingPortalSession,
|
||||||
getTenantPackagesOverview,
|
getTenantPackagesOverview,
|
||||||
getTenantPaddleTransactions,
|
getTenantPaddleTransactions,
|
||||||
getTenantPackageCheckoutStatus,
|
|
||||||
TenantPackageSummary,
|
TenantPackageSummary,
|
||||||
PaddleTransactionSummary,
|
PaddleTransactionSummary,
|
||||||
} from '../api';
|
} from '../api';
|
||||||
@@ -28,14 +27,6 @@ import {
|
|||||||
getPackageFeatureLabel,
|
getPackageFeatureLabel,
|
||||||
getPackageLimitEntries,
|
getPackageLimitEntries,
|
||||||
} from './lib/packageSummary';
|
} from './lib/packageSummary';
|
||||||
import {
|
|
||||||
PendingCheckout,
|
|
||||||
loadPendingCheckout,
|
|
||||||
shouldClearPendingCheckout,
|
|
||||||
storePendingCheckout,
|
|
||||||
} from './lib/billingCheckout';
|
|
||||||
|
|
||||||
const CHECKOUT_POLL_INTERVAL_MS = 10000;
|
|
||||||
|
|
||||||
export default function MobileBillingPage() {
|
export default function MobileBillingPage() {
|
||||||
const { t } = useTranslation('management');
|
const { t } = useTranslation('management');
|
||||||
@@ -49,11 +40,6 @@ export default function MobileBillingPage() {
|
|||||||
const [loading, setLoading] = React.useState(true);
|
const [loading, setLoading] = React.useState(true);
|
||||||
const [error, setError] = React.useState<string | null>(null);
|
const [error, setError] = React.useState<string | null>(null);
|
||||||
const [portalBusy, setPortalBusy] = React.useState(false);
|
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 packagesRef = React.useRef<HTMLDivElement | null>(null);
|
||||||
const invoicesRef = React.useRef<HTMLDivElement | null>(null);
|
const invoicesRef = React.useRef<HTMLDivElement | null>(null);
|
||||||
const supportEmail = 'support@fotospiel.de';
|
const supportEmail = 'support@fotospiel.de';
|
||||||
@@ -109,11 +95,6 @@ export default function MobileBillingPage() {
|
|||||||
}
|
}
|
||||||
}, [portalBusy, t]);
|
}, [portalBusy, t]);
|
||||||
|
|
||||||
const persistPendingCheckout = React.useCallback((next: PendingCheckout | null) => {
|
|
||||||
setPendingCheckout(next);
|
|
||||||
storePendingCheckout(next);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
void load();
|
void load();
|
||||||
}, [load]);
|
}, [load]);
|
||||||
@@ -127,115 +108,6 @@ export default function MobileBillingPage() {
|
|||||||
}
|
}
|
||||||
}, [location.hash, loading]);
|
}, [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 (
|
return (
|
||||||
<MobileShell
|
<MobileShell
|
||||||
activeTab="profile"
|
activeTab="profile"
|
||||||
@@ -255,109 +127,6 @@ export default function MobileBillingPage() {
|
|||||||
<CTAButton label={t('billing.actions.refresh', 'Refresh')} tone="ghost" onPress={load} />
|
<CTAButton label={t('billing.actions.refresh', 'Refresh')} tone="ghost" onPress={load} />
|
||||||
</MobileCard>
|
</MobileCard>
|
||||||
) : null}
|
) : 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}>
|
<MobileCard space="$2" ref={packagesRef as any}>
|
||||||
<XStack alignItems="center" space="$2">
|
<XStack alignItems="center" space="$2">
|
||||||
|
|||||||
@@ -2,18 +2,17 @@ import React from 'react';
|
|||||||
import { useParams, useNavigate } from 'react-router-dom';
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
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 { YStack, XStack } from '@tamagui/stacks';
|
||||||
import { SizableText as Text } from '@tamagui/text';
|
import { SizableText as Text } from '@tamagui/text';
|
||||||
import { format, parseISO } from 'date-fns';
|
import { format, parseISO } from 'date-fns';
|
||||||
import { de, enGB } from 'date-fns/locale';
|
import { de, enGB } from 'date-fns/locale';
|
||||||
|
|
||||||
import { MobileShell } from './components/MobileShell';
|
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 { getEventAnalytics, EventAnalytics } from '../api';
|
||||||
import { ApiError } from '../lib/apiError';
|
import { ApiError } from '../lib/apiError';
|
||||||
import { useAdminTheme } from './theme';
|
import { useAdminTheme } from './theme';
|
||||||
import { resolveMaxCount, resolveTimelineHours } from './lib/analytics';
|
|
||||||
import { adminPath } from '../constants';
|
import { adminPath } from '../constants';
|
||||||
|
|
||||||
export default function MobileEventAnalyticsPage() {
|
export default function MobileEventAnalyticsPage() {
|
||||||
@@ -98,17 +97,9 @@ export default function MobileEventAnalyticsPage() {
|
|||||||
const hasTimeline = timeline.length > 0;
|
const hasTimeline = timeline.length > 0;
|
||||||
const hasContributors = contributors.length > 0;
|
const hasContributors = contributors.length > 0;
|
||||||
const hasTasks = tasks.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
|
// Prepare chart data
|
||||||
const maxTimelineCount = resolveMaxCount(timeline.map((point) => point.count));
|
const maxCount = Math.max(...timeline.map((p) => p.count), 1);
|
||||||
const maxTaskCount = resolveMaxCount(tasks.map((task) => task.count));
|
|
||||||
const totalUploads = timeline.reduce((total, point) => total + point.count, 0);
|
|
||||||
const totalLikes = contributors.reduce((total, contributor) => total + contributor.likes, 0);
|
|
||||||
const totalContributors = contributors.length;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MobileShell
|
<MobileShell
|
||||||
@@ -117,28 +108,6 @@ export default function MobileEventAnalyticsPage() {
|
|||||||
onBack={() => navigate(-1)}
|
onBack={() => navigate(-1)}
|
||||||
>
|
>
|
||||||
<YStack space="$4">
|
<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 */}
|
{/* Activity Timeline */}
|
||||||
<MobileCard space="$3" borderColor={border} backgroundColor={surface}>
|
<MobileCard space="$3" borderColor={border} backgroundColor={surface}>
|
||||||
<XStack alignItems="center" space="$2">
|
<XStack alignItems="center" space="$2">
|
||||||
@@ -147,22 +116,12 @@ export default function MobileEventAnalyticsPage() {
|
|||||||
{t('analytics.activityTitle', 'Activity Timeline')}
|
{t('analytics.activityTitle', 'Activity Timeline')}
|
||||||
</Text>
|
</Text>
|
||||||
</XStack>
|
</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 ? (
|
{hasTimeline ? (
|
||||||
<YStack height={180} justifyContent="flex-end" space="$2">
|
<YStack height={180} justifyContent="flex-end" space="$2">
|
||||||
<XStack alignItems="flex-end" justifyContent="space-between" height={150} gap="$1">
|
<XStack alignItems="flex-end" justifyContent="space-between" height={150} gap="$1">
|
||||||
{timeline.map((point, index) => {
|
{timeline.map((point, index) => {
|
||||||
const heightPercent = (point.count / maxTimelineCount) * 100;
|
const heightPercent = (point.count / maxCount) * 100;
|
||||||
const date = parseISO(point.timestamp);
|
const date = parseISO(point.timestamp);
|
||||||
// Show label every 3rd point or if few points
|
// Show label every 3rd point or if few points
|
||||||
const showLabel = timeline.length < 8 || index % 3 === 0;
|
const showLabel = timeline.length < 8 || index % 3 === 0;
|
||||||
@@ -179,7 +138,7 @@ export default function MobileEventAnalyticsPage() {
|
|||||||
/>
|
/>
|
||||||
{showLabel && (
|
{showLabel && (
|
||||||
<Text fontSize={10} color={muted} numberOfLines={1}>
|
<Text fontSize={10} color={muted} numberOfLines={1}>
|
||||||
{format(date, 'HH:mm', { locale: dateLocale })}
|
{format(date, 'HH:mm')}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</YStack>
|
</YStack>
|
||||||
@@ -191,11 +150,7 @@ export default function MobileEventAnalyticsPage() {
|
|||||||
</Text>
|
</Text>
|
||||||
</YStack>
|
</YStack>
|
||||||
) : (
|
) : (
|
||||||
<EmptyState
|
<EmptyState message={t('analytics.noActivity', 'No uploads yet')} />
|
||||||
message={t('analytics.noActivity', 'No uploads yet')}
|
|
||||||
actionLabel={t('analytics.emptyActionShareQr', 'Share your QR code')}
|
|
||||||
onAction={() => slug && navigate(adminPath(`/mobile/events/${slug}/qr`))}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</MobileCard>
|
</MobileCard>
|
||||||
|
|
||||||
@@ -241,11 +196,7 @@ export default function MobileEventAnalyticsPage() {
|
|||||||
))}
|
))}
|
||||||
</YStack>
|
</YStack>
|
||||||
) : (
|
) : (
|
||||||
<EmptyState
|
<EmptyState message={t('analytics.noContributors', 'No contributors yet')} />
|
||||||
message={t('analytics.noContributors', 'No contributors yet')}
|
|
||||||
actionLabel={t('analytics.emptyActionInvite', 'Invite guests')}
|
|
||||||
onAction={() => slug && navigate(adminPath(`/mobile/events/${slug}/members`))}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</MobileCard>
|
</MobileCard>
|
||||||
|
|
||||||
@@ -261,6 +212,7 @@ export default function MobileEventAnalyticsPage() {
|
|||||||
{hasTasks ? (
|
{hasTasks ? (
|
||||||
<YStack space="$3">
|
<YStack space="$3">
|
||||||
{tasks.map((task) => {
|
{tasks.map((task) => {
|
||||||
|
const maxTaskCount = Math.max(...tasks.map(t => t.count), 1);
|
||||||
const percent = (task.count / maxTaskCount) * 100;
|
const percent = (task.count / maxTaskCount) * 100;
|
||||||
return (
|
return (
|
||||||
<YStack key={task.task_id} space="$1">
|
<YStack key={task.task_id} space="$1">
|
||||||
@@ -285,11 +237,7 @@ export default function MobileEventAnalyticsPage() {
|
|||||||
})}
|
})}
|
||||||
</YStack>
|
</YStack>
|
||||||
) : (
|
) : (
|
||||||
<EmptyState
|
<EmptyState message={t('analytics.noTasks', 'No task activity yet')} />
|
||||||
message={t('analytics.noTasks', 'No task activity yet')}
|
|
||||||
actionLabel={t('analytics.emptyActionOpenTasks', 'Open tasks')}
|
|
||||||
onAction={() => slug && navigate(adminPath(`/mobile/events/${slug}/tasks`))}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</MobileCard>
|
</MobileCard>
|
||||||
</YStack>
|
</YStack>
|
||||||
@@ -297,24 +245,13 @@ export default function MobileEventAnalyticsPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function EmptyState({
|
function EmptyState({ message }: { message: string }) {
|
||||||
message,
|
|
||||||
actionLabel,
|
|
||||||
onAction,
|
|
||||||
}: {
|
|
||||||
message: string;
|
|
||||||
actionLabel?: string;
|
|
||||||
onAction?: () => void;
|
|
||||||
}) {
|
|
||||||
const { muted } = useAdminTheme();
|
const { muted } = useAdminTheme();
|
||||||
return (
|
return (
|
||||||
<YStack padding="$4" alignItems="center" justifyContent="center" space="$2">
|
<YStack padding="$4" alignItems="center" justifyContent="center">
|
||||||
<Text fontSize="$sm" color={muted}>
|
<Text fontSize="$sm" color={muted}>
|
||||||
{message}
|
{message}
|
||||||
</Text>
|
</Text>
|
||||||
{actionLabel && onAction ? (
|
|
||||||
<CTAButton label={actionLabel} tone="ghost" fullWidth={false} onPress={onAction} />
|
|
||||||
) : null}
|
|
||||||
</YStack>
|
</YStack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,31 +1,25 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useNavigate, useLocation } from 'react-router-dom';
|
import { useNavigate, useLocation } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
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 { YStack, XStack } from '@tamagui/stacks';
|
||||||
import { SizableText as Text } from '@tamagui/text';
|
import { SizableText as Text } from '@tamagui/text';
|
||||||
import { Checkbox } from '@tamagui/checkbox';
|
import { Checkbox } from '@tamagui/checkbox';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
import { MobileShell } from './components/MobileShell';
|
import { MobileShell } from './components/MobileShell';
|
||||||
import { MobileCard, CTAButton, PillBadge, SkeletonCard } from './components/Primitives';
|
import { MobileCard, CTAButton, PillBadge, SkeletonCard } from './components/Primitives';
|
||||||
import { useAdminTheme } from './theme';
|
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 { useQuery } from '@tanstack/react-query';
|
||||||
import {
|
|
||||||
buildPackageComparisonRows,
|
|
||||||
classifyPackageChange,
|
|
||||||
getEnabledPackageFeatures,
|
|
||||||
selectRecommendedPackageId,
|
|
||||||
} from './lib/packageShop';
|
|
||||||
import { usePackageCheckout } from './hooks/usePackageCheckout';
|
|
||||||
|
|
||||||
export default function MobilePackageShopPage() {
|
export default function MobilePackageShopPage() {
|
||||||
const { t } = useTranslation('management');
|
const { t } = useTranslation('management');
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
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 [selectedPackage, setSelectedPackage] = React.useState<Package | null>(null);
|
||||||
const [viewMode, setViewMode] = React.useState<'cards' | 'compare'>('cards');
|
|
||||||
|
|
||||||
// Extract recommended feature from URL
|
// Extract recommended feature from URL
|
||||||
const searchParams = new URLSearchParams(location.search);
|
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
|
// Merge and sort packages
|
||||||
const sortedPackages = [...(catalog || [])].sort((a, b) => {
|
const sortedPackages = [...(catalog || [])].sort((a, b) => {
|
||||||
if (recommendedPackageId) {
|
// 1. Recommended feature first
|
||||||
if (a.id === recommendedPackageId && b.id !== recommendedPackageId) return -1;
|
const aHasFeature = recommendedFeature && a.features?.[recommendedFeature];
|
||||||
if (b.id === recommendedPackageId && a.id !== recommendedPackageId) return 1;
|
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;
|
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 (
|
return (
|
||||||
<MobileShell title={t('shop.title', 'Upgrade Package')} onBack={() => navigate(-1)} activeTab="profile">
|
<MobileShell title={t('shop.title', 'Upgrade Package')} onBack={() => navigate(-1)} activeTab="profile">
|
||||||
<YStack space="$4">
|
<YStack space="$4">
|
||||||
@@ -116,45 +93,23 @@ export default function MobilePackageShopPage() {
|
|||||||
</Text>
|
</Text>
|
||||||
</YStack>
|
</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">
|
<YStack space="$3">
|
||||||
{viewMode === 'compare' ? (
|
{sortedPackages.map((pkg) => {
|
||||||
<PackageShopCompareView
|
const owned = inventory?.packages?.find(p => p.package_id === pkg.id);
|
||||||
entries={packageEntries}
|
const isActive = inventory?.activePackage?.package_id === pkg.id;
|
||||||
onSelect={(pkg) => setSelectedPackage(pkg)}
|
const isRecommended = recommendedFeature && pkg.features?.[recommendedFeature];
|
||||||
/>
|
|
||||||
) : (
|
return (
|
||||||
packageEntries.map((entry) => (
|
|
||||||
<PackageShopCard
|
<PackageShopCard
|
||||||
key={entry.pkg.id}
|
key={pkg.id}
|
||||||
pkg={entry.pkg}
|
pkg={pkg}
|
||||||
owned={entry.owned}
|
owned={owned}
|
||||||
isActive={entry.isActive}
|
isActive={isActive}
|
||||||
isRecommended={entry.isRecommended}
|
isRecommended={isRecommended}
|
||||||
isUpgrade={entry.isUpgrade}
|
onSelect={() => setSelectedPackage(pkg)}
|
||||||
isDowngrade={entry.isDowngrade}
|
|
||||||
onSelect={() => setSelectedPackage(entry.pkg)}
|
|
||||||
/>
|
/>
|
||||||
))
|
);
|
||||||
)}
|
})}
|
||||||
</YStack>
|
</YStack>
|
||||||
</YStack>
|
</YStack>
|
||||||
</MobileShell>
|
</MobileShell>
|
||||||
@@ -166,34 +121,34 @@ function PackageShopCard({
|
|||||||
owned,
|
owned,
|
||||||
isActive,
|
isActive,
|
||||||
isRecommended,
|
isRecommended,
|
||||||
isUpgrade,
|
|
||||||
isDowngrade,
|
|
||||||
onSelect
|
onSelect
|
||||||
}: {
|
}: {
|
||||||
pkg: Package;
|
pkg: Package;
|
||||||
owned?: TenantPackageSummary;
|
owned?: TenantPackageSummary;
|
||||||
isActive?: boolean;
|
isActive?: boolean;
|
||||||
isRecommended?: any;
|
isRecommended?: any;
|
||||||
isUpgrade?: boolean;
|
|
||||||
isDowngrade?: boolean;
|
|
||||||
onSelect: () => void
|
onSelect: () => void
|
||||||
}) {
|
}) {
|
||||||
const { textStrong, muted, border, primary, accentSoft } = useAdminTheme();
|
const { textStrong, muted, border, primary, accentSoft } = useAdminTheme();
|
||||||
const { t } = useTranslation('management');
|
const { t } = useTranslation('management');
|
||||||
|
|
||||||
const statusLabel = getPackageStatusLabel({ t, isActive, owned });
|
const hasRemainingEvents = owned && (owned.remaining_events === null || owned.remaining_events > 0);
|
||||||
const isSubdued = Boolean((isDowngrade || !isUpgrade) && !isActive);
|
const statusLabel = isActive
|
||||||
const canSelect = canSelectPackage(isUpgrade, 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 (
|
return (
|
||||||
<MobileCard
|
<MobileCard
|
||||||
onPress={canSelect ? onSelect : undefined}
|
onPress={onSelect}
|
||||||
borderColor={isRecommended ? primary : (isActive ? '$green8' : border)}
|
borderColor={isRecommended ? primary : (isActive ? '$green8' : border)}
|
||||||
borderWidth={isRecommended || isActive ? 2 : 1}
|
borderWidth={isRecommended || isActive ? 2 : 1}
|
||||||
space="$3"
|
space="$3"
|
||||||
pressStyle={canSelect ? { backgroundColor: accentSoft } : undefined}
|
pressStyle={{ backgroundColor: accentSoft }}
|
||||||
backgroundColor={isActive ? '$green1' : undefined}
|
backgroundColor={isActive ? '$green1' : undefined}
|
||||||
style={{ opacity: isSubdued ? 0.6 : 1 }}
|
|
||||||
>
|
>
|
||||||
<XStack justifyContent="space-between" alignItems="flex-start">
|
<XStack justifyContent="space-between" alignItems="flex-start">
|
||||||
<YStack space="$1">
|
<YStack space="$1">
|
||||||
@@ -202,8 +157,6 @@ function PackageShopCard({
|
|||||||
{pkg.name}
|
{pkg.name}
|
||||||
</Text>
|
</Text>
|
||||||
{isRecommended && <PillBadge tone="warning">{t('shop.badges.recommended', 'Recommended')}</PillBadge>}
|
{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>}
|
{isActive && <PillBadge tone="success">{t('shop.badges.active', 'Active')}</PillBadge>}
|
||||||
</XStack>
|
</XStack>
|
||||||
|
|
||||||
@@ -234,25 +187,19 @@ function PackageShopCard({
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{/* Render specific feature if it was requested */}
|
{/* Render specific feature if it was requested */}
|
||||||
{getEnabledPackageFeatures(pkg)
|
{Object.entries(pkg.features || {})
|
||||||
.filter((key) => !pkg.max_photos || key !== 'photos')
|
.filter(([key, val]) => val === true && (!pkg.max_photos || key !== 'photos'))
|
||||||
.slice(0, 3)
|
.slice(0, 3)
|
||||||
.map((key) => (
|
.map(([key]) => (
|
||||||
<FeatureRow key={key} label={t(`shop.features.${key}`, key)} />
|
<FeatureRow key={key} label={t(`shop.features.${key}`, key)} />
|
||||||
))}
|
))
|
||||||
|
}
|
||||||
</YStack>
|
</YStack>
|
||||||
|
|
||||||
<CTAButton
|
<CTAButton
|
||||||
label={
|
label={isActive ? t('shop.manage', 'Manage Plan') : t('shop.select', 'Select')}
|
||||||
isActive
|
onPress={onSelect}
|
||||||
? t('shop.manage', 'Manage Plan')
|
tone={isActive ? 'ghost' : 'primary'}
|
||||||
: isUpgrade
|
|
||||||
? t('shop.select', 'Select')
|
|
||||||
: t('shop.selectDisabled', 'Not available')
|
|
||||||
}
|
|
||||||
onPress={canSelect ? onSelect : undefined}
|
|
||||||
tone={isActive || !isUpgrade ? 'ghost' : 'primary'}
|
|
||||||
disabled={!canSelect}
|
|
||||||
/>
|
/>
|
||||||
</MobileCard>
|
</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 }) {
|
function CheckoutConfirmation({ pkg, onCancel }: { pkg: Package; onCancel: () => void }) {
|
||||||
const { t } = useTranslation('management');
|
const { t } = useTranslation('management');
|
||||||
const { textStrong, muted, border, primary } = useAdminTheme();
|
const { textStrong, muted, border, primary, danger } = useAdminTheme();
|
||||||
const [agbAccepted, setAgbAccepted] = React.useState(false);
|
const [agbAccepted, setAgbAccepted] = React.useState(false);
|
||||||
const [withdrawalAccepted, setWithdrawalAccepted] = React.useState(false);
|
const [withdrawalAccepted, setWithdrawalAccepted] = React.useState(false);
|
||||||
const { busy, startCheckout } = usePackageCheckout();
|
const [busy, setBusy] = React.useState(false);
|
||||||
|
|
||||||
const canProceed = agbAccepted && withdrawalAccepted;
|
const canProceed = agbAccepted && withdrawalAccepted;
|
||||||
|
|
||||||
const handleCheckout = async () => {
|
const handleCheckout = async () => {
|
||||||
if (!canProceed || busy) return;
|
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 (
|
return (
|
||||||
|
|||||||
@@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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']);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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 };
|
|
||||||
}
|
|
||||||
@@ -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));
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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];
|
|
||||||
}
|
|
||||||
@@ -15,8 +15,7 @@ const t = (key: string, options?: Record<string, unknown> | string) => {
|
|||||||
return template
|
return template
|
||||||
.replace('{{used}}', String(options?.used ?? '{{used}}'))
|
.replace('{{used}}', String(options?.used ?? '{{used}}'))
|
||||||
.replace('{{limit}}', String(options?.limit ?? '{{limit}}'))
|
.replace('{{limit}}', String(options?.limit ?? '{{limit}}'))
|
||||||
.replace('{{remaining}}', String(options?.remaining ?? '{{remaining}}'))
|
.replace('{{remaining}}', String(options?.remaining ?? '{{remaining}}'));
|
||||||
.replace('{{count}}', String(options?.count ?? '{{count}}'));
|
|
||||||
};
|
};
|
||||||
|
|
||||||
describe('packageSummary helpers', () => {
|
describe('packageSummary helpers', () => {
|
||||||
@@ -54,12 +53,6 @@ describe('packageSummary helpers', () => {
|
|||||||
expect(result[0].value).toBe('30 of 120 remaining');
|
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', () => {
|
it('formats event usage copy', () => {
|
||||||
const result = formatEventUsage(3, 10, t);
|
const result = formatEventUsage(3, 10, t);
|
||||||
|
|
||||||
|
|||||||
@@ -138,12 +138,6 @@ const formatLimitWithRemaining = (limit: number | null, remaining: number | null
|
|||||||
|
|
||||||
if (remaining !== null && remaining >= 0) {
|
if (remaining !== null && remaining >= 0) {
|
||||||
const normalizedRemaining = Number.isFinite(remaining) ? Math.max(0, Math.round(remaining)) : remaining;
|
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', {
|
return t('mobileBilling.usage.remainingOf', {
|
||||||
remaining: normalizedRemaining,
|
remaining: normalizedRemaining,
|
||||||
limit,
|
limit,
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import { SettingsSheet } from './settings-sheet';
|
|||||||
import { useTranslation, type TranslateFn } from '../i18n/useTranslation';
|
import { useTranslation, type TranslateFn } from '../i18n/useTranslation';
|
||||||
import { DEFAULT_EVENT_BRANDING, useOptionalEventBranding } from '../context/EventBrandingContext';
|
import { DEFAULT_EVENT_BRANDING, useOptionalEventBranding } from '../context/EventBrandingContext';
|
||||||
import { useOptionalNotificationCenter, type NotificationCenterValue } from '../context/NotificationCenterContext';
|
import { useOptionalNotificationCenter, type NotificationCenterValue } from '../context/NotificationCenterContext';
|
||||||
|
import { useGuestTaskProgress, TASK_BADGE_TARGET } from '../hooks/useGuestTaskProgress';
|
||||||
import { usePushSubscription } from '../hooks/usePushSubscription';
|
import { usePushSubscription } from '../hooks/usePushSubscription';
|
||||||
import { getContrastingTextColor, relativeLuminance } from '../lib/color';
|
import { getContrastingTextColor, relativeLuminance } from '../lib/color';
|
||||||
import { isTaskModeEnabled } from '../lib/engagement';
|
import { isTaskModeEnabled } from '../lib/engagement';
|
||||||
@@ -150,6 +151,7 @@ export default function Header({ eventToken, title = '' }: { eventToken?: string
|
|||||||
const { event, status } = useEventData();
|
const { event, status } = useEventData();
|
||||||
const notificationCenter = useOptionalNotificationCenter();
|
const notificationCenter = useOptionalNotificationCenter();
|
||||||
const [notificationsOpen, setNotificationsOpen] = React.useState(false);
|
const [notificationsOpen, setNotificationsOpen] = React.useState(false);
|
||||||
|
const taskProgress = useGuestTaskProgress(eventToken);
|
||||||
const tasksEnabled = isTaskModeEnabled(event);
|
const tasksEnabled = isTaskModeEnabled(event);
|
||||||
const panelRef = React.useRef<HTMLDivElement | null>(null);
|
const panelRef = React.useRef<HTMLDivElement | null>(null);
|
||||||
const notificationButtonRef = React.useRef<HTMLButtonElement | 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)}
|
onToggle={() => setNotificationsOpen((prev) => !prev)}
|
||||||
panelRef={panelRef}
|
panelRef={panelRef}
|
||||||
buttonRef={notificationButtonRef}
|
buttonRef={notificationButtonRef}
|
||||||
|
taskProgress={tasksEnabled && taskProgress?.hydrated ? taskProgress : undefined}
|
||||||
t={t}
|
t={t}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -282,14 +285,18 @@ type NotificationButtonProps = {
|
|||||||
onToggle: () => void;
|
onToggle: () => void;
|
||||||
panelRef: React.RefObject<HTMLDivElement | null>;
|
panelRef: React.RefObject<HTMLDivElement | null>;
|
||||||
buttonRef: React.RefObject<HTMLButtonElement | null>;
|
buttonRef: React.RefObject<HTMLButtonElement | null>;
|
||||||
|
taskProgress?: ReturnType<typeof useGuestTaskProgress>;
|
||||||
t: TranslateFn;
|
t: TranslateFn;
|
||||||
};
|
};
|
||||||
|
|
||||||
type PushState = ReturnType<typeof usePushSubscription>;
|
type PushState = ReturnType<typeof usePushSubscription>;
|
||||||
|
|
||||||
function NotificationButton({ center, eventToken, open, onToggle, panelRef, buttonRef, t }: NotificationButtonProps) {
|
function NotificationButton({ center, eventToken, open, onToggle, panelRef, buttonRef, taskProgress, t }: NotificationButtonProps) {
|
||||||
const badgeCount = center.unreadCount;
|
const badgeCount = center.unreadCount + center.pendingCount + center.queueCount;
|
||||||
const [activeTab, setActiveTab] = React.useState<'unread' | 'all' | 'uploads'>(center.unreadCount > 0 ? 'unread' : 'all');
|
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 [scopeFilter, setScopeFilter] = React.useState<'all' | 'tips' | 'general'>('all');
|
||||||
const pushState = usePushSubscription(eventToken);
|
const pushState = usePushSubscription(eventToken);
|
||||||
|
|
||||||
@@ -314,7 +321,7 @@ function NotificationButton({ center, eventToken, open, onToggle, panelRef, butt
|
|||||||
case 'unread':
|
case 'unread':
|
||||||
base = unreadNotifications;
|
base = unreadNotifications;
|
||||||
break;
|
break;
|
||||||
case 'uploads':
|
case 'status':
|
||||||
base = uploadNotifications;
|
base = uploadNotifications;
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
@@ -324,7 +331,7 @@ function NotificationButton({ center, eventToken, open, onToggle, panelRef, butt
|
|||||||
}, [activeTab, center.notifications, unreadNotifications, uploadNotifications]);
|
}, [activeTab, center.notifications, unreadNotifications, uploadNotifications]);
|
||||||
|
|
||||||
const scopedNotifications = React.useMemo(() => {
|
const scopedNotifications = React.useMemo(() => {
|
||||||
if (activeTab === 'uploads' || scopeFilter === 'all') {
|
if (scopeFilter === 'all') {
|
||||||
return filteredNotifications;
|
return filteredNotifications;
|
||||||
}
|
}
|
||||||
return filteredNotifications.filter((item) => {
|
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 className="flex items-start justify-between gap-3">
|
||||||
<div>
|
<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">
|
<p className="text-xs text-slate-500">
|
||||||
{center.unreadCount > 0
|
{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')}
|
: t('header.notifications.allRead', 'Alles gelesen')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -377,14 +384,13 @@ function NotificationButton({ center, eventToken, open, onToggle, panelRef, butt
|
|||||||
</div>
|
</div>
|
||||||
<NotificationTabs
|
<NotificationTabs
|
||||||
tabs={[
|
tabs={[
|
||||||
{ key: 'unread', label: t('header.notifications.tabUnread', 'Nachrichten'), badge: unreadNotifications.length },
|
{ key: 'unread', label: t('header.notifications.tabUnread', 'Neu'), badge: unreadNotifications.length },
|
||||||
{ key: 'uploads', label: t('header.notifications.tabUploads', 'Uploads'), badge: uploadNotifications.length },
|
{ key: 'status', label: t('header.notifications.tabStatus', 'Uploads/Status'), badge: uploadNotifications.length },
|
||||||
{ key: 'all', label: t('header.notifications.tabAll', 'Alle Updates'), badge: center.notifications.length },
|
{ key: 'all', label: t('header.notifications.tabAll', 'Alle'), badge: center.notifications.length },
|
||||||
]}
|
]}
|
||||||
activeTab={activeTab}
|
activeTab={activeTab}
|
||||||
onTabChange={(next) => setActiveTab(next as typeof activeTab)}
|
onTabChange={(next) => setActiveTab(next as typeof activeTab)}
|
||||||
/>
|
/>
|
||||||
{activeTab !== 'uploads' && (
|
|
||||||
<div className="mt-3">
|
<div className="mt-3">
|
||||||
<div className="flex gap-2 overflow-x-auto text-xs whitespace-nowrap pb-1">
|
<div className="flex gap-2 overflow-x-auto text-xs whitespace-nowrap pb-1">
|
||||||
{(
|
{(
|
||||||
@@ -412,8 +418,33 @@ function NotificationButton({ center, eventToken, open, onToggle, panelRef, butt
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="mt-3 max-h-80 space-y-2 overflow-y-auto pr-1">
|
||||||
|
{center.loading ? (
|
||||||
|
<NotificationSkeleton />
|
||||||
|
) : scopedNotifications.length === 0 ? (
|
||||||
|
<NotificationEmptyState
|
||||||
|
t={t}
|
||||||
|
message={
|
||||||
|
activeTab === 'unread'
|
||||||
|
? t('header.notifications.emptyUnread', 'Du bist auf dem neuesten Stand!')
|
||||||
|
: activeTab === 'status'
|
||||||
|
? t('header.notifications.emptyStatus', 'Keine Upload-Hinweise oder Wartungen aktiv.')
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
scopedNotifications.map((item) => (
|
||||||
|
<NotificationListItem
|
||||||
|
key={item.id}
|
||||||
|
item={item}
|
||||||
|
onMarkRead={() => center.markAsRead(item.id)}
|
||||||
|
onDismiss={() => center.dismiss(item.id)}
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
))
|
||||||
)}
|
)}
|
||||||
{activeTab === 'uploads' && (center.pendingCount > 0 || center.queueCount > 0) && (
|
</div>
|
||||||
|
{activeTab === 'status' && (
|
||||||
<div className="mt-3 space-y-2">
|
<div className="mt-3 space-y-2">
|
||||||
{center.pendingCount > 0 && (
|
{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">
|
<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>
|
||||||
)}
|
)}
|
||||||
<div className="mt-3 max-h-80 space-y-2 overflow-y-auto pr-1">
|
{taskProgress && (
|
||||||
{center.loading ? (
|
<div className="mt-3 rounded-2xl border border-slate-200 bg-slate-50/90 p-3">
|
||||||
<NotificationSkeleton />
|
<div className="flex items-center justify-between">
|
||||||
) : scopedNotifications.length === 0 ? (
|
<div>
|
||||||
<NotificationEmptyState
|
<p className="text-xs uppercase tracking-[0.3em] text-slate-400">{t('header.notifications.badgeLabel', 'Badge-Fortschritt')}</p>
|
||||||
t={t}
|
<p className="text-lg font-semibold text-slate-900">
|
||||||
message={
|
{taskProgress.completedCount}/{TASK_BADGE_TARGET}
|
||||||
activeTab === 'unread'
|
</p>
|
||||||
? 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}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
</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>
|
||||||
|
)}
|
||||||
<NotificationStatusBar
|
<NotificationStatusBar
|
||||||
lastFetchedAt={center.lastFetchedAt}
|
lastFetchedAt={center.lastFetchedAt}
|
||||||
isOffline={center.isOffline}
|
isOffline={center.isOffline}
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ vi.mock('../../context/NotificationCenterContext', () => ({
|
|||||||
queueItems: [],
|
queueItems: [],
|
||||||
queueCount: 0,
|
queueCount: 0,
|
||||||
pendingCount: 0,
|
pendingCount: 0,
|
||||||
|
totalCount: 0,
|
||||||
loading: false,
|
loading: false,
|
||||||
pendingLoading: false,
|
pendingLoading: false,
|
||||||
refresh: vi.fn(),
|
refresh: vi.fn(),
|
||||||
@@ -96,10 +97,10 @@ describe('Header notifications toggle', () => {
|
|||||||
const bellButton = screen.getByLabelText('Benachrichtigungen anzeigen');
|
const bellButton = screen.getByLabelText('Benachrichtigungen anzeigen');
|
||||||
fireEvent.click(bellButton);
|
fireEvent.click(bellButton);
|
||||||
|
|
||||||
expect(screen.getByText('Updates')).toBeInTheDocument();
|
expect(screen.getByText('Benachrichtigungen')).toBeInTheDocument();
|
||||||
|
|
||||||
fireEvent.click(bellButton);
|
fireEvent.click(bellButton);
|
||||||
|
|
||||||
expect(screen.queryByText('Updates')).not.toBeInTheDocument();
|
expect(screen.queryByText('Benachrichtigungen')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export type NotificationCenterValue = {
|
|||||||
queueItems: QueueItem[];
|
queueItems: QueueItem[];
|
||||||
queueCount: number;
|
queueCount: number;
|
||||||
pendingCount: number;
|
pendingCount: number;
|
||||||
|
totalCount: number;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
pendingLoading: boolean;
|
pendingLoading: boolean;
|
||||||
refresh: () => Promise<void>;
|
refresh: () => Promise<void>;
|
||||||
@@ -263,9 +264,11 @@ export function NotificationCenterProvider({ eventToken, children }: { eventToke
|
|||||||
}, [loadNotifications, refreshQueue, loadPendingUploads]);
|
}, [loadNotifications, refreshQueue, loadPendingUploads]);
|
||||||
|
|
||||||
const loading = loadingNotifications || queueLoading || pendingLoading;
|
const loading = loadingNotifications || queueLoading || pendingLoading;
|
||||||
|
const totalCount = unreadCount + queueCount + pendingCount;
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
void updateAppBadge(unreadCount);
|
void updateAppBadge(totalCount);
|
||||||
}, [unreadCount]);
|
}, [totalCount]);
|
||||||
|
|
||||||
const value: NotificationCenterValue = {
|
const value: NotificationCenterValue = {
|
||||||
notifications,
|
notifications,
|
||||||
@@ -273,6 +276,7 @@ export function NotificationCenterProvider({ eventToken, children }: { eventToke
|
|||||||
queueItems: items,
|
queueItems: items,
|
||||||
queueCount,
|
queueCount,
|
||||||
pendingCount,
|
pendingCount,
|
||||||
|
totalCount,
|
||||||
loading,
|
loading,
|
||||||
pendingLoading,
|
pendingLoading,
|
||||||
refresh,
|
refresh,
|
||||||
|
|||||||
@@ -42,13 +42,7 @@ export const messages: Record<LocaleCode, NestedMessages> = {
|
|||||||
},
|
},
|
||||||
helpGallery: 'Hilfe zu Galerie & Teilen',
|
helpGallery: 'Hilfe zu Galerie & Teilen',
|
||||||
notifications: {
|
notifications: {
|
||||||
title: 'Updates',
|
tabStatus: 'Upload-Status',
|
||||||
unread: '{count} neu',
|
|
||||||
allRead: 'Alles gelesen',
|
|
||||||
tabUnread: 'Nachrichten',
|
|
||||||
tabUploads: 'Uploads',
|
|
||||||
tabAll: 'Alle Updates',
|
|
||||||
emptyStatus: 'Keine Upload-Hinweise oder Wartungen aktiv.',
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
liveShowPlayer: {
|
liveShowPlayer: {
|
||||||
@@ -780,13 +774,7 @@ export const messages: Record<LocaleCode, NestedMessages> = {
|
|||||||
},
|
},
|
||||||
helpGallery: 'Help: Gallery & sharing',
|
helpGallery: 'Help: Gallery & sharing',
|
||||||
notifications: {
|
notifications: {
|
||||||
title: 'Updates',
|
tabStatus: 'Upload status',
|
||||||
unread: '{count} new',
|
|
||||||
allRead: 'All read',
|
|
||||||
tabUnread: 'Messages',
|
|
||||||
tabUploads: 'Uploads',
|
|
||||||
tabAll: 'All updates',
|
|
||||||
emptyStatus: 'No upload status or maintenance active.',
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
liveShowPlayer: {
|
liveShowPlayer: {
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ use App\Http\Controllers\Api\LegalController;
|
|||||||
use App\Http\Controllers\Api\LiveShowController;
|
use App\Http\Controllers\Api\LiveShowController;
|
||||||
use App\Http\Controllers\Api\Marketing\CouponPreviewController;
|
use App\Http\Controllers\Api\Marketing\CouponPreviewController;
|
||||||
use App\Http\Controllers\Api\PackageController;
|
use App\Http\Controllers\Api\PackageController;
|
||||||
use App\Http\Controllers\Api\PhotoboothConnectController;
|
|
||||||
use App\Http\Controllers\Api\SparkboothUploadController;
|
use App\Http\Controllers\Api\SparkboothUploadController;
|
||||||
use App\Http\Controllers\Api\Tenant\AdminPushSubscriptionController;
|
use App\Http\Controllers\Api\Tenant\AdminPushSubscriptionController;
|
||||||
use App\Http\Controllers\Api\Tenant\DashboardController;
|
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\LiveShowPhotoController;
|
||||||
use App\Http\Controllers\Api\Tenant\NotificationLogController;
|
use App\Http\Controllers\Api\Tenant\NotificationLogController;
|
||||||
use App\Http\Controllers\Api\Tenant\OnboardingController;
|
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\PhotoboothController;
|
||||||
use App\Http\Controllers\Api\Tenant\PhotoController;
|
use App\Http\Controllers\Api\Tenant\PhotoController;
|
||||||
use App\Http\Controllers\Api\Tenant\ProfileController;
|
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'])
|
Route::post('/photobooth/sparkbooth/upload', [SparkboothUploadController::class, 'store'])
|
||||||
->name('photobooth.sparkbooth.upload');
|
->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'])
|
Route::get('/tenant/events/{event:slug}/photos/{photo}/{variant}/asset', [PhotoController::class, 'asset'])
|
||||||
->whereNumber('photo')
|
->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('/enable', [PhotoboothController::class, 'enable'])->name('tenant.events.photobooth.enable');
|
||||||
Route::post('/rotate', [PhotoboothController::class, 'rotate'])->name('tenant.events.photobooth.rotate');
|
Route::post('/rotate', [PhotoboothController::class, 'rotate'])->name('tenant.events.photobooth.rotate');
|
||||||
Route::post('/disable', [PhotoboothController::class, 'disable'])->name('tenant.events.photobooth.disable');
|
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'])
|
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('/complete', [PackageController::class, 'completePurchase'])->name('packages.complete');
|
||||||
Route::post('/free', [PackageController::class, 'assignFree'])->name('packages.free');
|
Route::post('/free', [PackageController::class, 'assignFree'])->name('packages.free');
|
||||||
Route::post('/paddle-checkout', [PackageController::class, 'createPaddleCheckout'])->name('packages.paddle-checkout');
|
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'])
|
Route::get('addons/catalog', [EventAddonCatalogController::class, 'index'])
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -29,10 +29,7 @@ class TenantPaddleCheckoutTest extends TenantTestCase
|
|||||||
return $tenant->is($this->tenant)
|
return $tenant->is($this->tenant)
|
||||||
&& $payloadPackage->is($package)
|
&& $payloadPackage->is($package)
|
||||||
&& array_key_exists('success_url', $payload)
|
&& array_key_exists('success_url', $payload)
|
||||||
&& array_key_exists('return_url', $payload)
|
&& array_key_exists('return_url', $payload);
|
||||||
&& array_key_exists('metadata', $payload)
|
|
||||||
&& is_array($payload['metadata'])
|
|
||||||
&& ! empty($payload['metadata']['checkout_session_id']);
|
|
||||||
})
|
})
|
||||||
->andReturn([
|
->andReturn([
|
||||||
'checkout_url' => 'https://checkout.paddle.test/checkout/123',
|
'checkout_url' => 'https://checkout.paddle.test/checkout/123',
|
||||||
@@ -45,8 +42,7 @@ class TenantPaddleCheckoutTest extends TenantTestCase
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
$response->assertOk()
|
$response->assertOk()
|
||||||
->assertJsonPath('checkout_url', 'https://checkout.paddle.test/checkout/123')
|
->assertJsonPath('checkout_url', 'https://checkout.paddle.test/checkout/123');
|
||||||
->assertJsonStructure(['checkout_session_id']);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_paddle_checkout_requires_paddle_price_id(): void
|
public function test_paddle_checkout_requires_paddle_price_id(): void
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user