Compare commits
24 Commits
main
...
2287e7f32c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2287e7f32c | ||
|
|
cceed361b7 | ||
|
|
02363792c8 | ||
|
|
e93a00f0fc | ||
|
|
c1be7dd1ef | ||
|
|
f01a0e823b | ||
|
|
915aede66e | ||
|
|
b854e3feaa | ||
|
|
4bcaef53f7 | ||
|
|
8f1d3a3eb6 | ||
|
|
ab2cf3e023 | ||
|
|
ce0ab269c9 | ||
|
|
dce24bb86a | ||
|
|
03bf178d61 | ||
|
|
8ebaf6c31d | ||
|
|
1b6dc63ec6 | ||
|
|
accc63f4a2 | ||
|
|
59e318e7b9 | ||
|
|
3de1d3deab | ||
|
|
e9afbeb028 | ||
|
|
3e2b63f71f | ||
|
|
cff014ede5 | ||
|
|
8c5d3b93d5 | ||
|
|
22cb7ed7ce |
10
.gitignore
vendored
10
.gitignore
vendored
@@ -23,11 +23,9 @@ Homestead.yaml
|
|||||||
npm-debug.log
|
npm-debug.log
|
||||||
yarn-error.log
|
yarn-error.log
|
||||||
/auth.json
|
/auth.json
|
||||||
/.fleet
|
|
||||||
/.idea
|
|
||||||
/.nova
|
|
||||||
/.vscode
|
/.vscode
|
||||||
/.zed
|
|
||||||
tools/git-askpass.ps1
|
|
||||||
podman-compose.dev.yml
|
|
||||||
test-results
|
test-results
|
||||||
|
GEMINI.md
|
||||||
|
.beads/.sync.lock
|
||||||
|
.beads/daemon-error
|
||||||
|
.beads/sync_base.jsonl
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -40,7 +40,7 @@ class Login extends BaseLogin implements HasForms
|
|||||||
}
|
}
|
||||||
|
|
||||||
// SuperAdmin-spezifisch: Prüfe auf SuperAdmin-Rolle, keine Tenant-Prüfung
|
// SuperAdmin-spezifisch: Prüfe auf SuperAdmin-Rolle, keine Tenant-Prüfung
|
||||||
if ($user->role !== 'super_admin') {
|
if (! $user->isSuperAdmin()) {
|
||||||
$authGuard->logout();
|
$authGuard->logout();
|
||||||
|
|
||||||
throw ValidationException::withMessages([
|
throw ValidationException::withMessages([
|
||||||
|
|||||||
@@ -45,11 +45,11 @@ class GuestPolicySettingsPage extends Page
|
|||||||
|
|
||||||
public int $join_token_failure_decay_minutes = 5;
|
public int $join_token_failure_decay_minutes = 5;
|
||||||
|
|
||||||
public int $join_token_access_limit = 120;
|
public int $join_token_access_limit = 300;
|
||||||
|
|
||||||
public int $join_token_access_decay_minutes = 1;
|
public int $join_token_access_decay_minutes = 1;
|
||||||
|
|
||||||
public int $join_token_download_limit = 60;
|
public int $join_token_download_limit = 120;
|
||||||
|
|
||||||
public int $join_token_download_decay_minutes = 1;
|
public int $join_token_download_decay_minutes = 1;
|
||||||
|
|
||||||
@@ -69,9 +69,9 @@ class GuestPolicySettingsPage extends Page
|
|||||||
$this->per_device_upload_limit = (int) ($settings->per_device_upload_limit ?? 50);
|
$this->per_device_upload_limit = (int) ($settings->per_device_upload_limit ?? 50);
|
||||||
$this->join_token_failure_limit = (int) ($settings->join_token_failure_limit ?? 10);
|
$this->join_token_failure_limit = (int) ($settings->join_token_failure_limit ?? 10);
|
||||||
$this->join_token_failure_decay_minutes = (int) ($settings->join_token_failure_decay_minutes ?? 5);
|
$this->join_token_failure_decay_minutes = (int) ($settings->join_token_failure_decay_minutes ?? 5);
|
||||||
$this->join_token_access_limit = (int) ($settings->join_token_access_limit ?? 120);
|
$this->join_token_access_limit = (int) ($settings->join_token_access_limit ?? 300);
|
||||||
$this->join_token_access_decay_minutes = (int) ($settings->join_token_access_decay_minutes ?? 1);
|
$this->join_token_access_decay_minutes = (int) ($settings->join_token_access_decay_minutes ?? 1);
|
||||||
$this->join_token_download_limit = (int) ($settings->join_token_download_limit ?? 60);
|
$this->join_token_download_limit = (int) ($settings->join_token_download_limit ?? 120);
|
||||||
$this->join_token_download_decay_minutes = (int) ($settings->join_token_download_decay_minutes ?? 1);
|
$this->join_token_download_decay_minutes = (int) ($settings->join_token_download_decay_minutes ?? 1);
|
||||||
$this->join_token_ttl_hours = (int) ($settings->join_token_ttl_hours ?? 168);
|
$this->join_token_ttl_hours = (int) ($settings->join_token_ttl_hours ?? 168);
|
||||||
$this->share_link_ttl_hours = (int) ($settings->share_link_ttl_hours ?? 48);
|
$this->share_link_ttl_hours = (int) ($settings->share_link_ttl_hours ?? 48);
|
||||||
|
|||||||
@@ -3,9 +3,12 @@
|
|||||||
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;
|
||||||
@@ -14,7 +17,10 @@ use Illuminate\Validation\ValidationException;
|
|||||||
|
|
||||||
class PackageController extends Controller
|
class PackageController extends Controller
|
||||||
{
|
{
|
||||||
public function __construct(private readonly PaddleCheckoutService $paddleCheckout) {}
|
public function __construct(
|
||||||
|
private readonly PaddleCheckoutService $paddleCheckout,
|
||||||
|
private readonly CheckoutSessionService $sessions,
|
||||||
|
) {}
|
||||||
|
|
||||||
public function index(Request $request): JsonResponse
|
public function index(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
@@ -165,23 +171,82 @@ 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);
|
||||||
|
|
||||||
return response()->json($checkout);
|
$session->forceFill([
|
||||||
|
'paddle_checkout_id' => $checkout['id'] ?? $session->paddle_checkout_id,
|
||||||
|
'provider_metadata' => array_merge($session->provider_metadata ?? [], array_filter([
|
||||||
|
'paddle_checkout_id' => $checkout['id'] ?? null,
|
||||||
|
'paddle_checkout_url' => $checkout['checkout_url'] ?? null,
|
||||||
|
'paddle_expires_at' => $checkout['expires_at'] ?? null,
|
||||||
|
])),
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
return response()->json(array_merge($checkout, [
|
||||||
|
'checkout_session_id' => $session->id,
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function checkoutSessionStatus(CheckoutSessionStatusRequest $request, CheckoutSession $session): JsonResponse
|
||||||
|
{
|
||||||
|
$history = $session->status_history ?? [];
|
||||||
|
$reason = null;
|
||||||
|
|
||||||
|
foreach (array_reverse($history) as $entry) {
|
||||||
|
if (($entry['status'] ?? null) === $session->status) {
|
||||||
|
$reason = $entry['reason'] ?? null;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$checkoutUrl = data_get($session->provider_metadata ?? [], 'paddle_checkout_url');
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'status' => $session->status,
|
||||||
|
'completed_at' => optional($session->completed_at)->toIso8601String(),
|
||||||
|
'reason' => $reason,
|
||||||
|
'checkout_url' => is_string($checkoutUrl) ? $checkoutUrl : null,
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function handleFreePurchase(Request $request, Package $package, $tenant): JsonResponse
|
private function handleFreePurchase(Request $request, Package $package, $tenant): JsonResponse
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ use App\Models\Package;
|
|||||||
use App\Models\PackagePurchase;
|
use App\Models\PackagePurchase;
|
||||||
use App\Models\Photo;
|
use App\Models\Photo;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
use App\Services\EventJoinTokenService;
|
use App\Services\EventJoinTokenService;
|
||||||
use App\Support\ApiError;
|
use App\Support\ApiError;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
@@ -88,12 +89,15 @@ class EventController extends Controller
|
|||||||
$tenant = Tenant::findOrFail($tenantId);
|
$tenant = Tenant::findOrFail($tenantId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$actor = $request->user();
|
||||||
|
$isSuperAdmin = $actor instanceof User && $actor->isSuperAdmin();
|
||||||
|
|
||||||
// Package check is now handled by middleware
|
// Package check is now handled by middleware
|
||||||
|
|
||||||
$validated = $request->validated();
|
$validated = $request->validated();
|
||||||
$tenantId = $tenant->id;
|
$tenantId = $tenant->id;
|
||||||
|
|
||||||
$requestedPackageId = $validated['package_id'] ?? null;
|
$requestedPackageId = $isSuperAdmin ? $request->integer('package_id') : null;
|
||||||
unset($validated['package_id']);
|
unset($validated['package_id']);
|
||||||
|
|
||||||
$tenantPackage = $tenant->tenantPackages()
|
$tenantPackage = $tenant->tenantPackages()
|
||||||
@@ -108,6 +112,10 @@ class EventController extends Controller
|
|||||||
$package = Package::query()->find($requestedPackageId);
|
$package = Package::query()->find($requestedPackageId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (! $package && $isSuperAdmin) {
|
||||||
|
$package = $this->resolveOwnerPackage();
|
||||||
|
}
|
||||||
|
|
||||||
if (! $package && $tenantPackage) {
|
if (! $package && $tenantPackage) {
|
||||||
$package = $tenantPackage->package ?? Package::query()->find($tenantPackage->package_id);
|
$package = $tenantPackage->package ?? Package::query()->find($tenantPackage->package_id);
|
||||||
}
|
}
|
||||||
@@ -121,7 +129,7 @@ class EventController extends Controller
|
|||||||
$requiresWaiver = $package->isEndcustomer();
|
$requiresWaiver = $package->isEndcustomer();
|
||||||
$latestPurchase = $requiresWaiver ? $this->resolveLatestPackagePurchase($tenant, $package) : null;
|
$latestPurchase = $requiresWaiver ? $this->resolveLatestPackagePurchase($tenant, $package) : null;
|
||||||
$existingWaiver = $latestPurchase ? data_get($latestPurchase->metadata, 'consents.digital_content_waiver_at') : null;
|
$existingWaiver = $latestPurchase ? data_get($latestPurchase->metadata, 'consents.digital_content_waiver_at') : null;
|
||||||
$needsWaiver = $requiresWaiver && ! $existingWaiver;
|
$needsWaiver = ! $isSuperAdmin && $requiresWaiver && ! $existingWaiver;
|
||||||
|
|
||||||
if ($needsWaiver && ! $request->boolean('accepted_waiver')) {
|
if ($needsWaiver && ! $request->boolean('accepted_waiver')) {
|
||||||
throw ValidationException::withMessages([
|
throw ValidationException::withMessages([
|
||||||
@@ -182,7 +190,7 @@ class EventController extends Controller
|
|||||||
|
|
||||||
$eventData = Arr::only($eventData, $allowed);
|
$eventData = Arr::only($eventData, $allowed);
|
||||||
|
|
||||||
$event = DB::transaction(function () use ($tenant, $eventData, $package) {
|
$event = DB::transaction(function () use ($tenant, $eventData, $package, $isSuperAdmin) {
|
||||||
$event = Event::create($eventData);
|
$event = Event::create($eventData);
|
||||||
|
|
||||||
EventPackage::create([
|
EventPackage::create([
|
||||||
@@ -193,7 +201,7 @@ class EventController extends Controller
|
|||||||
'gallery_expires_at' => $package->gallery_days ? now()->addDays($package->gallery_days) : null,
|
'gallery_expires_at' => $package->gallery_days ? now()->addDays($package->gallery_days) : null,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if ($package->isReseller()) {
|
if ($package->isReseller() && ! $isSuperAdmin) {
|
||||||
$note = sprintf('Event #%d created (%s)', $event->id, $event->name);
|
$note = sprintf('Event #%d created (%s)', $event->id, $event->name);
|
||||||
|
|
||||||
if (! $tenant->consumeEventAllowance(1, 'event.create', $note)) {
|
if (! $tenant->consumeEventAllowance(1, 'event.create', $note)) {
|
||||||
@@ -229,6 +237,15 @@ class EventController extends Controller
|
|||||||
->first();
|
->first();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function resolveOwnerPackage(): ?Package
|
||||||
|
{
|
||||||
|
$ownerPackage = Package::query()
|
||||||
|
->where('slug', 'pro')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
return $ownerPackage ?? Package::query()->find(3);
|
||||||
|
}
|
||||||
|
|
||||||
private function recordEventStartWaiver(Tenant $tenant, Package $package, ?PackagePurchase $purchase): void
|
private function recordEventStartWaiver(Tenant $tenant, Package $package, ?PackagePurchase $purchase): void
|
||||||
{
|
{
|
||||||
$timestamp = now();
|
$timestamp = now();
|
||||||
|
|||||||
@@ -135,7 +135,7 @@ class EventMemberController extends Controller
|
|||||||
$user->password = Hash::make(Str::random(32));
|
$user->password = Hash::make(Str::random(32));
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($user->tenant_id && (int) $user->tenant_id !== (int) $tenant->id && $user->role !== 'super_admin') {
|
if ($user->tenant_id && (int) $user->tenant_id !== (int) $tenant->id && ! $user->isSuperAdmin()) {
|
||||||
throw ValidationException::withMessages([
|
throw ValidationException::withMessages([
|
||||||
'email' => __('Dieser Benutzer ist einem anderen Mandanten zugeordnet.'),
|
'email' => __('Dieser Benutzer ist einem anderen Mandanten zugeordnet.'),
|
||||||
]);
|
]);
|
||||||
@@ -143,9 +143,9 @@ class EventMemberController extends Controller
|
|||||||
|
|
||||||
$user->tenant_id = $tenant->id;
|
$user->tenant_id = $tenant->id;
|
||||||
|
|
||||||
if ($role === 'tenant_admin' && $user->role !== 'super_admin') {
|
if ($role === 'tenant_admin' && ! $user->isSuperAdmin()) {
|
||||||
$user->role = 'tenant_admin';
|
$user->role = 'tenant_admin';
|
||||||
} elseif (! in_array($user->role, ['tenant_admin', 'super_admin'], true)) {
|
} elseif (! in_array($user->role, ['tenant_admin', 'super_admin', 'superadmin'], true)) {
|
||||||
$user->role = 'member';
|
$user->role = 'member';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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:write')) {
|
if (isset($validated['status']) && ! $this->tokenHasScope($request, 'tenant-admin')) {
|
||||||
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:write']
|
['required_scope' => 'tenant-admin']
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -823,6 +823,11 @@ 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)) {
|
||||||
|
|||||||
@@ -193,11 +193,11 @@ class TenantAdminTokenController extends Controller
|
|||||||
$abilities[] = 'tenant:'.$user->tenant_id;
|
$abilities[] = 'tenant:'.$user->tenant_id;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (in_array($user->role, ['tenant_admin', 'admin', 'super_admin'], true)) {
|
if (in_array($user->role, ['tenant_admin', 'admin', 'super_admin', 'superadmin'], true)) {
|
||||||
$abilities[] = 'tenant-admin';
|
$abilities[] = 'tenant-admin';
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($user->role === 'super_admin') {
|
if ($user->isSuperAdmin()) {
|
||||||
$abilities[] = 'super-admin';
|
$abilities[] = 'super-admin';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -219,7 +219,7 @@ class TenantAdminTokenController extends Controller
|
|||||||
|
|
||||||
private function ensureUserCanAccessPanel(User $user): void
|
private function ensureUserCanAccessPanel(User $user): void
|
||||||
{
|
{
|
||||||
if (in_array($user->role, ['tenant_admin', 'admin', 'super_admin'], true)) {
|
if (in_array($user->role, ['tenant_admin', 'admin', 'super_admin', 'superadmin'], true)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ use App\Models\User;
|
|||||||
use App\Notifications\TenantFeedbackSubmitted;
|
use App\Notifications\TenantFeedbackSubmitted;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Validation\Rule;
|
|
||||||
use Illuminate\Support\Facades\Notification;
|
use Illuminate\Support\Facades\Notification;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
class TenantFeedbackController extends Controller
|
class TenantFeedbackController extends Controller
|
||||||
{
|
{
|
||||||
@@ -56,7 +56,7 @@ class TenantFeedbackController extends Controller
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
$recipients = User::query()
|
$recipients = User::query()
|
||||||
->where('role', 'super_admin')
|
->whereIn('role', ['super_admin', 'superadmin'])
|
||||||
->whereNotNull('email')
|
->whereNotNull('email')
|
||||||
->get();
|
->get();
|
||||||
|
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ class TenantAdminPasswordResetController extends Controller
|
|||||||
|
|
||||||
private function canAccessEventAdmin(User $user): bool
|
private function canAccessEventAdmin(User $user): bool
|
||||||
{
|
{
|
||||||
if (in_array($user->role, ['tenant_admin', 'admin', 'super_admin'], true)) {
|
if (in_array($user->role, ['tenant_admin', 'admin', 'super_admin', 'superadmin'], true)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -155,7 +155,7 @@ class AuthenticatedSessionController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Super admins go to Filament superadmin panel
|
// Super admins go to Filament superadmin panel
|
||||||
if ($user && $user->role === 'super_admin') {
|
if ($user && $user->isSuperAdmin()) {
|
||||||
return '/super-admin';
|
return '/super-admin';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ class TenantAdminAuthController extends Controller
|
|||||||
$user = Auth::user();
|
$user = Auth::user();
|
||||||
|
|
||||||
// Allow only tenant_admin and super_admin
|
// Allow only tenant_admin and super_admin
|
||||||
if ($user && in_array($user->role, ['tenant_admin', 'super_admin'])) {
|
if ($user && in_array($user->role, ['tenant_admin', 'super_admin', 'superadmin'], true)) {
|
||||||
return view('admin');
|
return view('admin');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ class TenantAdminGoogleController extends Controller
|
|||||||
/** @var User|null $user */
|
/** @var User|null $user */
|
||||||
$user = User::query()->where('email', $email)->first();
|
$user = User::query()->where('email', $email)->first();
|
||||||
|
|
||||||
if (! $user || ! in_array($user->role, ['tenant_admin', 'super_admin'], true)) {
|
if (! $user || ! in_array($user->role, ['tenant_admin', 'super_admin', 'superadmin'], true)) {
|
||||||
return $this->sendBackWithError($request, 'google_no_match', 'No tenant admin account is linked to this Google address.');
|
return $this->sendBackWithError($request, 'google_no_match', 'No tenant admin account is linked to this Google address.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace App\Http\Middleware;
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
use App\Services\Packages\PackageLimitEvaluator;
|
use App\Services\Packages\PackageLimitEvaluator;
|
||||||
use App\Support\ApiError;
|
use App\Support\ApiError;
|
||||||
use Closure;
|
use Closure;
|
||||||
@@ -26,7 +27,7 @@ class CreditCheckMiddleware
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($this->requiresCredits($request)) {
|
if ($this->requiresCredits($request) && ! $this->shouldBypassCreditCheck($request, $tenant)) {
|
||||||
$violation = $this->limitEvaluator->assessEventCreation($tenant);
|
$violation = $this->limitEvaluator->assessEventCreation($tenant);
|
||||||
|
|
||||||
if ($violation !== null) {
|
if ($violation !== null) {
|
||||||
@@ -43,6 +44,24 @@ class CreditCheckMiddleware
|
|||||||
return $next($request);
|
return $next($request);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function shouldBypassCreditCheck(Request $request, Tenant $tenant): bool
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $user->isSuperAdmin()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $user->tenant_id) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (int) $user->tenant_id === (int) $tenant->id;
|
||||||
|
}
|
||||||
|
|
||||||
private function requiresCredits(Request $request): bool
|
private function requiresCredits(Request $request): bool
|
||||||
{
|
{
|
||||||
return $request->isMethod('post')
|
return $request->isMethod('post')
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ class EnsureTenantAdminToken
|
|||||||
/** @var Tenant|null $tenant */
|
/** @var Tenant|null $tenant */
|
||||||
$tenant = $user->tenant;
|
$tenant = $user->tenant;
|
||||||
|
|
||||||
if (! $tenant && $user->role === 'super_admin') {
|
if (! $tenant && $user->isSuperAdmin()) {
|
||||||
$requestedTenantId = $this->resolveRequestedTenantId($request);
|
$requestedTenantId = $this->resolveRequestedTenantId($request);
|
||||||
|
|
||||||
if ($requestedTenantId !== null) {
|
if ($requestedTenantId !== null) {
|
||||||
@@ -50,14 +50,14 @@ class EnsureTenantAdminToken
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! $tenant && $user->role !== 'super_admin') {
|
if (! $tenant && ! $user->isSuperAdmin()) {
|
||||||
return $this->forbiddenResponse('Tenant context missing for user.');
|
return $this->forbiddenResponse('Tenant context missing for user.');
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($tenant) {
|
if ($tenant) {
|
||||||
$request->attributes->set('tenant_id', $tenant->id);
|
$request->attributes->set('tenant_id', $tenant->id);
|
||||||
$request->attributes->set('tenant', $tenant);
|
$request->attributes->set('tenant', $tenant);
|
||||||
} elseif ($user->role === 'super_admin') {
|
} elseif ($user->isSuperAdmin()) {
|
||||||
$requestedTenantId = $this->resolveRequestedTenantId($request);
|
$requestedTenantId = $this->resolveRequestedTenantId($request);
|
||||||
if ($requestedTenantId !== null) {
|
if ($requestedTenantId !== null) {
|
||||||
$request->attributes->set('tenant_id', $requestedTenantId);
|
$request->attributes->set('tenant_id', $requestedTenantId);
|
||||||
@@ -96,7 +96,7 @@ class EnsureTenantAdminToken
|
|||||||
*/
|
*/
|
||||||
protected function allowedRoles(): array
|
protected function allowedRoles(): array
|
||||||
{
|
{
|
||||||
return ['tenant_admin', 'super_admin', 'admin'];
|
return ['tenant_admin', 'super_admin', 'superadmin', 'admin'];
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function forbiddenRoleMessage(): string
|
protected function forbiddenRoleMessage(): string
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ class EnsureTenantCollaboratorToken extends EnsureTenantAdminToken
|
|||||||
{
|
{
|
||||||
protected function allowedRoles(): array
|
protected function allowedRoles(): array
|
||||||
{
|
{
|
||||||
return ['tenant_admin', 'super_admin', 'admin', 'member'];
|
return ['tenant_admin', 'super_admin', 'superadmin', 'admin', 'member'];
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function forbiddenRoleMessage(): string
|
protected function forbiddenRoleMessage(): string
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace App\Http\Middleware;
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
use App\Services\Packages\PackageLimitEvaluator;
|
use App\Services\Packages\PackageLimitEvaluator;
|
||||||
use App\Support\ApiError;
|
use App\Support\ApiError;
|
||||||
use Closure;
|
use Closure;
|
||||||
@@ -26,7 +27,7 @@ class PackageMiddleware
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($this->requiresPackageCheck($request)) {
|
if ($this->requiresPackageCheck($request) && ! $this->shouldBypassPackageCheck($request, $tenant)) {
|
||||||
$violation = $this->detectViolation($request, $tenant);
|
$violation = $this->detectViolation($request, $tenant);
|
||||||
|
|
||||||
if ($violation !== null) {
|
if ($violation !== null) {
|
||||||
@@ -43,6 +44,24 @@ class PackageMiddleware
|
|||||||
return $next($request);
|
return $next($request);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function shouldBypassPackageCheck(Request $request, Tenant $tenant): bool
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $user->isSuperAdmin()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $user->tenant_id) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (int) $user->tenant_id === (int) $tenant->id;
|
||||||
|
}
|
||||||
|
|
||||||
private function requiresPackageCheck(Request $request): bool
|
private function requiresPackageCheck(Request $request): bool
|
||||||
{
|
{
|
||||||
return $request->isMethod('post') && (
|
return $request->isMethod('post') && (
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ class RedirectIfAuthenticated extends BaseMiddleware
|
|||||||
return '/event-admin/dashboard';
|
return '/event-admin/dashboard';
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($user && $user->role === 'super_admin') {
|
if ($user && $user->isSuperAdmin()) {
|
||||||
return '/super-admin';
|
return '/super-admin';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ namespace App\Http\Middleware;
|
|||||||
|
|
||||||
use Closure;
|
use Closure;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
class SuperAdminAuth
|
class SuperAdminAuth
|
||||||
{
|
{
|
||||||
@@ -21,15 +21,15 @@ class SuperAdminAuth
|
|||||||
return $next($request);
|
return $next($request);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!Auth::check()) {
|
if (! Auth::check()) {
|
||||||
abort(403, 'Nicht angemeldet.');
|
abort(403, 'Nicht angemeldet.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$user = Auth::user();
|
$user = Auth::user();
|
||||||
Log::info('SuperAdminAuth: User ID ' . $user->id . ', role: ' . $user->role);
|
Log::info('SuperAdminAuth: User ID '.$user->id.', role: '.$user->role);
|
||||||
|
|
||||||
if ($user->role !== 'super_admin') {
|
if (! $user->isSuperAdmin()) {
|
||||||
abort(403, 'Zugriff nur für SuperAdmin. User ID: ' . $user->id . ', Role: ' . $user->role);
|
abort(403, 'Zugriff nur für SuperAdmin. User ID: '.$user->id.', Role: '.$user->role);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $next($request);
|
return $next($request);
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ class EventStoreRequest extends FormRequest
|
|||||||
'event_date' => ['required', 'date', 'after_or_equal:today'],
|
'event_date' => ['required', 'date', 'after_or_equal:today'],
|
||||||
'location' => ['nullable', 'string', 'max:255'],
|
'location' => ['nullable', 'string', 'max:255'],
|
||||||
'event_type_id' => ['required', 'exists:event_types,id'],
|
'event_type_id' => ['required', 'exists:event_types,id'],
|
||||||
|
'package_id' => ['nullable', 'integer', 'exists:packages,id'],
|
||||||
'max_participants' => ['nullable', 'integer', 'min:1', 'max:10000'],
|
'max_participants' => ['nullable', 'integer', 'min:1', 'max:10000'],
|
||||||
'public_url' => ['nullable', 'url', 'max:500'],
|
'public_url' => ['nullable', 'url', 'max:500'],
|
||||||
'custom_domain' => ['nullable', 'string', 'max:255'],
|
'custom_domain' => ['nullable', 'string', 'max:255'],
|
||||||
|
|||||||
@@ -5,11 +5,19 @@ 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
|
||||||
*/
|
*/
|
||||||
@@ -25,7 +33,20 @@ 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!';
|
||||||
|
|
||||||
$this->notifications->createNotification(
|
$recent = $this->findRecentPhotoNotification($event->event->id);
|
||||||
|
if ($recent) {
|
||||||
|
if ($this->shouldSkipDuplicate($recent, $event->photoId, $title)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$notification = $this->updateGroupedNotification($recent, $event->photoId);
|
||||||
|
$this->markUploaderRead($notification, $event->guestIdentifier);
|
||||||
|
$this->maybeCreateMilestoneNotification($event, $guestLabel);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$notification = $this->notifications->createNotification(
|
||||||
$event->event,
|
$event->event,
|
||||||
GuestNotificationType::PHOTO_ACTIVITY,
|
GuestNotificationType::PHOTO_ACTIVITY,
|
||||||
$title,
|
$title,
|
||||||
@@ -34,11 +55,15 @@ 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,4 +112,94 @@ 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,9 +41,9 @@ class GuestPolicySetting extends Model
|
|||||||
'per_device_upload_limit' => 50,
|
'per_device_upload_limit' => 50,
|
||||||
'join_token_failure_limit' => (int) config('join_tokens.failure_limit', 10),
|
'join_token_failure_limit' => (int) config('join_tokens.failure_limit', 10),
|
||||||
'join_token_failure_decay_minutes' => (int) config('join_tokens.failure_decay_minutes', 5),
|
'join_token_failure_decay_minutes' => (int) config('join_tokens.failure_decay_minutes', 5),
|
||||||
'join_token_access_limit' => (int) config('join_tokens.access_limit', 120),
|
'join_token_access_limit' => (int) config('join_tokens.access_limit', 300),
|
||||||
'join_token_access_decay_minutes' => (int) config('join_tokens.access_decay_minutes', 1),
|
'join_token_access_decay_minutes' => (int) config('join_tokens.access_decay_minutes', 1),
|
||||||
'join_token_download_limit' => (int) config('join_tokens.download_limit', 60),
|
'join_token_download_limit' => (int) config('join_tokens.download_limit', 120),
|
||||||
'join_token_download_decay_minutes' => (int) config('join_tokens.download_decay_minutes', 1),
|
'join_token_download_decay_minutes' => (int) config('join_tokens.download_decay_minutes', 1),
|
||||||
'join_token_ttl_hours' => 168,
|
'join_token_ttl_hours' => 168,
|
||||||
'share_link_ttl_hours' => (int) config('share-links.ttl_hours', 48),
|
'share_link_ttl_hours' => (int) config('share-links.ttl_hours', 48),
|
||||||
|
|||||||
@@ -69,6 +69,16 @@ class User extends Authenticatable implements FilamentHasTenants, FilamentUser,
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function isSuperAdmin(): bool
|
||||||
|
{
|
||||||
|
return self::isSuperAdminRole($this->role);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function isSuperAdminRole(?string $role): bool
|
||||||
|
{
|
||||||
|
return in_array($role, ['super_admin', 'superadmin'], true);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Retrieve the user by the given credentials.
|
* Retrieve the user by the given credentials.
|
||||||
*/
|
*/
|
||||||
@@ -127,12 +137,12 @@ class User extends Authenticatable implements FilamentHasTenants, FilamentUser,
|
|||||||
|
|
||||||
public function canAccessPanel(Panel $panel): bool
|
public function canAccessPanel(Panel $panel): bool
|
||||||
{
|
{
|
||||||
if (! $this->email_verified_at && $this->role !== 'super_admin') {
|
if (! $this->email_verified_at && ! $this->isSuperAdmin()) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return match ($panel->getId()) {
|
return match ($panel->getId()) {
|
||||||
'superadmin' => $this->role === 'super_admin',
|
'superadmin' => $this->isSuperAdmin(),
|
||||||
'admin' => $this->role === 'tenant_admin',
|
'admin' => $this->role === 'tenant_admin',
|
||||||
default => false,
|
default => false,
|
||||||
};
|
};
|
||||||
@@ -140,7 +150,7 @@ class User extends Authenticatable implements FilamentHasTenants, FilamentUser,
|
|||||||
|
|
||||||
public function canAccessTenant(Model $tenant): bool
|
public function canAccessTenant(Model $tenant): bool
|
||||||
{
|
{
|
||||||
if ($this->role === 'super_admin') {
|
if ($this->isSuperAdmin()) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,7 +165,7 @@ class User extends Authenticatable implements FilamentHasTenants, FilamentUser,
|
|||||||
|
|
||||||
public function getTenants(Panel $panel): array|Collection
|
public function getTenants(Panel $panel): array|Collection
|
||||||
{
|
{
|
||||||
if ($this->role === 'super_admin') {
|
if ($this->isSuperAdmin()) {
|
||||||
return Tenant::query()->orderBy('name')->get();
|
return Tenant::query()->orderBy('name')->get();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,12 +12,11 @@ class PurchaseHistoryPolicy
|
|||||||
|
|
||||||
public function viewAny(User $user): bool
|
public function viewAny(User $user): bool
|
||||||
{
|
{
|
||||||
return $user->role === 'super_admin';
|
return $user->isSuperAdmin();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function view(User $user, PurchaseHistory $purchaseHistory): bool
|
public function view(User $user, PurchaseHistory $purchaseHistory): bool
|
||||||
{
|
{
|
||||||
return $user->role === 'super_admin';
|
return $user->isSuperAdmin();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ class TenantPolicy
|
|||||||
*/
|
*/
|
||||||
public function viewAny(User $user): bool
|
public function viewAny(User $user): bool
|
||||||
{
|
{
|
||||||
return $user->role === 'super_admin';
|
return $user->isSuperAdmin();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -35,7 +35,7 @@ class TenantPolicy
|
|||||||
*/
|
*/
|
||||||
public function create(User $user): bool
|
public function create(User $user): bool
|
||||||
{
|
{
|
||||||
return $user->role === 'super_admin';
|
return $user->isSuperAdmin();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -43,7 +43,7 @@ class TenantPolicy
|
|||||||
*/
|
*/
|
||||||
public function update(User $user, Tenant $tenant): bool
|
public function update(User $user, Tenant $tenant): bool
|
||||||
{
|
{
|
||||||
return $user->role === 'super_admin';
|
return $user->isSuperAdmin();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -51,7 +51,7 @@ class TenantPolicy
|
|||||||
*/
|
*/
|
||||||
public function delete(User $user, Tenant $tenant): bool
|
public function delete(User $user, Tenant $tenant): bool
|
||||||
{
|
{
|
||||||
return $user->role === 'super_admin';
|
return $user->isSuperAdmin();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -59,6 +59,6 @@ class TenantPolicy
|
|||||||
*/
|
*/
|
||||||
public function suspend(User $user, Tenant $tenant): bool
|
public function suspend(User $user, Tenant $tenant): bool
|
||||||
{
|
{
|
||||||
return $user->role === 'super_admin';
|
return $user->isSuperAdmin();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -155,7 +155,11 @@ class AppServiceProvider extends ServiceProvider
|
|||||||
|
|
||||||
$key = $tenantId ? 'tenant:'.$tenantId : ('ip:'.($request->ip() ?? 'unknown'));
|
$key = $tenantId ? 'tenant:'.$tenantId : ('ip:'.($request->ip() ?? 'unknown'));
|
||||||
|
|
||||||
return Limit::perMinute(100)->by($key);
|
return Limit::perMinute(600)->by($key);
|
||||||
|
});
|
||||||
|
|
||||||
|
RateLimiter::for('guest-api', function (Request $request) {
|
||||||
|
return Limit::perMinute(300)->by('guest-api:'.($request->ip() ?? 'unknown'));
|
||||||
});
|
});
|
||||||
|
|
||||||
RateLimiter::for('tenant-auth', function (Request $request) {
|
RateLimiter::for('tenant-auth', function (Request $request) {
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ class AuthServiceProvider extends ServiceProvider
|
|||||||
});
|
});
|
||||||
|
|
||||||
Gate::before(function (User $user): ?bool {
|
Gate::before(function (User $user): ?bool {
|
||||||
return $user->role === 'super_admin' ? true : null;
|
return $user->isSuperAdmin() ? true : null;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ class SuperAdminAuditLogger
|
|||||||
|
|
||||||
private function shouldLog(?User $actor): bool
|
private function shouldLog(?User $actor): bool
|
||||||
{
|
{
|
||||||
if (! $actor || $actor->role !== 'super_admin') {
|
if (! $actor || ! $actor->isSuperAdmin()) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -126,6 +126,36 @@ 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 = [
|
||||||
@@ -142,6 +172,9 @@ 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;
|
||||||
|
|||||||
@@ -17,6 +17,11 @@ class PaddleDiscountService
|
|||||||
*/
|
*/
|
||||||
public function createDiscount(Coupon $coupon): array
|
public function createDiscount(Coupon $coupon): array
|
||||||
{
|
{
|
||||||
|
$existing = $this->findExistingDiscount($coupon->code);
|
||||||
|
if ($existing !== null) {
|
||||||
|
return $existing;
|
||||||
|
}
|
||||||
|
|
||||||
$payload = $this->buildDiscountPayload($coupon);
|
$payload = $this->buildDiscountPayload($coupon);
|
||||||
|
|
||||||
$response = $this->client->post('/discounts', $payload);
|
$response = $this->client->post('/discounts', $payload);
|
||||||
@@ -82,6 +87,35 @@ class PaddleDiscountService
|
|||||||
return Arr::get($response, 'data', $response);
|
return Arr::get($response, 'data', $response);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>|null
|
||||||
|
*/
|
||||||
|
protected function findExistingDiscount(?string $code): ?array
|
||||||
|
{
|
||||||
|
$normalized = Str::upper(trim((string) $code));
|
||||||
|
if ($normalized === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$response = $this->client->get('/discounts', [
|
||||||
|
'code' => $normalized,
|
||||||
|
'per_page' => 1,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$items = Arr::get($response, 'data', []);
|
||||||
|
if (! is_array($items) || $items === []) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$match = Collection::make($items)->first(static function ($item) use ($normalized) {
|
||||||
|
$codeValue = Str::upper((string) Arr::get($item, 'code', ''));
|
||||||
|
|
||||||
|
return $codeValue === $normalized ? $item : null;
|
||||||
|
});
|
||||||
|
|
||||||
|
return is_array($match) ? $match : null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<string, mixed>
|
* @return array<string, mixed>
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -24,15 +24,15 @@ class TenantAuth
|
|||||||
}
|
}
|
||||||
|
|
||||||
$user = $request->user();
|
$user = $request->user();
|
||||||
if ($user && in_array($user->role, ['tenant_admin', 'admin', 'super_admin', 'member'], true)) {
|
if ($user && in_array($user->role, ['tenant_admin', 'admin', 'super_admin', 'superadmin', 'member'], true)) {
|
||||||
if ($user->role !== 'super_admin' || (int) $user->tenant_id === (int) $tenantId) {
|
if (! $user->isSuperAdmin() || (int) $user->tenant_id === (int) $tenantId) {
|
||||||
return $user;
|
return $user;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$user = User::query()
|
$user = User::query()
|
||||||
->where('tenant_id', $tenantId)
|
->where('tenant_id', $tenantId)
|
||||||
->whereIn('role', ['tenant_admin', 'admin', 'member'])
|
->whereIn('role', ['tenant_admin', 'admin', 'super_admin', 'superadmin', 'member'])
|
||||||
->orderByDesc('email_verified_at')
|
->orderByDesc('email_verified_at')
|
||||||
->orderBy('id')
|
->orderBy('id')
|
||||||
->first();
|
->first();
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ return [
|
|||||||
'failure_limit' => (int) env('JOIN_TOKEN_FAILURE_LIMIT', 10),
|
'failure_limit' => (int) env('JOIN_TOKEN_FAILURE_LIMIT', 10),
|
||||||
'failure_decay_minutes' => (int) env('JOIN_TOKEN_FAILURE_DECAY', 5),
|
'failure_decay_minutes' => (int) env('JOIN_TOKEN_FAILURE_DECAY', 5),
|
||||||
|
|
||||||
'access_limit' => (int) env('JOIN_TOKEN_ACCESS_LIMIT', 120),
|
'access_limit' => (int) env('JOIN_TOKEN_ACCESS_LIMIT', 300),
|
||||||
'access_decay_minutes' => (int) env('JOIN_TOKEN_ACCESS_DECAY', 1),
|
'access_decay_minutes' => (int) env('JOIN_TOKEN_ACCESS_DECAY', 1),
|
||||||
|
|
||||||
'download_limit' => (int) env('JOIN_TOKEN_DOWNLOAD_LIMIT', 60),
|
'download_limit' => (int) env('JOIN_TOKEN_DOWNLOAD_LIMIT', 120),
|
||||||
'download_decay_minutes' => (int) env('JOIN_TOKEN_DOWNLOAD_DECAY', 1),
|
'download_decay_minutes' => (int) env('JOIN_TOKEN_DOWNLOAD_DECAY', 1),
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -2,9 +2,11 @@
|
|||||||
|
|
||||||
namespace Database\Seeders;
|
namespace Database\Seeders;
|
||||||
|
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
use Illuminate\Database\Seeder;
|
use Illuminate\Database\Seeder;
|
||||||
use Illuminate\Support\Facades\Hash;
|
use Illuminate\Support\Facades\Hash;
|
||||||
use App\Models\User;
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
class SuperAdminSeeder extends Seeder
|
class SuperAdminSeeder extends Seeder
|
||||||
{
|
{
|
||||||
@@ -12,12 +14,49 @@ class SuperAdminSeeder extends Seeder
|
|||||||
{
|
{
|
||||||
$email = env('ADMIN_EMAIL', 'admin@example.com');
|
$email = env('ADMIN_EMAIL', 'admin@example.com');
|
||||||
$password = env('ADMIN_PASSWORD', 'ChangeMe123!');
|
$password = env('ADMIN_PASSWORD', 'ChangeMe123!');
|
||||||
User::updateOrCreate(['email'=>$email], [
|
$user = User::updateOrCreate(['email' => $email], [
|
||||||
'first_name' => 'Super',
|
'first_name' => 'Super',
|
||||||
'last_name' => 'Admin',
|
'last_name' => 'Admin',
|
||||||
'password' => Hash::make($password),
|
'password' => Hash::make($password),
|
||||||
'role' => 'super_admin',
|
'role' => 'super_admin',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
$tenantSlug = env('OWNER_TENANT_SLUG', 'owner-tenant');
|
||||||
|
$tenantName = env('OWNER_TENANT_NAME', 'Owner Tenant');
|
||||||
|
|
||||||
|
$tenant = Tenant::query()->firstOrCreate(
|
||||||
|
['slug' => $tenantSlug],
|
||||||
|
[
|
||||||
|
'name' => $tenantName,
|
||||||
|
'email' => $email,
|
||||||
|
'contact_email' => $email,
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'is_active' => true,
|
||||||
|
'is_suspended' => false,
|
||||||
|
'settings' => [
|
||||||
|
'contact_email' => $email,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (! $tenant->slug) {
|
||||||
|
$tenant->forceFill(['slug' => Str::slug($tenantName)])->save();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $tenant->user_id) {
|
||||||
|
$tenant->forceFill(['user_id' => $user->id])->save();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $tenant->email) {
|
||||||
|
$tenant->forceFill(['email' => $email])->save();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $tenant->contact_email) {
|
||||||
|
$tenant->forceFill(['contact_email' => $email])->save();
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($user->tenant_id !== $tenant->id) {
|
||||||
|
$user->forceFill(['tenant_id' => $tenant->id])->save();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
BIN
firefox_i0ktsA4zsn.png
Normal file
BIN
firefox_i0ktsA4zsn.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 118 KiB |
File diff suppressed because one or more lines are too long
@@ -1 +1,80 @@
|
|||||||
{}
|
{
|
||||||
|
"login_failed": "Diese Anmeldedaten wurden nicht gefunden.",
|
||||||
|
"login_success": "Sie sind nun eingeloggt.",
|
||||||
|
"registration_failed": "Registrierung fehlgeschlagen.",
|
||||||
|
"registration_success": "Registrierung erfolgreich – bitte mit dem Kauf fortfahren.",
|
||||||
|
"already_logged_in": "Sie sind bereits eingeloggt.",
|
||||||
|
"failed_credentials": "Diese Anmeldedaten wurden nicht gefunden.",
|
||||||
|
"header": {
|
||||||
|
"login": "Anmelden",
|
||||||
|
"register": "Registrieren",
|
||||||
|
"home": "Startseite",
|
||||||
|
"packages": "Pakete",
|
||||||
|
"blog": "Blog",
|
||||||
|
"occasions": {
|
||||||
|
"wedding": "Hochzeit",
|
||||||
|
"birthday": "Geburtstag",
|
||||||
|
"corporate": "Firmenevent"
|
||||||
|
},
|
||||||
|
"contact": "Kontakt"
|
||||||
|
},
|
||||||
|
"login": {
|
||||||
|
"title": "Die Fotospiel App",
|
||||||
|
"description": "Melde dich mit deinem Fotospiel-Zugang an und steuere deine Events zentral in einem Dashboard.",
|
||||||
|
"brand": "Die Fotospiel App",
|
||||||
|
"logo_alt": "Logo Die Fotospiel App",
|
||||||
|
"identifier": "E-Mail oder Username",
|
||||||
|
"identifier_placeholder": "z. B. name@beispiel.de oder hochzeit_julia",
|
||||||
|
"username_or_email": "Username oder E-Mail",
|
||||||
|
"email": "E-Mail-Adresse",
|
||||||
|
"email_placeholder": "ihre@email.de",
|
||||||
|
"password": "Passwort",
|
||||||
|
"password_placeholder": "Ihr Passwort",
|
||||||
|
"remember": "Angemeldet bleiben",
|
||||||
|
"forgot": "Passwort vergessen?",
|
||||||
|
"submit": "Anmelden",
|
||||||
|
"oauth_divider": "oder",
|
||||||
|
"google_cta": "Mit Google anmelden",
|
||||||
|
"google_helper": "Nutze dein Google-Konto, um dich sicher bei der Eventverwaltung anzumelden.",
|
||||||
|
"no_account": "Noch keinen Zugang?",
|
||||||
|
"sign_up": "Jetzt registrieren"
|
||||||
|
},
|
||||||
|
"register": {
|
||||||
|
"title": "Registrieren",
|
||||||
|
"name": "Vollständiger Name",
|
||||||
|
"username": "Username",
|
||||||
|
"email": "E-Mail-Adresse",
|
||||||
|
"password": "Passwort",
|
||||||
|
"password_confirmation": "Passwort bestätigen",
|
||||||
|
"first_name": "Vorname",
|
||||||
|
"last_name": "Nachname",
|
||||||
|
"address": "Adresse",
|
||||||
|
"phone": "Telefonnummer",
|
||||||
|
"privacy_consent": "Ich stimme der Datenschutzerklärung zu und akzeptiere die Verarbeitung meiner persönlichen Daten.",
|
||||||
|
"privacy_policy_link": "Datenschutzerklärung",
|
||||||
|
"submit": "Registrieren",
|
||||||
|
"first_name_placeholder": "Vorname",
|
||||||
|
"last_name_placeholder": "Nachname",
|
||||||
|
"email_placeholder": "beispiel@email.de",
|
||||||
|
"address_placeholder": "Straße Hausnummer, PLZ Ort",
|
||||||
|
"phone_placeholder": "+49 170 1234567",
|
||||||
|
"username_placeholder": "z. B. hochzeit_julia",
|
||||||
|
"password_placeholder": "Mindestens 8 Zeichen",
|
||||||
|
"password_confirmation_placeholder": "Passwort erneut eingeben",
|
||||||
|
"server_error_title": "Registrierung konnte nicht abgeschlossen werden",
|
||||||
|
"server_error_message": "Auf unserer Seite ist ein Fehler aufgetreten. Bitte versuche es später erneut oder kontaktiere support@fotospiel.de.",
|
||||||
|
"session_expired_title": "Sicherheitsprüfung abgelaufen",
|
||||||
|
"session_expired_message": "Deine Sitzung ist abgelaufen. Lade die Seite neu und versuche es erneut."
|
||||||
|
},
|
||||||
|
"verification": {
|
||||||
|
"notice": "Bitte bestätigen Sie Ihre E-Mail-Adresse.",
|
||||||
|
"resend": "E-Mail erneut senden",
|
||||||
|
"success_title": "E-Mail bestätigt",
|
||||||
|
"success_message": "Deine E-Mail ist bestätigt. Du kannst dich jetzt anmelden.",
|
||||||
|
"checkout_success_message": "E-Mail bestätigt. Du kannst mit dem Checkout fortfahren.",
|
||||||
|
"toast_success": "E-Mail erfolgreich bestätigt.",
|
||||||
|
"expired_title": "Bestätigungslink abgelaufen",
|
||||||
|
"expired_message": "Dieser Bestätigungslink ist nicht mehr gültig. Fordere unten einen neuen Link an.",
|
||||||
|
"toast_error": "Bestätigungslink abgelaufen. Bitte fordere einen neuen Link an."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1 +1,80 @@
|
|||||||
{}
|
{
|
||||||
|
"login_failed": "Invalid email or password.",
|
||||||
|
"login_success": "You are now logged in.",
|
||||||
|
"registration_failed": "Registration failed.",
|
||||||
|
"registration_success": "Registration successful – proceed with purchase.",
|
||||||
|
"already_logged_in": "You are already logged in.",
|
||||||
|
"failed_credentials": "Wrong credentials.",
|
||||||
|
"header": {
|
||||||
|
"login": "Login",
|
||||||
|
"register": "Register",
|
||||||
|
"home": "Home",
|
||||||
|
"packages": "Packages",
|
||||||
|
"blog": "Blog",
|
||||||
|
"occasions": {
|
||||||
|
"wedding": "Wedding",
|
||||||
|
"birthday": "Birthday",
|
||||||
|
"corporate": "Corporate Event"
|
||||||
|
},
|
||||||
|
"contact": "Contact"
|
||||||
|
},
|
||||||
|
"login": {
|
||||||
|
"title": "Die Fotospiel App",
|
||||||
|
"description": "Sign in with your Fotospiel account to manage every event in one place.",
|
||||||
|
"brand": "Die Fotospiel App",
|
||||||
|
"logo_alt": "Fotospiel App logo",
|
||||||
|
"identifier": "Email or Username",
|
||||||
|
"identifier_placeholder": "you@example.com or username",
|
||||||
|
"username_or_email": "Username or Email",
|
||||||
|
"email": "Email Address",
|
||||||
|
"email_placeholder": "your@email.com",
|
||||||
|
"password": "Password",
|
||||||
|
"password_placeholder": "Your password",
|
||||||
|
"remember": "Stay logged in",
|
||||||
|
"forgot": "Forgot password?",
|
||||||
|
"submit": "Login",
|
||||||
|
"oauth_divider": "or",
|
||||||
|
"google_cta": "Continue with Google",
|
||||||
|
"google_helper": "Use your Google account to access the event dashboard securely.",
|
||||||
|
"no_account": "Don't have access yet?",
|
||||||
|
"sign_up": "Create an account"
|
||||||
|
},
|
||||||
|
"register": {
|
||||||
|
"title": "Register",
|
||||||
|
"name": "Full Name",
|
||||||
|
"username": "Username",
|
||||||
|
"email": "Email Address",
|
||||||
|
"password": "Password",
|
||||||
|
"password_confirmation": "Confirm password",
|
||||||
|
"first_name": "First Name",
|
||||||
|
"last_name": "Last Name",
|
||||||
|
"address": "Address",
|
||||||
|
"phone": "Phone Number",
|
||||||
|
"privacy_consent": "I agree to the privacy policy and accept the processing of my personal data.",
|
||||||
|
"privacy_policy_link": "Privacy Policy",
|
||||||
|
"submit": "Register",
|
||||||
|
"first_name_placeholder": "First name",
|
||||||
|
"last_name_placeholder": "Last name",
|
||||||
|
"email_placeholder": "you@example.com",
|
||||||
|
"address_placeholder": "Street, ZIP, City",
|
||||||
|
"phone_placeholder": "+1 555 123 4567",
|
||||||
|
"username_placeholder": "e.g. wedding_julia",
|
||||||
|
"password_placeholder": "At least 8 characters",
|
||||||
|
"password_confirmation_placeholder": "Repeat your password",
|
||||||
|
"server_error_title": "We couldn't finish your registration",
|
||||||
|
"server_error_message": "Something went wrong on our side. Please try again in a moment or contact support@fotospiel.de.",
|
||||||
|
"session_expired_title": "Security check expired",
|
||||||
|
"session_expired_message": "Your session expired. Refresh the page and try again."
|
||||||
|
},
|
||||||
|
"verification": {
|
||||||
|
"notice": "Please verify your email address.",
|
||||||
|
"resend": "Resend email",
|
||||||
|
"success_title": "Email verified",
|
||||||
|
"success_message": "Your email is confirmed. You can sign in now.",
|
||||||
|
"checkout_success_message": "Email confirmed. Continue your checkout to finish the order.",
|
||||||
|
"toast_success": "Email verified successfully.",
|
||||||
|
"expired_title": "Verification link expired",
|
||||||
|
"expired_message": "That verification link is no longer valid. Request a new email below.",
|
||||||
|
"toast_error": "Verification link expired. Request a new one."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,7 @@
|
|||||||
import { authorizedFetch } from './auth/tokens';
|
import { authorizedFetch } from './auth/tokens';
|
||||||
import { ApiError, emitApiErrorEvent } from './lib/apiError';
|
import { ApiError, emitApiErrorEvent } from './lib/apiError';
|
||||||
import type { EventLimitSummary } from './lib/limitWarnings';
|
import type { EventLimitSummary } from './lib/limitWarnings';
|
||||||
|
export type { EventLimitSummary };
|
||||||
import i18n from './i18n';
|
import i18n from './i18n';
|
||||||
|
|
||||||
type JsonValue = Record<string, unknown>;
|
type JsonValue = Record<string, unknown>;
|
||||||
@@ -2457,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 }> {
|
): Promise<{ checkout_url: string; id: string; expires_at?: string; checkout_session_id?: 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' },
|
||||||
@@ -2467,12 +2468,22 @@ 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 }>(
|
return await jsonOrThrow<{ checkout_url: string; id: string; expires_at?: string; checkout_session_id?: 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,6 +34,27 @@
|
|||||||
"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",
|
||||||
@@ -176,6 +197,8 @@
|
|||||||
},
|
},
|
||||||
"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",
|
||||||
@@ -2875,16 +2898,25 @@
|
|||||||
"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."
|
||||||
},
|
},
|
||||||
@@ -2893,6 +2925,26 @@
|
|||||||
"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": {
|
||||||
@@ -2906,7 +2958,13 @@
|
|||||||
},
|
},
|
||||||
"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": {
|
||||||
@@ -2918,7 +2976,9 @@
|
|||||||
},
|
},
|
||||||
"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:",
|
||||||
@@ -2931,6 +2991,7 @@
|
|||||||
"payNow": "Jetzt zahlen",
|
"payNow": "Jetzt zahlen",
|
||||||
"errors": {
|
"errors": {
|
||||||
"checkout": "Checkout fehlgeschlagen"
|
"checkout": "Checkout fehlgeschlagen"
|
||||||
}
|
},
|
||||||
|
"selectDisabled": "Nicht verfügbar"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,27 @@
|
|||||||
"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",
|
||||||
@@ -172,6 +193,8 @@
|
|||||||
},
|
},
|
||||||
"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",
|
||||||
@@ -2879,16 +2902,25 @@
|
|||||||
"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."
|
||||||
},
|
},
|
||||||
@@ -2897,6 +2929,26 @@
|
|||||||
"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": {
|
||||||
@@ -2910,7 +2962,13 @@
|
|||||||
},
|
},
|
||||||
"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": {
|
||||||
@@ -2922,7 +2980,9 @@
|
|||||||
},
|
},
|
||||||
"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:",
|
||||||
@@ -2935,6 +2995,7 @@
|
|||||||
"payNow": "Pay Now",
|
"payNow": "Pay Now",
|
||||||
"errors": {
|
"errors": {
|
||||||
"checkout": "Checkout failed"
|
"checkout": "Checkout failed"
|
||||||
}
|
},
|
||||||
|
"selectDisabled": "Not available"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export type EventTabCounts = Partial<{
|
|||||||
tasks: number;
|
tasks: number;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
type Translator = (key: string, fallback: string) => string;
|
type Translator = any;
|
||||||
|
|
||||||
export function buildEventTabs(event: TenantEvent, translate: Translator, counts: EventTabCounts = {}) {
|
export function buildEventTabs(event: TenantEvent, translate: Translator, counts: EventTabCounts = {}) {
|
||||||
if (!event.slug) {
|
if (!event.slug) {
|
||||||
|
|||||||
@@ -8,20 +8,11 @@ import { Pressable } from '@tamagui/react-native-web-lite';
|
|||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
import { MobileShell, HeaderActionButton } from './components/MobileShell';
|
import { MobileShell, HeaderActionButton } from './components/MobileShell';
|
||||||
import { MobileCard, CTAButton, PillBadge } from './components/Primitives';
|
import { MobileCard, CTAButton, PillBadge } from './components/Primitives';
|
||||||
import React from 'react';
|
|
||||||
import { useNavigate, useLocation } from 'react-router-dom';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { Package, Receipt, RefreshCcw, Sparkles } from 'lucide-react';
|
|
||||||
import { YStack, XStack } from '@tamagui/stacks';
|
|
||||||
import { SizableText as Text } from '@tamagui/text';
|
|
||||||
import { Pressable } from '@tamagui/react-native-web-lite';
|
|
||||||
import toast from 'react-hot-toast';
|
|
||||||
import { MobileShell, HeaderActionButton } from './components/MobileShell';
|
|
||||||
import { MobileCard, CTAButton, PillBadge } from './components/Primitives';
|
|
||||||
import {
|
import {
|
||||||
createTenantBillingPortalSession,
|
createTenantBillingPortalSession,
|
||||||
getTenantPackagesOverview,
|
getTenantPackagesOverview,
|
||||||
getTenantPaddleTransactions,
|
getTenantPaddleTransactions,
|
||||||
|
getTenantPackageCheckoutStatus,
|
||||||
TenantPackageSummary,
|
TenantPackageSummary,
|
||||||
PaddleTransactionSummary,
|
PaddleTransactionSummary,
|
||||||
} from '../api';
|
} from '../api';
|
||||||
@@ -37,6 +28,14 @@ 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');
|
||||||
@@ -50,6 +49,11 @@ 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';
|
||||||
@@ -105,6 +109,11 @@ 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]);
|
||||||
@@ -118,6 +127,115 @@ 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"
|
||||||
@@ -137,6 +255,109 @@ 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">
|
||||||
@@ -235,7 +456,6 @@ export default function MobileBillingPage() {
|
|||||||
))}
|
))}
|
||||||
</YStack>
|
</YStack>
|
||||||
)}
|
)}
|
||||||
{null}
|
|
||||||
</MobileCard>
|
</MobileCard>
|
||||||
|
|
||||||
<MobileCard space="$2">
|
<MobileCard space="$2">
|
||||||
@@ -263,7 +483,6 @@ export default function MobileBillingPage() {
|
|||||||
))}
|
))}
|
||||||
</YStack>
|
</YStack>
|
||||||
)}
|
)}
|
||||||
{null}
|
|
||||||
</MobileCard>
|
</MobileCard>
|
||||||
</MobileShell>
|
</MobileShell>
|
||||||
);
|
);
|
||||||
@@ -548,155 +767,3 @@ function formatDate(value: string | null | undefined): string {
|
|||||||
if (Number.isNaN(date.getTime())) return '—';
|
if (Number.isNaN(date.getTime())) return '—';
|
||||||
return date.toLocaleDateString(undefined, { day: '2-digit', month: 'short', year: 'numeric' });
|
return date.toLocaleDateString(undefined, { day: '2-digit', month: 'short', year: 'numeric' });
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderFeatureBadge(pkg: TenantPackageSummary, t: any, key: string, label: string) {
|
|
||||||
const value = (pkg.package_limits as any)?.[key] ?? (pkg as any)[key];
|
|
||||||
if (value === undefined || value === null) return null;
|
|
||||||
const enabled = value !== false;
|
|
||||||
return <PillBadge tone={enabled ? 'success' : 'muted'}>{enabled ? label : `${label} off`}</PillBadge>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function UsageBar({ metric }: { metric: PackageUsageMetric }) {
|
|
||||||
const { t } = useTranslation('management');
|
|
||||||
const { muted, textStrong, border, primary, subtle, warningText, danger } = useAdminTheme();
|
|
||||||
const labelMap: Record<PackageUsageMetric['key'], string> = {
|
|
||||||
events: t('mobileBilling.usage.events', 'Events'),
|
|
||||||
guests: t('mobileBilling.usage.guests', 'Guests'),
|
|
||||||
photos: t('mobileBilling.usage.photos', 'Photos'),
|
|
||||||
gallery: t('mobileBilling.usage.gallery', 'Gallery days'),
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!metric.limit) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const status = getUsageState(metric);
|
|
||||||
const hasUsage = metric.used !== null;
|
|
||||||
const valueText = hasUsage
|
|
||||||
? t('mobileBilling.usage.value', { used: metric.used, limit: metric.limit })
|
|
||||||
: t('mobileBilling.usage.limit', { limit: metric.limit });
|
|
||||||
const remainingText = metric.remaining !== null
|
|
||||||
? t('mobileBilling.usage.remainingOf', {
|
|
||||||
remaining: metric.remaining,
|
|
||||||
limit: metric.limit,
|
|
||||||
defaultValue: 'Remaining {{remaining}} of {{limit}}',
|
|
||||||
})
|
|
||||||
: null;
|
|
||||||
const fill = usagePercent(metric);
|
|
||||||
const statusLabel =
|
|
||||||
status === 'danger'
|
|
||||||
? t('mobileBilling.usage.statusDanger', 'Limit reached')
|
|
||||||
: status === 'warning'
|
|
||||||
? t('mobileBilling.usage.statusWarning', 'Low')
|
|
||||||
: null;
|
|
||||||
const fillColor = status === 'danger' ? danger : status === 'warning' ? warningText : primary;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<YStack space="$1.5">
|
|
||||||
<XStack alignItems="center" justifyContent="space-between">
|
|
||||||
<Text fontSize="$xs" color={muted}>
|
|
||||||
{labelMap[metric.key]}
|
|
||||||
</Text>
|
|
||||||
<XStack alignItems="center" space="$1.5">
|
|
||||||
{statusLabel ? <PillBadge tone={status === 'danger' ? 'danger' : 'warning'}>{statusLabel}</PillBadge> : null}
|
|
||||||
<Text fontSize="$xs" color={textStrong} fontWeight="700">
|
|
||||||
{valueText}
|
|
||||||
</Text>
|
|
||||||
</XStack>
|
|
||||||
</XStack>
|
|
||||||
<YStack height={6} borderRadius={999} backgroundColor={border} overflow="hidden">
|
|
||||||
<YStack height="100%" width={`${fill}%`} backgroundColor={hasUsage ? fillColor : subtle} />
|
|
||||||
</YStack>
|
|
||||||
{remainingText ? (
|
|
||||||
<Text fontSize="$xs" color={muted}>
|
|
||||||
{remainingText}
|
|
||||||
</Text>
|
|
||||||
) : null}
|
|
||||||
</YStack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatAmount(value: number | null | undefined, currency: string | null | undefined): string {
|
|
||||||
if (value === null || value === undefined) {
|
|
||||||
return '—';
|
|
||||||
}
|
|
||||||
const cur = currency ?? 'EUR';
|
|
||||||
try {
|
|
||||||
return new Intl.NumberFormat(undefined, { style: 'currency', currency: cur }).format(value);
|
|
||||||
} catch {
|
|
||||||
return `${value} ${cur}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function AddonRow({ addon }: { addon: TenantAddonHistoryEntry }) {
|
|
||||||
const { t } = useTranslation('management');
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const { border, textStrong, text, muted, subtle, primary } = useAdminTheme();
|
|
||||||
const labels: Record<TenantAddonHistoryEntry['status'], { tone: 'success' | 'warning' | 'muted'; text: string }> = {
|
|
||||||
completed: { tone: 'success', text: t('mobileBilling.status.completed', 'Completed') },
|
|
||||||
pending: { tone: 'warning', text: t('mobileBilling.status.pending', 'Pending') },
|
|
||||||
failed: { tone: 'muted', text: t('mobileBilling.status.failed', 'Failed') },
|
|
||||||
};
|
|
||||||
const status = labels[addon.status];
|
|
||||||
const eventName =
|
|
||||||
(addon.event?.name && typeof addon.event.name === 'string' && addon.event.name) ||
|
|
||||||
(addon.event?.name && typeof addon.event.name === 'object' ? addon.event.name?.en ?? addon.event.name?.de ?? Object.values(addon.event.name)[0] : null) ||
|
|
||||||
null;
|
|
||||||
const eventPath = addon.event?.slug ? ADMIN_EVENT_VIEW_PATH(addon.event.slug) : null;
|
|
||||||
const hasImpact = Boolean(addon.extra_photos || addon.extra_guests || addon.extra_gallery_days);
|
|
||||||
const impactBadges = hasImpact ? (
|
|
||||||
<XStack space="$2" marginTop="$1.5" flexWrap="wrap">
|
|
||||||
{addon.extra_photos ? (
|
|
||||||
<PillBadge tone="muted">{t('mobileBilling.extra.photos', '+{{count}} photos', { count: addon.extra_photos })}</PillBadge>
|
|
||||||
) : null}
|
|
||||||
{addon.extra_guests ? (
|
|
||||||
<PillBadge tone="muted">{t('mobileBilling.extra.guests', '+{{count}} guests', { count: addon.extra_guests })}</PillBadge>
|
|
||||||
) : null}
|
|
||||||
{addon.extra_gallery_days ? (
|
|
||||||
<PillBadge tone="muted">{t('mobileBilling.extra.days', '+{{count}} days', { count: addon.extra_gallery_days })}</PillBadge>
|
|
||||||
) : null}
|
|
||||||
</XStack>
|
|
||||||
) : null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<MobileCard borderColor={border} padding="$3" space="$1.5">
|
|
||||||
<XStack alignItems="center" justifyContent="space-between">
|
|
||||||
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
|
|
||||||
{addon.label ?? addon.addon_key}
|
|
||||||
</Text>
|
|
||||||
<PillBadge tone={status.tone}>{status.text}</PillBadge>
|
|
||||||
</XStack>
|
|
||||||
{eventName ? (
|
|
||||||
eventPath ? (
|
|
||||||
<Pressable onPress={() => navigate(eventPath)}>
|
|
||||||
<XStack alignItems="center" justifyContent="space-between">
|
|
||||||
<Text fontSize="$xs" color={textStrong} fontWeight="600">
|
|
||||||
{eventName}
|
|
||||||
</Text>
|
|
||||||
<Text fontSize="$xs" color={primary} fontWeight="700">
|
|
||||||
{t('mobileBilling.openEvent', 'Open event')}
|
|
||||||
</Text>
|
|
||||||
</XStack>
|
|
||||||
</Pressable>
|
|
||||||
) : (
|
|
||||||
<Text fontSize="$xs" color={subtle}>
|
|
||||||
{eventName}
|
|
||||||
</Text>
|
|
||||||
)
|
|
||||||
) : null}
|
|
||||||
{impactBadges}
|
|
||||||
<Text fontSize="$sm" color={text} marginTop="$1.5">
|
|
||||||
{formatAmount(addon.amount, addon.currency)}
|
|
||||||
</Text>
|
|
||||||
<Text fontSize="$xs" color={muted}>
|
|
||||||
{formatDate(addon.purchased_at)}
|
|
||||||
</Text>
|
|
||||||
</MobileCard>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
function formatDate(value: string | null | undefined): string {
|
|
||||||
if (!value) return '—';
|
|
||||||
const date = new Date(value);
|
|
||||||
if (Number.isNaN(date.getTime())) return '—';
|
|
||||||
return date.toLocaleDateString(undefined, { day: '2-digit', month: 'short', year: 'numeric' });
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useLocation, useNavigate } from 'react-router-dom';
|
import { useLocation, useNavigate, useParams } 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 { Bell, CheckCircle2, Download, Image as ImageIcon, ListTodo, MessageCircle, QrCode, Settings, ShieldCheck, Smartphone, Users, Sparkles, TrendingUp } from 'lucide-react';
|
import { Bell, CalendarDays, Camera, CheckCircle2, ChevronDown, Download, Image as ImageIcon, Layout, ListTodo, MapPin, Megaphone, MessageCircle, Pencil, QrCode, Settings, ShieldCheck, Smartphone, Sparkles, TrendingUp, Tv, Users } from 'lucide-react';
|
||||||
import { YStack, XStack } from '@tamagui/stacks';
|
import { YStack, XStack } from '@tamagui/stacks';
|
||||||
import { SizableText as Text } from '@tamagui/text';
|
import { SizableText as Text } from '@tamagui/text';
|
||||||
import { Pressable } from '@tamagui/react-native-web-lite';
|
import { Pressable } from '@tamagui/react-native-web-lite';
|
||||||
import { MobileShell, renderEventLocation } from './components/MobileShell';
|
import { MobileShell } from './components/MobileShell';
|
||||||
import { MobileCard, CTAButton, KpiTile, ActionTile, PillBadge, SkeletonCard } from './components/Primitives';
|
import { MobileCard, CTAButton, KpiTile, ActionTile, PillBadge, SkeletonCard } from './components/Primitives';
|
||||||
import { MobileSheet } from './components/Sheet';
|
import { MobileSheet } from './components/Sheet';
|
||||||
import { adminPath, ADMIN_WELCOME_BASE_PATH } from '../constants';
|
import { adminPath, ADMIN_WELCOME_BASE_PATH } from '../constants';
|
||||||
@@ -21,6 +21,7 @@ import { collectPackageFeatures, formatPackageLimit, getPackageFeatureLabel, get
|
|||||||
import { trackOnboarding } from '../api';
|
import { trackOnboarding } from '../api';
|
||||||
import { useAuth } from '../auth/context';
|
import { useAuth } from '../auth/context';
|
||||||
import { ADMIN_ACTION_COLORS, ADMIN_MOTION, useAdminTheme } from './theme';
|
import { ADMIN_ACTION_COLORS, ADMIN_MOTION, useAdminTheme } from './theme';
|
||||||
|
import { isPastEvent } from './eventDate';
|
||||||
|
|
||||||
type DeviceSetupProps = {
|
type DeviceSetupProps = {
|
||||||
installPrompt: ReturnType<typeof useInstallPrompt>;
|
installPrompt: ReturnType<typeof useInstallPrompt>;
|
||||||
@@ -32,6 +33,7 @@ type DeviceSetupProps = {
|
|||||||
export default function MobileDashboardPage() {
|
export default function MobileDashboardPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
const { slug: slugParam } = useParams<{ slug?: string }>();
|
||||||
const { t, i18n } = useTranslation('management');
|
const { t, i18n } = useTranslation('management');
|
||||||
const { events, activeEvent, hasEvents, hasMultipleEvents, isLoading, selectEvent } = useEventContext();
|
const { events, activeEvent, hasEvents, hasMultipleEvents, isLoading, selectEvent } = useEventContext();
|
||||||
const { status } = useAuth();
|
const { status } = useAuth();
|
||||||
@@ -42,11 +44,12 @@ export default function MobileDashboardPage() {
|
|||||||
const [tourStep, setTourStep] = React.useState(0);
|
const [tourStep, setTourStep] = React.useState(0);
|
||||||
const [summaryOpen, setSummaryOpen] = React.useState(false);
|
const [summaryOpen, setSummaryOpen] = React.useState(false);
|
||||||
const [summarySeenOverride, setSummarySeenOverride] = React.useState<number | null>(null);
|
const [summarySeenOverride, setSummarySeenOverride] = React.useState<number | null>(null);
|
||||||
|
const [eventSwitcherOpen, setEventSwitcherOpen] = React.useState(false);
|
||||||
const onboardingTrackedRef = React.useRef(false);
|
const onboardingTrackedRef = React.useRef(false);
|
||||||
const installPrompt = useInstallPrompt();
|
const installPrompt = useInstallPrompt();
|
||||||
const pushState = useAdminPushSubscription();
|
const pushState = useAdminPushSubscription();
|
||||||
const devicePermissions = useDevicePermissions();
|
const devicePermissions = useDevicePermissions();
|
||||||
const { textStrong, muted, border, surface, accentSoft, primary } = useAdminTheme();
|
const { textStrong, muted, accentSoft, primary } = useAdminTheme();
|
||||||
const text = textStrong;
|
const text = textStrong;
|
||||||
const accentText = primary;
|
const accentText = primary;
|
||||||
|
|
||||||
@@ -84,6 +87,14 @@ export default function MobileDashboardPage() {
|
|||||||
const tourTargetSlug = activeEvent?.slug ?? effectiveEvents[0]?.slug ?? null;
|
const tourTargetSlug = activeEvent?.slug ?? effectiveEvents[0]?.slug ?? null;
|
||||||
const tourStepKeys = React.useMemo(() => resolveTourStepKeys(effectiveHasEvents), [effectiveHasEvents]);
|
const tourStepKeys = React.useMemo(() => resolveTourStepKeys(effectiveHasEvents), [effectiveHasEvents]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!slugParam || slugParam === activeEvent?.slug) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
selectEvent(slugParam);
|
||||||
|
}, [activeEvent?.slug, selectEvent, slugParam]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (status !== 'authenticated' || onboardingTrackedRef.current) {
|
if (status !== 'authenticated' || onboardingTrackedRef.current) {
|
||||||
return;
|
return;
|
||||||
@@ -424,7 +435,7 @@ export default function MobileDashboardPage() {
|
|||||||
onOpen={() => setSummaryOpen(true)}
|
onOpen={() => setSummaryOpen(true)}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
<EventPickerList events={effectiveEvents} locale={locale} text={text} muted={muted} border={border} />
|
<EventPickerList events={effectiveEvents} locale={locale} navigateOnSelect={false} />
|
||||||
{tourSheet}
|
{tourSheet}
|
||||||
{packageSummarySheet}
|
{packageSummarySheet}
|
||||||
</MobileShell>
|
</MobileShell>
|
||||||
@@ -434,8 +445,7 @@ export default function MobileDashboardPage() {
|
|||||||
return (
|
return (
|
||||||
<MobileShell
|
<MobileShell
|
||||||
activeTab="home"
|
activeTab="home"
|
||||||
title={resolveEventDisplayName(activeEvent ?? undefined)}
|
title={t('mobileDashboard.title', 'Dashboard')}
|
||||||
subtitle={formatEventDate(activeEvent?.event_date, locale) ?? undefined}
|
|
||||||
>
|
>
|
||||||
{showPackageSummaryBanner ? (
|
{showPackageSummaryBanner ? (
|
||||||
<PackageSummaryBanner
|
<PackageSummaryBanner
|
||||||
@@ -443,28 +453,18 @@ export default function MobileDashboardPage() {
|
|||||||
onOpen={() => setSummaryOpen(true)}
|
onOpen={() => setSummaryOpen(true)}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
<DeviceSetupCard
|
<EventHeaderCard
|
||||||
installPrompt={installPrompt}
|
|
||||||
pushState={pushState}
|
|
||||||
devicePermissions={devicePermissions}
|
|
||||||
onOpenSettings={() => navigate(adminPath('/mobile/settings'))}
|
|
||||||
/>
|
|
||||||
<FeaturedActions
|
|
||||||
tasksEnabled={tasksEnabled}
|
|
||||||
onReviewPhotos={() => activeEvent?.slug && navigate(adminPath(`/mobile/events/${activeEvent.slug}/photos`))}
|
|
||||||
onManageTasks={() => activeEvent?.slug && navigate(adminPath(`/mobile/events/${activeEvent.slug}/tasks`))}
|
|
||||||
onShowQr={() => activeEvent?.slug && navigate(adminPath(`/mobile/events/${activeEvent.slug}/qr`))}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<SecondaryGrid
|
|
||||||
event={activeEvent}
|
event={activeEvent}
|
||||||
onGuests={() => activeEvent?.slug && navigate(adminPath(`/mobile/events/${activeEvent.slug}/members`))}
|
locale={locale}
|
||||||
onPrint={() => activeEvent?.slug && navigate(adminPath(`/mobile/events/${activeEvent.slug}/qr`))}
|
canSwitch={effectiveMultiple}
|
||||||
onInvites={() => activeEvent?.slug && navigate(adminPath(`/mobile/events/${activeEvent.slug}/members`))}
|
onSwitch={() => setEventSwitcherOpen(true)}
|
||||||
onSettings={() => activeEvent?.slug && navigate(adminPath(`/mobile/events/${activeEvent.slug}`))}
|
onEdit={() => activeEvent?.slug && navigate(adminPath(`/mobile/events/${activeEvent.slug}/edit`))}
|
||||||
onAnalytics={() => activeEvent?.slug && navigate(adminPath(`/mobile/events/${activeEvent.slug}/analytics`))}
|
/>
|
||||||
|
<EventManagementGrid
|
||||||
|
event={activeEvent}
|
||||||
|
tasksEnabled={tasksEnabled}
|
||||||
|
onNavigate={(path) => navigate(path)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<KpiStrip
|
<KpiStrip
|
||||||
event={activeEvent}
|
event={activeEvent}
|
||||||
stats={stats}
|
stats={stats}
|
||||||
@@ -474,8 +474,20 @@ export default function MobileDashboardPage() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<AlertsAndHints event={activeEvent} stats={stats} tasksEnabled={tasksEnabled} />
|
<AlertsAndHints event={activeEvent} stats={stats} tasksEnabled={tasksEnabled} />
|
||||||
|
<DeviceSetupCard
|
||||||
|
installPrompt={installPrompt}
|
||||||
|
pushState={pushState}
|
||||||
|
devicePermissions={devicePermissions}
|
||||||
|
onOpenSettings={() => navigate(adminPath('/mobile/settings'))}
|
||||||
|
/>
|
||||||
{tourSheet}
|
{tourSheet}
|
||||||
{packageSummarySheet}
|
{packageSummarySheet}
|
||||||
|
<EventSwitcherSheet
|
||||||
|
open={eventSwitcherOpen}
|
||||||
|
onClose={() => setEventSwitcherOpen(false)}
|
||||||
|
events={effectiveEvents}
|
||||||
|
locale={locale}
|
||||||
|
/>
|
||||||
</MobileShell>
|
</MobileShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -976,8 +988,20 @@ function OnboardingEmptyState({ installPrompt, pushState, devicePermissions, onO
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function EventPickerList({ events, locale, text, muted, border }: { events: TenantEvent[]; locale: string; text: string; muted: string; border: string }) {
|
function EventPickerList({
|
||||||
|
events,
|
||||||
|
locale,
|
||||||
|
onPick,
|
||||||
|
navigateOnSelect = true,
|
||||||
|
}: {
|
||||||
|
events: TenantEvent[];
|
||||||
|
locale: string;
|
||||||
|
onPick?: (event: TenantEvent) => void;
|
||||||
|
navigateOnSelect?: boolean;
|
||||||
|
}) {
|
||||||
const { t } = useTranslation('management');
|
const { t } = useTranslation('management');
|
||||||
|
const { textStrong, muted, border } = useAdminTheme();
|
||||||
|
const text = textStrong;
|
||||||
const { selectEvent } = useEventContext();
|
const { selectEvent } = useEventContext();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [localEvents, setLocalEvents] = React.useState<TenantEvent[]>(events);
|
const [localEvents, setLocalEvents] = React.useState<TenantEvent[]>(events);
|
||||||
@@ -1008,7 +1032,8 @@ function EventPickerList({ events, locale, text, muted, border }: { events: Tena
|
|||||||
key={event.slug}
|
key={event.slug}
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
selectEvent(event.slug ?? null);
|
selectEvent(event.slug ?? null);
|
||||||
if (event.slug) {
|
onPick?.(event);
|
||||||
|
if (navigateOnSelect && event.slug) {
|
||||||
navigate(adminPath(`/mobile/events/${event.slug}`));
|
navigate(adminPath(`/mobile/events/${event.slug}`));
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
@@ -1036,140 +1061,232 @@ function EventPickerList({ events, locale, text, muted, border }: { events: Tena
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function FeaturedActions({
|
function EventSwitcherSheet({
|
||||||
tasksEnabled,
|
open,
|
||||||
onReviewPhotos,
|
onClose,
|
||||||
onManageTasks,
|
events,
|
||||||
onShowQr,
|
locale,
|
||||||
}: {
|
}: {
|
||||||
tasksEnabled: boolean;
|
open: boolean;
|
||||||
onReviewPhotos: () => void;
|
onClose: () => void;
|
||||||
onManageTasks: () => void;
|
events: TenantEvent[];
|
||||||
onShowQr: () => void;
|
locale: string;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation('management');
|
const { t } = useTranslation('management');
|
||||||
const { textStrong, muted, subtle } = useAdminTheme();
|
|
||||||
const text = textStrong;
|
|
||||||
const cards = [
|
|
||||||
{
|
|
||||||
key: 'photos',
|
|
||||||
label: t('mobileDashboard.photosLabel', 'Review photos'),
|
|
||||||
desc: t('mobileDashboard.photosDesc', 'Moderate uploads and highlights'),
|
|
||||||
icon: ImageIcon,
|
|
||||||
color: ADMIN_ACTION_COLORS.images,
|
|
||||||
action: onReviewPhotos,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'tasks',
|
|
||||||
label: t('mobileDashboard.tasksLabel', 'Manage tasks & challenges'),
|
|
||||||
desc: tasksEnabled
|
|
||||||
? t('mobileDashboard.tasksDesc', 'Assign and track progress')
|
|
||||||
: t('mobileDashboard.tasksDisabledDesc', 'Guests do not see tasks (task mode off)'),
|
|
||||||
icon: ListTodo,
|
|
||||||
color: ADMIN_ACTION_COLORS.tasks,
|
|
||||||
action: onManageTasks,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'qr',
|
|
||||||
label: t('mobileDashboard.qrLabel', 'Show / share QR code'),
|
|
||||||
desc: t('mobileDashboard.qrDesc', 'Posters, cards, and links'),
|
|
||||||
icon: QrCode,
|
|
||||||
color: ADMIN_ACTION_COLORS.qr,
|
|
||||||
action: onShowQr,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<YStack space="$2">
|
<MobileSheet open={open} title={t('mobileDashboard.pickEvent', 'Select an event')} onClose={onClose}>
|
||||||
{cards.map((card) => (
|
<EventPickerList events={events} locale={locale} navigateOnSelect={false} onPick={onClose} />
|
||||||
<Pressable key={card.key} onPress={card.action}>
|
</MobileSheet>
|
||||||
<MobileCard borderColor={`${card.color}44`} backgroundColor={`${card.color}0f`} space="$2.5">
|
|
||||||
<XStack alignItems="center" space="$3">
|
|
||||||
<XStack width={44} height={44} borderRadius={14} backgroundColor={card.color} alignItems="center" justifyContent="center">
|
|
||||||
<card.icon size={20} color="white" />
|
|
||||||
</XStack>
|
|
||||||
<YStack space="$1" flex={1}>
|
|
||||||
<Text fontSize="$md" fontWeight="800" color={text}>
|
|
||||||
{card.label}
|
|
||||||
</Text>
|
|
||||||
<Text fontSize="$xs" color={muted}>
|
|
||||||
{card.desc}
|
|
||||||
</Text>
|
|
||||||
</YStack>
|
|
||||||
<Text fontSize="$xl" color={subtle}>
|
|
||||||
˃
|
|
||||||
</Text>
|
|
||||||
</XStack>
|
|
||||||
</MobileCard>
|
|
||||||
</Pressable>
|
|
||||||
))}
|
|
||||||
</YStack>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SecondaryGrid({
|
function resolveLocation(event: TenantEvent | null, t: (key: string, fallback: string) => string): string {
|
||||||
|
if (!event) return t('events.detail.locationPlaceholder', 'Location');
|
||||||
|
const settings = (event.settings ?? {}) as Record<string, unknown>;
|
||||||
|
const candidate =
|
||||||
|
(settings.location as string | undefined) ??
|
||||||
|
(settings.address as string | undefined) ??
|
||||||
|
(settings.city as string | undefined);
|
||||||
|
if (candidate && candidate.trim()) {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
return t('events.detail.locationPlaceholder', 'Location');
|
||||||
|
}
|
||||||
|
|
||||||
|
function EventHeaderCard({
|
||||||
event,
|
event,
|
||||||
onGuests,
|
locale,
|
||||||
onPrint,
|
canSwitch,
|
||||||
onInvites,
|
onSwitch,
|
||||||
onSettings,
|
onEdit,
|
||||||
onAnalytics,
|
|
||||||
}: {
|
}: {
|
||||||
event: TenantEvent | null;
|
event: TenantEvent | null;
|
||||||
onGuests: () => void;
|
locale: string;
|
||||||
onPrint: () => void;
|
canSwitch: boolean;
|
||||||
onInvites: () => void;
|
onSwitch: () => void;
|
||||||
onSettings: () => void;
|
onEdit: () => void;
|
||||||
onAnalytics: () => void;
|
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation('management');
|
const { t } = useTranslation('management');
|
||||||
const { textStrong, muted, border, surface, accentSoft, primary } = useAdminTheme();
|
const { textStrong, muted, border, surface, accentSoft, primary } = useAdminTheme();
|
||||||
const text = textStrong;
|
|
||||||
|
if (!event) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dateLabel = formatEventDate(event.event_date, locale) ?? t('events.detail.dateTbd', 'Date tbd');
|
||||||
|
const locationLabel = resolveLocation(event, t);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MobileCard space="$3" borderColor={border} backgroundColor={surface} position="relative">
|
||||||
|
<XStack alignItems="center" justifyContent="space-between" space="$2">
|
||||||
|
{canSwitch ? (
|
||||||
|
<Pressable onPress={onSwitch} aria-label={t('mobileDashboard.pickEvent', 'Select an event')}>
|
||||||
|
<XStack alignItems="center" space="$2">
|
||||||
|
<Text fontSize="$lg" fontWeight="800" color={textStrong}>
|
||||||
|
{resolveEventDisplayName(event)}
|
||||||
|
</Text>
|
||||||
|
<ChevronDown size={16} color={muted} />
|
||||||
|
</XStack>
|
||||||
|
</Pressable>
|
||||||
|
) : (
|
||||||
|
<Text fontSize="$lg" fontWeight="800" color={textStrong}>
|
||||||
|
{resolveEventDisplayName(event)}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<PillBadge tone={event.status === 'published' ? 'success' : 'warning'}>
|
||||||
|
{event.status === 'published'
|
||||||
|
? t('events.status.published', 'Live')
|
||||||
|
: t('events.status.draft', 'Draft')}
|
||||||
|
</PillBadge>
|
||||||
|
</XStack>
|
||||||
|
|
||||||
|
<XStack alignItems="center" space="$2">
|
||||||
|
<CalendarDays size={16} color={muted} />
|
||||||
|
<Text fontSize="$sm" color={muted}>
|
||||||
|
{dateLabel}
|
||||||
|
</Text>
|
||||||
|
<MapPin size={16} color={muted} />
|
||||||
|
<Text fontSize="$sm" color={muted}>
|
||||||
|
{locationLabel}
|
||||||
|
</Text>
|
||||||
|
</XStack>
|
||||||
|
|
||||||
|
<Pressable
|
||||||
|
aria-label={t('mobileEvents.edit', 'Edit event')}
|
||||||
|
onPress={onEdit}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
right: 16,
|
||||||
|
top: 16,
|
||||||
|
width: 44,
|
||||||
|
height: 44,
|
||||||
|
borderRadius: 22,
|
||||||
|
backgroundColor: accentSoft,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
boxShadow: '0 6px 16px rgba(0,0,0,0.12)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Pencil size={18} color={primary} />
|
||||||
|
</Pressable>
|
||||||
|
</MobileCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EventManagementGrid({
|
||||||
|
event,
|
||||||
|
tasksEnabled,
|
||||||
|
onNavigate,
|
||||||
|
}: {
|
||||||
|
event: TenantEvent | null;
|
||||||
|
tasksEnabled: boolean;
|
||||||
|
onNavigate: (path: string) => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation('management');
|
||||||
|
const { textStrong } = useAdminTheme();
|
||||||
|
const slug = event?.slug ?? null;
|
||||||
const brandingAllowed = isBrandingAllowed(event ?? null);
|
const brandingAllowed = isBrandingAllowed(event ?? null);
|
||||||
|
|
||||||
|
if (!event) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
const tiles = [
|
const tiles = [
|
||||||
|
{
|
||||||
|
icon: Pencil,
|
||||||
|
label: t('mobileDashboard.shortcutSettings', 'Event settings'),
|
||||||
|
color: ADMIN_ACTION_COLORS.settings,
|
||||||
|
onPress: slug ? () => onNavigate(adminPath(`/mobile/events/${slug}/edit`)) : undefined,
|
||||||
|
disabled: !slug,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Sparkles,
|
||||||
|
label: tasksEnabled
|
||||||
|
? t('events.quick.tasks', 'Tasks & Checklists')
|
||||||
|
: `${t('events.quick.tasks', 'Tasks & Checklists')} (${t('common:states.disabled', 'Disabled')})`,
|
||||||
|
color: ADMIN_ACTION_COLORS.tasks,
|
||||||
|
onPress: slug ? () => onNavigate(adminPath(`/mobile/events/${slug}/tasks`)) : undefined,
|
||||||
|
disabled: !tasksEnabled || !slug,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: QrCode,
|
||||||
|
label: t('events.quick.qr', 'QR Code Layouts'),
|
||||||
|
color: ADMIN_ACTION_COLORS.qr,
|
||||||
|
onPress: slug ? () => onNavigate(adminPath(`/mobile/events/${slug}/qr`)) : undefined,
|
||||||
|
disabled: !slug,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: ImageIcon,
|
||||||
|
label: t('events.quick.images', 'Image Management'),
|
||||||
|
color: ADMIN_ACTION_COLORS.images,
|
||||||
|
onPress: slug ? () => onNavigate(adminPath(`/mobile/events/${slug}/photos`)) : undefined,
|
||||||
|
disabled: !slug,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Tv,
|
||||||
|
label: t('events.quick.liveShow', 'Live Show queue'),
|
||||||
|
color: ADMIN_ACTION_COLORS.images,
|
||||||
|
onPress: slug ? () => onNavigate(adminPath(`/mobile/events/${slug}/live-show`)) : undefined,
|
||||||
|
disabled: !slug,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Settings,
|
||||||
|
label: t('events.quick.liveShowSettings', 'Live Show settings'),
|
||||||
|
color: ADMIN_ACTION_COLORS.images,
|
||||||
|
onPress: slug ? () => onNavigate(adminPath(`/mobile/events/${slug}/live-show/settings`)) : undefined,
|
||||||
|
disabled: !slug,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
icon: Users,
|
icon: Users,
|
||||||
label: t('mobileDashboard.shortcutGuests', 'Guest management'),
|
label: t('events.quick.guests', 'Guest Management'),
|
||||||
color: ADMIN_ACTION_COLORS.guests,
|
color: ADMIN_ACTION_COLORS.guests,
|
||||||
action: onGuests,
|
onPress: slug ? () => onNavigate(adminPath(`/mobile/events/${slug}/members`)) : undefined,
|
||||||
|
disabled: !slug,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Megaphone,
|
||||||
|
label: t('events.quick.guestMessages', 'Guest messages'),
|
||||||
|
color: ADMIN_ACTION_COLORS.guestMessages,
|
||||||
|
onPress: slug ? () => onNavigate(adminPath(`/mobile/events/${slug}/guest-notifications`)) : undefined,
|
||||||
|
disabled: !slug,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Layout,
|
||||||
|
label: t('events.quick.branding', 'Branding & Theme'),
|
||||||
|
color: ADMIN_ACTION_COLORS.branding,
|
||||||
|
onPress: slug && brandingAllowed ? () => onNavigate(adminPath(`/mobile/events/${slug}/branding`)) : undefined,
|
||||||
|
disabled: !brandingAllowed || !slug,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Camera,
|
||||||
|
label: t('events.quick.photobooth', 'Photobooth'),
|
||||||
|
color: ADMIN_ACTION_COLORS.photobooth,
|
||||||
|
onPress: slug ? () => onNavigate(adminPath(`/mobile/events/${slug}/photobooth`)) : undefined,
|
||||||
|
disabled: !slug,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: TrendingUp,
|
icon: TrendingUp,
|
||||||
label: t('mobileDashboard.shortcutAnalytics', 'Analytics'),
|
label: t('mobileDashboard.shortcutAnalytics', 'Analytics'),
|
||||||
color: ADMIN_ACTION_COLORS.analytics,
|
color: ADMIN_ACTION_COLORS.analytics,
|
||||||
action: onAnalytics,
|
onPress: slug ? () => onNavigate(adminPath(`/mobile/events/${slug}/analytics`)) : undefined,
|
||||||
},
|
disabled: !slug,
|
||||||
{
|
|
||||||
icon: QrCode,
|
|
||||||
label: t('mobileDashboard.shortcutPrints', 'Print & poster downloads'),
|
|
||||||
color: ADMIN_ACTION_COLORS.qr,
|
|
||||||
action: onPrint,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: Sparkles,
|
|
||||||
label: t('mobileDashboard.shortcutInvites', 'Team / helper invites'),
|
|
||||||
color: ADMIN_ACTION_COLORS.invites,
|
|
||||||
action: onInvites,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: Settings,
|
|
||||||
label: t('mobileDashboard.shortcutSettings', 'Event settings'),
|
|
||||||
color: ADMIN_ACTION_COLORS.success,
|
|
||||||
action: onSettings,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: Sparkles,
|
|
||||||
label: t('mobileDashboard.shortcutBranding', 'Branding & moderation'),
|
|
||||||
color: ADMIN_ACTION_COLORS.branding,
|
|
||||||
action: brandingAllowed ? onSettings : undefined,
|
|
||||||
disabled: !brandingAllowed,
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
if (event && isPastEvent(event.event_date)) {
|
||||||
|
tiles.push({
|
||||||
|
icon: Sparkles,
|
||||||
|
label: t('events.quick.recap', 'Recap & Archive'),
|
||||||
|
color: ADMIN_ACTION_COLORS.recap,
|
||||||
|
onPress: slug ? () => onNavigate(adminPath(`/mobile/events/${slug}/recap`)) : undefined,
|
||||||
|
disabled: !slug,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<YStack space="$2" marginTop="$2">
|
<YStack space="$2">
|
||||||
<Text fontSize="$sm" fontWeight="800" color={text}>
|
<Text fontSize="$sm" fontWeight="800" color={textStrong}>
|
||||||
{t('mobileDashboard.shortcutsTitle', 'Shortcuts')}
|
{t('events.detail.managementTitle', 'Event management')}
|
||||||
</Text>
|
</Text>
|
||||||
<XStack flexWrap="wrap" space="$2">
|
<XStack flexWrap="wrap" space="$2">
|
||||||
{tiles.map((tile, index) => (
|
{tiles.map((tile, index) => (
|
||||||
@@ -1178,22 +1295,12 @@ function SecondaryGrid({
|
|||||||
icon={tile.icon}
|
icon={tile.icon}
|
||||||
label={tile.label}
|
label={tile.label}
|
||||||
color={tile.color}
|
color={tile.color}
|
||||||
onPress={tile.action}
|
onPress={tile.onPress}
|
||||||
disabled={tile.disabled}
|
disabled={tile.disabled}
|
||||||
delayMs={index * ADMIN_MOTION.tileStaggerMs}
|
delayMs={index * ADMIN_MOTION.tileStaggerMs}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</XStack>
|
</XStack>
|
||||||
{event ? (
|
|
||||||
<MobileCard backgroundColor={surface} borderColor={border} space="$1.5">
|
|
||||||
<Text fontSize="$sm" fontWeight="700" color={text}>
|
|
||||||
{resolveEventDisplayName(event)}
|
|
||||||
</Text>
|
|
||||||
<Text fontSize="$xs" color={muted}>
|
|
||||||
{renderEventLocation(event)}
|
|
||||||
</Text>
|
|
||||||
</MobileCard>
|
|
||||||
) : null}
|
|
||||||
</YStack>
|
</YStack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,25 +2,24 @@ 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 { BarChart2, TrendingUp, Users, ListTodo, Lock, Trophy, Calendar } from 'lucide-react';
|
import { TrendingUp, Users, ListTodo, Lock, Trophy } from 'lucide-react';
|
||||||
import { YStack, XStack } from '@tamagui/stacks';
|
import { 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, SkeletonCard } from './components/Primitives';
|
import { MobileCard, CTAButton, KpiTile, 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';
|
||||||
import { useEventContext } from '../context/EventContext';
|
|
||||||
|
|
||||||
export default function MobileEventAnalyticsPage() {
|
export default function MobileEventAnalyticsPage() {
|
||||||
const { slug } = useParams<{ slug: string }>();
|
const { slug } = useParams<{ slug: string }>();
|
||||||
const { t, i18n } = useTranslation('management');
|
const { t, i18n } = useTranslation('management');
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { activeEvent } = useEventContext();
|
|
||||||
const { textStrong, muted, border, surface, primary, accentSoft } = useAdminTheme();
|
const { textStrong, muted, border, surface, primary, accentSoft } = useAdminTheme();
|
||||||
|
|
||||||
const dateLocale = i18n.language.startsWith('de') ? de : enGB;
|
const dateLocale = i18n.language.startsWith('de') ? de : enGB;
|
||||||
@@ -36,7 +35,7 @@ export default function MobileEventAnalyticsPage() {
|
|||||||
|
|
||||||
if (isFeatureLocked) {
|
if (isFeatureLocked) {
|
||||||
return (
|
return (
|
||||||
<MobileShell title={t('analytics.title', 'Analytics')} activeTab="events">
|
<MobileShell title={t('analytics.title', 'Analytics')} activeTab="home">
|
||||||
<MobileCard
|
<MobileCard
|
||||||
space="$4"
|
space="$4"
|
||||||
padding="$6"
|
padding="$6"
|
||||||
@@ -75,7 +74,7 @@ export default function MobileEventAnalyticsPage() {
|
|||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<MobileShell title={t('analytics.title', 'Analytics')} activeTab="events">
|
<MobileShell title={t('analytics.title', 'Analytics')} activeTab="home">
|
||||||
<YStack space="$3">
|
<YStack space="$3">
|
||||||
<SkeletonCard height={200} />
|
<SkeletonCard height={200} />
|
||||||
<SkeletonCard height={150} />
|
<SkeletonCard height={150} />
|
||||||
@@ -87,7 +86,7 @@ export default function MobileEventAnalyticsPage() {
|
|||||||
|
|
||||||
if (error || !data) {
|
if (error || !data) {
|
||||||
return (
|
return (
|
||||||
<MobileShell title={t('analytics.title', 'Analytics')} activeTab="events">
|
<MobileShell title={t('analytics.title', 'Analytics')} activeTab="home">
|
||||||
<MobileCard borderColor={border} padding="$4">
|
<MobileCard borderColor={border} padding="$4">
|
||||||
<Text color={muted}>{t('common.error', 'Something went wrong')}</Text>
|
<Text color={muted}>{t('common.error', 'Something went wrong')}</Text>
|
||||||
</MobileCard>
|
</MobileCard>
|
||||||
@@ -99,18 +98,47 @@ 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 maxCount = Math.max(...timeline.map((p) => p.count), 1);
|
const maxTimelineCount = resolveMaxCount(timeline.map((point) => point.count));
|
||||||
|
const maxTaskCount = resolveMaxCount(tasks.map((task) => task.count));
|
||||||
|
const totalUploads = timeline.reduce((total, point) => total + point.count, 0);
|
||||||
|
const totalLikes = contributors.reduce((total, contributor) => total + contributor.likes, 0);
|
||||||
|
const totalContributors = contributors.length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MobileShell
|
<MobileShell
|
||||||
title={t('analytics.title', 'Analytics')}
|
title={t('analytics.title', 'Analytics')}
|
||||||
subtitle={activeEvent?.name as string}
|
activeTab="home"
|
||||||
activeTab="events"
|
onBack={() => navigate(-1)}
|
||||||
showBack
|
|
||||||
>
|
>
|
||||||
<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">
|
||||||
@@ -119,12 +147,22 @@ 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 / maxCount) * 100;
|
const heightPercent = (point.count / maxTimelineCount) * 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;
|
||||||
@@ -141,7 +179,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')}
|
{format(date, 'HH:mm', { locale: dateLocale })}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</YStack>
|
</YStack>
|
||||||
@@ -153,7 +191,11 @@ export default function MobileEventAnalyticsPage() {
|
|||||||
</Text>
|
</Text>
|
||||||
</YStack>
|
</YStack>
|
||||||
) : (
|
) : (
|
||||||
<EmptyState message={t('analytics.noActivity', 'No uploads yet')} />
|
<EmptyState
|
||||||
|
message={t('analytics.noActivity', 'No uploads yet')}
|
||||||
|
actionLabel={t('analytics.emptyActionShareQr', 'Share your QR code')}
|
||||||
|
onAction={() => slug && navigate(adminPath(`/mobile/events/${slug}/qr`))}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</MobileCard>
|
</MobileCard>
|
||||||
|
|
||||||
@@ -199,7 +241,11 @@ export default function MobileEventAnalyticsPage() {
|
|||||||
))}
|
))}
|
||||||
</YStack>
|
</YStack>
|
||||||
) : (
|
) : (
|
||||||
<EmptyState message={t('analytics.noContributors', 'No contributors yet')} />
|
<EmptyState
|
||||||
|
message={t('analytics.noContributors', 'No contributors yet')}
|
||||||
|
actionLabel={t('analytics.emptyActionInvite', 'Invite guests')}
|
||||||
|
onAction={() => slug && navigate(adminPath(`/mobile/events/${slug}/members`))}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</MobileCard>
|
</MobileCard>
|
||||||
|
|
||||||
@@ -215,7 +261,6 @@ 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">
|
||||||
@@ -240,7 +285,11 @@ export default function MobileEventAnalyticsPage() {
|
|||||||
})}
|
})}
|
||||||
</YStack>
|
</YStack>
|
||||||
) : (
|
) : (
|
||||||
<EmptyState message={t('analytics.noTasks', 'No task activity yet')} />
|
<EmptyState
|
||||||
|
message={t('analytics.noTasks', 'No task activity yet')}
|
||||||
|
actionLabel={t('analytics.emptyActionOpenTasks', 'Open tasks')}
|
||||||
|
onAction={() => slug && navigate(adminPath(`/mobile/events/${slug}/tasks`))}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</MobileCard>
|
</MobileCard>
|
||||||
</YStack>
|
</YStack>
|
||||||
@@ -248,13 +297,24 @@ export default function MobileEventAnalyticsPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function EmptyState({ message }: { message: string }) {
|
function EmptyState({
|
||||||
|
message,
|
||||||
|
actionLabel,
|
||||||
|
onAction,
|
||||||
|
}: {
|
||||||
|
message: string;
|
||||||
|
actionLabel?: string;
|
||||||
|
onAction?: () => void;
|
||||||
|
}) {
|
||||||
const { muted } = useAdminTheme();
|
const { muted } = useAdminTheme();
|
||||||
return (
|
return (
|
||||||
<YStack padding="$4" alignItems="center" justifyContent="center">
|
<YStack padding="$4" alignItems="center" justifyContent="center" space="$2">
|
||||||
<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,343 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { useNavigate, useParams } from 'react-router-dom';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { CalendarDays, MapPin, Settings, Users, Camera, Sparkles, QrCode, Image, Shield, Layout, RefreshCcw, Pencil, Megaphone, Tv } from 'lucide-react';
|
|
||||||
import { YStack, XStack } from '@tamagui/stacks';
|
|
||||||
import { SizableText as Text } from '@tamagui/text';
|
|
||||||
import { Pressable } from '@tamagui/react-native-web-lite';
|
|
||||||
import { MobileShell, HeaderActionButton } from './components/MobileShell';
|
|
||||||
import { MobileCard, PillBadge, KpiTile, ActionTile } from './components/Primitives';
|
|
||||||
import { TenantEvent, EventStats, EventToolkit, getEvent, getEventStats, getEventToolkit, getEvents } from '../api';
|
|
||||||
import { adminPath, ADMIN_EVENT_BRANDING_PATH, ADMIN_EVENT_GUEST_NOTIFICATIONS_PATH, ADMIN_EVENT_INVITES_PATH, ADMIN_EVENT_LIVE_SHOW_PATH, ADMIN_EVENT_LIVE_SHOW_SETTINGS_PATH, ADMIN_EVENT_MEMBERS_PATH, ADMIN_EVENT_PHOTOS_PATH, ADMIN_EVENT_TASKS_PATH } from '../constants';
|
|
||||||
import { isAuthError } from '../auth/tokens';
|
|
||||||
import { getApiErrorMessage } from '../lib/apiError';
|
|
||||||
import { MobileSheet } from './components/Sheet';
|
|
||||||
import { useEventContext } from '../context/EventContext';
|
|
||||||
import { formatEventDate, isBrandingAllowed, resolveEngagementMode, resolveEventDisplayName } from '../lib/events';
|
|
||||||
import { isPastEvent } from './eventDate';
|
|
||||||
import { useBackNavigation } from './hooks/useBackNavigation';
|
|
||||||
import { ADMIN_ACTION_COLORS, ADMIN_MOTION, useAdminTheme } from './theme';
|
|
||||||
|
|
||||||
export default function MobileEventDetailPage() {
|
|
||||||
const { slug: slugParam } = useParams<{ slug?: string }>();
|
|
||||||
const slug = slugParam ?? null;
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const { t } = useTranslation('management');
|
|
||||||
|
|
||||||
const [event, setEvent] = React.useState<TenantEvent | null>(null);
|
|
||||||
const [stats, setStats] = React.useState<EventStats | null>(null);
|
|
||||||
const [toolkit, setToolkit] = React.useState<EventToolkit | null>(null);
|
|
||||||
const [loading, setLoading] = React.useState(true);
|
|
||||||
const [error, setError] = React.useState<string | null>(null);
|
|
||||||
const { events, activeEvent, selectEvent } = useEventContext();
|
|
||||||
const [showEventPicker, setShowEventPicker] = React.useState(false);
|
|
||||||
const back = useBackNavigation(adminPath('/mobile/events'));
|
|
||||||
const { textStrong, text, muted, danger, accentSoft } = useAdminTheme();
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (!slug) return;
|
|
||||||
selectEvent(slug);
|
|
||||||
}, [slug, selectEvent]);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (!slug) return;
|
|
||||||
(async () => {
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
const [eventData, statsData, toolkitData] = await Promise.all([getEvent(slug), getEventStats(slug), getEventToolkit(slug)]);
|
|
||||||
setEvent(eventData);
|
|
||||||
setStats(statsData);
|
|
||||||
setToolkit(toolkitData);
|
|
||||||
setError(null);
|
|
||||||
} catch (err) {
|
|
||||||
if (!isAuthError(err)) {
|
|
||||||
try {
|
|
||||||
const list = await getEvents({ force: true });
|
|
||||||
const fallback = list.find((ev: TenantEvent) => ev.slug === slug) ?? null;
|
|
||||||
if (fallback) {
|
|
||||||
setEvent(fallback);
|
|
||||||
setError(null);
|
|
||||||
} else {
|
|
||||||
setError(getApiErrorMessage(err, t('events.errors.loadFailed', 'Event konnte nicht geladen werden.')));
|
|
||||||
}
|
|
||||||
} catch (fallbackErr) {
|
|
||||||
setError(getApiErrorMessage(fallbackErr, t('events.errors.loadFailed', 'Event konnte nicht geladen werden.')));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
}, [slug, t]);
|
|
||||||
|
|
||||||
const tasksEnabled = resolveEngagementMode(event ?? activeEvent ?? null) !== 'photo_only';
|
|
||||||
const brandingAllowed = isBrandingAllowed(event ?? activeEvent ?? null);
|
|
||||||
|
|
||||||
const kpis = [
|
|
||||||
{
|
|
||||||
label: t('events.detail.kpi.guests', 'Guests Registered'),
|
|
||||||
value: toolkit?.invites?.summary.total ?? event?.active_invites_count ?? '—',
|
|
||||||
icon: Users,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: t('events.detail.kpi.photos', 'Images Uploaded'),
|
|
||||||
value: stats?.uploads_total ?? event?.photo_count ?? '—',
|
|
||||||
icon: Camera,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
if (tasksEnabled) {
|
|
||||||
kpis.unshift({
|
|
||||||
label: t('events.detail.kpi.tasks', 'Active Tasks'),
|
|
||||||
value: event?.tasks_count ?? toolkit?.tasks?.summary?.total ?? '—',
|
|
||||||
icon: Sparkles,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<MobileShell
|
|
||||||
activeTab="home"
|
|
||||||
title={resolveEventDisplayName(event ?? activeEvent ?? undefined)}
|
|
||||||
subtitle={
|
|
||||||
event?.event_date || activeEvent?.event_date
|
|
||||||
? formatDate(event?.event_date ?? activeEvent?.event_date, t)
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
onBack={back}
|
|
||||||
headerActions={
|
|
||||||
<XStack space="$3" alignItems="center">
|
|
||||||
<HeaderActionButton onPress={() => navigate(adminPath('/mobile/settings'))} ariaLabel={t('mobileSettings.title', 'Settings')}>
|
|
||||||
<Settings size={18} color={textStrong} />
|
|
||||||
</HeaderActionButton>
|
|
||||||
<HeaderActionButton onPress={() => navigate(0)} ariaLabel={t('common.refresh', 'Refresh')}>
|
|
||||||
<RefreshCcw size={18} color={textStrong} />
|
|
||||||
</HeaderActionButton>
|
|
||||||
</XStack>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{error ? (
|
|
||||||
<MobileCard>
|
|
||||||
<Text fontWeight="700" color={danger}>
|
|
||||||
{error}
|
|
||||||
</Text>
|
|
||||||
</MobileCard>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<MobileCard space="$3">
|
|
||||||
<Text fontSize="$lg" fontWeight="800" color={textStrong}>
|
|
||||||
{event ? renderName(event.name, t) : t('events.placeholders.untitled', 'Unbenanntes Event')}
|
|
||||||
</Text>
|
|
||||||
<XStack alignItems="center" space="$2">
|
|
||||||
<CalendarDays size={16} color={muted} />
|
|
||||||
<Text fontSize="$sm" color={muted}>
|
|
||||||
{formatDate(event?.event_date, t)}
|
|
||||||
</Text>
|
|
||||||
<MapPin size={16} color={muted} />
|
|
||||||
<Text fontSize="$sm" color={muted}>
|
|
||||||
{resolveLocation(event, t)}
|
|
||||||
</Text>
|
|
||||||
</XStack>
|
|
||||||
<PillBadge tone={event?.status === 'published' ? 'success' : 'warning'}>
|
|
||||||
{event?.status === 'published' ? t('events.status.published', 'Live') : t('events.status.draft', 'Draft')}
|
|
||||||
</PillBadge>
|
|
||||||
<Pressable
|
|
||||||
aria-label={t('mobileEvents.edit', 'Edit event')}
|
|
||||||
onPress={() => slug && navigate(adminPath(`/mobile/events/${slug}/edit`))}
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
right: 16,
|
|
||||||
top: 16,
|
|
||||||
width: 44,
|
|
||||||
height: 44,
|
|
||||||
borderRadius: 22,
|
|
||||||
backgroundColor: accentSoft,
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
boxShadow: '0 6px 16px rgba(0,0,0,0.12)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Pencil size={18} color={textStrong} />
|
|
||||||
</Pressable>
|
|
||||||
</MobileCard>
|
|
||||||
|
|
||||||
<YStack space="$2">
|
|
||||||
{loading ? (
|
|
||||||
<XStack space="$2" flexWrap="wrap">
|
|
||||||
{Array.from({ length: 3 }).map((_, idx) => (
|
|
||||||
<MobileCard key={`kpi-${idx}`} height={90} width="32%" />
|
|
||||||
))}
|
|
||||||
</XStack>
|
|
||||||
) : (
|
|
||||||
<XStack space="$2" flexWrap="wrap">
|
|
||||||
{kpis.map((kpi) => (
|
|
||||||
<KpiTile key={kpi.label} icon={kpi.icon} label={kpi.label} value={kpi.value} />
|
|
||||||
))}
|
|
||||||
</XStack>
|
|
||||||
)}
|
|
||||||
</YStack>
|
|
||||||
|
|
||||||
<MobileSheet
|
|
||||||
open={showEventPicker}
|
|
||||||
onClose={() => setShowEventPicker(false)}
|
|
||||||
title={t('events.detail.pickEvent', 'Event wählen')}
|
|
||||||
footer={null}
|
|
||||||
bottomOffsetPx={120}
|
|
||||||
>
|
|
||||||
<YStack space="$2">
|
|
||||||
{events.length === 0 ? (
|
|
||||||
<Text fontSize={12.5} color={muted}>
|
|
||||||
{t('events.list.empty.description', 'Starte jetzt mit deinem ersten Event.')}
|
|
||||||
</Text>
|
|
||||||
) : (
|
|
||||||
events.map((ev) => (
|
|
||||||
<Pressable
|
|
||||||
key={ev.slug}
|
|
||||||
onPress={() => {
|
|
||||||
selectEvent(ev.slug ?? null);
|
|
||||||
setShowEventPicker(false);
|
|
||||||
navigate(adminPath(`/mobile/events/${ev.slug}`));
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<XStack alignItems="center" justifyContent="space-between" paddingVertical="$2">
|
|
||||||
<YStack space="$1">
|
|
||||||
<Text fontSize={13} fontWeight="700" color={textStrong}>
|
|
||||||
{renderName(ev.name, t)}
|
|
||||||
</Text>
|
|
||||||
<XStack alignItems="center" space="$1.5">
|
|
||||||
<CalendarDays size={14} color={muted} />
|
|
||||||
<Text fontSize={12} color={muted}>
|
|
||||||
{formatDate(ev.event_date, t)}
|
|
||||||
</Text>
|
|
||||||
</XStack>
|
|
||||||
</YStack>
|
|
||||||
<PillBadge tone={ev.slug === activeEvent?.slug ? 'success' : 'muted'}>
|
|
||||||
{ev.slug === activeEvent?.slug ? t('events.detail.active', 'Aktiv') : t('events.actions.open', 'Öffnen')}
|
|
||||||
</PillBadge>
|
|
||||||
</XStack>
|
|
||||||
</Pressable>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</YStack>
|
|
||||||
</MobileSheet>
|
|
||||||
|
|
||||||
<YStack space="$2">
|
|
||||||
<Text fontSize="$md" fontWeight="800" color={textStrong}>
|
|
||||||
{t('events.detail.managementTitle', 'Event Management')}
|
|
||||||
</Text>
|
|
||||||
<XStack flexWrap="wrap" space="$2">
|
|
||||||
<ActionTile
|
|
||||||
icon={Sparkles}
|
|
||||||
label={
|
|
||||||
tasksEnabled
|
|
||||||
? t('events.quick.tasks', 'Tasks & Checklists')
|
|
||||||
: `${t('events.quick.tasks', 'Tasks & Checklists')} (${t('common:states.disabled', 'Disabled')})`
|
|
||||||
}
|
|
||||||
color={ADMIN_ACTION_COLORS.tasks}
|
|
||||||
onPress={() => navigate(adminPath(`/mobile/events/${slug ?? ''}/tasks`))}
|
|
||||||
delayMs={0}
|
|
||||||
/>
|
|
||||||
<ActionTile
|
|
||||||
icon={QrCode}
|
|
||||||
label={t('events.quick.qr', 'QR Code Layouts')}
|
|
||||||
color={ADMIN_ACTION_COLORS.qr}
|
|
||||||
onPress={() => navigate(adminPath(`/mobile/events/${slug ?? ''}/qr`))}
|
|
||||||
delayMs={ADMIN_MOTION.tileStaggerMs}
|
|
||||||
/>
|
|
||||||
<ActionTile
|
|
||||||
icon={Image}
|
|
||||||
label={t('events.quick.images', 'Image Management')}
|
|
||||||
color={ADMIN_ACTION_COLORS.images}
|
|
||||||
onPress={() => navigate(adminPath(`/mobile/events/${slug ?? ''}/photos`))}
|
|
||||||
delayMs={ADMIN_MOTION.tileStaggerMs * 2}
|
|
||||||
/>
|
|
||||||
<ActionTile
|
|
||||||
icon={Tv}
|
|
||||||
label={t('events.quick.liveShow', 'Live Show queue')}
|
|
||||||
color={ADMIN_ACTION_COLORS.images}
|
|
||||||
onPress={() => slug && navigate(ADMIN_EVENT_LIVE_SHOW_PATH(slug))}
|
|
||||||
disabled={!slug}
|
|
||||||
delayMs={ADMIN_MOTION.tileStaggerMs * 3}
|
|
||||||
/>
|
|
||||||
<ActionTile
|
|
||||||
icon={Settings}
|
|
||||||
label={t('events.quick.liveShowSettings', 'Live Show settings')}
|
|
||||||
color={ADMIN_ACTION_COLORS.images}
|
|
||||||
onPress={() => slug && navigate(ADMIN_EVENT_LIVE_SHOW_SETTINGS_PATH(slug))}
|
|
||||||
disabled={!slug}
|
|
||||||
delayMs={ADMIN_MOTION.tileStaggerMs * 4}
|
|
||||||
/>
|
|
||||||
<ActionTile
|
|
||||||
icon={Users}
|
|
||||||
label={t('events.quick.guests', 'Guest Management')}
|
|
||||||
color={ADMIN_ACTION_COLORS.guests}
|
|
||||||
onPress={() => navigate(adminPath(`/mobile/events/${slug ?? ''}/members`))}
|
|
||||||
delayMs={ADMIN_MOTION.tileStaggerMs * 5}
|
|
||||||
/>
|
|
||||||
<ActionTile
|
|
||||||
icon={Megaphone}
|
|
||||||
label={t('events.quick.guestMessages', 'Guest messages')}
|
|
||||||
color={ADMIN_ACTION_COLORS.guestMessages}
|
|
||||||
onPress={() => slug && navigate(ADMIN_EVENT_GUEST_NOTIFICATIONS_PATH(slug))}
|
|
||||||
disabled={!slug}
|
|
||||||
delayMs={ADMIN_MOTION.tileStaggerMs * 6}
|
|
||||||
/>
|
|
||||||
<ActionTile
|
|
||||||
icon={Layout}
|
|
||||||
label={t('events.quick.branding', 'Branding & Theme')}
|
|
||||||
color={ADMIN_ACTION_COLORS.branding}
|
|
||||||
onPress={
|
|
||||||
brandingAllowed ? () => navigate(adminPath(`/mobile/events/${slug ?? ''}/branding`)) : undefined
|
|
||||||
}
|
|
||||||
disabled={!brandingAllowed}
|
|
||||||
delayMs={ADMIN_MOTION.tileStaggerMs * 7}
|
|
||||||
/>
|
|
||||||
<ActionTile
|
|
||||||
icon={Camera}
|
|
||||||
label={t('events.quick.photobooth', 'Photobooth')}
|
|
||||||
color={ADMIN_ACTION_COLORS.photobooth}
|
|
||||||
onPress={() => navigate(adminPath(`/mobile/events/${slug ?? ''}/photobooth`))}
|
|
||||||
delayMs={ADMIN_MOTION.tileStaggerMs * 8}
|
|
||||||
/>
|
|
||||||
{isPastEvent(event?.event_date) ? (
|
|
||||||
<ActionTile
|
|
||||||
icon={Sparkles}
|
|
||||||
label={t('events.quick.recap', 'Recap & Archive')}
|
|
||||||
color={ADMIN_ACTION_COLORS.recap}
|
|
||||||
onPress={() => navigate(adminPath(`/mobile/events/${slug ?? ''}/recap`))}
|
|
||||||
delayMs={ADMIN_MOTION.tileStaggerMs * 9}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</XStack>
|
|
||||||
</YStack>
|
|
||||||
</MobileShell>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderName(name: TenantEvent['name'], t: (key: string, fallback: string) => string): string {
|
|
||||||
const fallback = t('events.placeholders.untitled', 'Untitled event');
|
|
||||||
if (typeof name === 'string' && name.trim()) return name;
|
|
||||||
if (name && typeof name === 'object') {
|
|
||||||
return name.de ?? name.en ?? Object.values(name)[0] ?? fallback;
|
|
||||||
}
|
|
||||||
return fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDate(iso: string | null | undefined, t: (key: string, fallback: string) => string): string {
|
|
||||||
if (!iso) return t('events.detail.dateTbd', 'Date tbd');
|
|
||||||
const date = new Date(iso);
|
|
||||||
if (Number.isNaN(date.getTime())) return t('events.detail.dateTbd', 'Date tbd');
|
|
||||||
return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveLocation(event: TenantEvent | null, t: (key: string, fallback: string) => string): string {
|
|
||||||
if (!event) return t('events.detail.locationPlaceholder', 'Location');
|
|
||||||
const settings = (event.settings ?? {}) as Record<string, unknown>;
|
|
||||||
const candidate =
|
|
||||||
(settings.location as string | undefined) ??
|
|
||||||
(settings.address as string | undefined) ??
|
|
||||||
(settings.city as string | undefined);
|
|
||||||
if (candidate && candidate.trim()) {
|
|
||||||
return candidate;
|
|
||||||
}
|
|
||||||
return t('events.detail.locationPlaceholder', 'Location');
|
|
||||||
}
|
|
||||||
@@ -9,15 +9,16 @@ import { MobileShell } from './components/MobileShell';
|
|||||||
import { MobileCard, CTAButton } from './components/Primitives';
|
import { MobileCard, CTAButton } from './components/Primitives';
|
||||||
import { MobileField, MobileInput, MobileSelect, MobileTextArea } from './components/FormControls';
|
import { MobileField, MobileInput, MobileSelect, MobileTextArea } from './components/FormControls';
|
||||||
import { LegalConsentSheet } from './components/LegalConsentSheet';
|
import { LegalConsentSheet } from './components/LegalConsentSheet';
|
||||||
import { createEvent, getEvent, updateEvent, getEventTypes, TenantEvent, TenantEventType, trackOnboarding } from '../api';
|
import { createEvent, getEvent, updateEvent, getEventTypes, getPackages, Package, TenantEvent, TenantEventType, trackOnboarding } from '../api';
|
||||||
import { resolveEventSlugAfterUpdate } from './eventFormNavigation';
|
import { resolveEventSlugAfterUpdate } from './eventFormNavigation';
|
||||||
import { adminPath } from '../constants';
|
import { adminPath } from '../constants';
|
||||||
import { isAuthError } from '../auth/tokens';
|
import { isAuthError } from '../auth/tokens';
|
||||||
import { getApiValidationMessage, isApiError } from '../lib/apiError';
|
import { getApiErrorMessage, getApiValidationMessage, isApiError } from '../lib/apiError';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
import { useBackNavigation } from './hooks/useBackNavigation';
|
import { useBackNavigation } from './hooks/useBackNavigation';
|
||||||
import { useAdminTheme } from './theme';
|
import { useAdminTheme } from './theme';
|
||||||
import { withAlpha } from './components/colors';
|
import { withAlpha } from './components/colors';
|
||||||
|
import { useAuth } from '../auth/context';
|
||||||
|
|
||||||
type FormState = {
|
type FormState = {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -28,6 +29,7 @@ type FormState = {
|
|||||||
published: boolean;
|
published: boolean;
|
||||||
autoApproveUploads: boolean;
|
autoApproveUploads: boolean;
|
||||||
tasksEnabled: boolean;
|
tasksEnabled: boolean;
|
||||||
|
packageId: number | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function MobileEventFormPage() {
|
export default function MobileEventFormPage() {
|
||||||
@@ -36,7 +38,9 @@ export default function MobileEventFormPage() {
|
|||||||
const isEdit = Boolean(slug);
|
const isEdit = Boolean(slug);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { t } = useTranslation(['management', 'common']);
|
const { t } = useTranslation(['management', 'common']);
|
||||||
|
const { user } = useAuth();
|
||||||
const { text, muted, subtle, danger, border, surface, primary } = useAdminTheme();
|
const { text, muted, subtle, danger, border, surface, primary } = useAdminTheme();
|
||||||
|
const isSuperAdmin = user?.role === 'super_admin' || user?.role === 'superadmin';
|
||||||
|
|
||||||
const [form, setForm] = React.useState<FormState>({
|
const [form, setForm] = React.useState<FormState>({
|
||||||
name: '',
|
name: '',
|
||||||
@@ -47,9 +51,12 @@ export default function MobileEventFormPage() {
|
|||||||
published: false,
|
published: false,
|
||||||
autoApproveUploads: true,
|
autoApproveUploads: true,
|
||||||
tasksEnabled: true,
|
tasksEnabled: true,
|
||||||
|
packageId: null,
|
||||||
});
|
});
|
||||||
const [eventTypes, setEventTypes] = React.useState<TenantEventType[]>([]);
|
const [eventTypes, setEventTypes] = React.useState<TenantEventType[]>([]);
|
||||||
const [typesLoading, setTypesLoading] = React.useState(false);
|
const [typesLoading, setTypesLoading] = React.useState(false);
|
||||||
|
const [packages, setPackages] = React.useState<Package[]>([]);
|
||||||
|
const [packagesLoading, setPackagesLoading] = React.useState(false);
|
||||||
const [loading, setLoading] = React.useState(isEdit);
|
const [loading, setLoading] = React.useState(isEdit);
|
||||||
const [saving, setSaving] = React.useState(false);
|
const [saving, setSaving] = React.useState(false);
|
||||||
const [consentOpen, setConsentOpen] = React.useState(false);
|
const [consentOpen, setConsentOpen] = React.useState(false);
|
||||||
@@ -76,6 +83,7 @@ export default function MobileEventFormPage() {
|
|||||||
tasksEnabled:
|
tasksEnabled:
|
||||||
(data.settings?.engagement_mode as string | undefined) !== 'photo_only' &&
|
(data.settings?.engagement_mode as string | undefined) !== 'photo_only' &&
|
||||||
(data.engagement_mode as string | undefined) !== 'photo_only',
|
(data.engagement_mode as string | undefined) !== 'photo_only',
|
||||||
|
packageId: null,
|
||||||
});
|
});
|
||||||
setError(null);
|
setError(null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -106,6 +114,31 @@ export default function MobileEventFormPage() {
|
|||||||
})();
|
})();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!isSuperAdmin || isEdit) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
setPackagesLoading(true);
|
||||||
|
try {
|
||||||
|
const data = await getPackages('endcustomer');
|
||||||
|
setPackages(data);
|
||||||
|
setForm((prev) => {
|
||||||
|
if (prev.packageId) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
const preferred = data.find((pkg) => pkg.id === 3) ?? data[0] ?? null;
|
||||||
|
return { ...prev, packageId: preferred?.id ?? null };
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
setPackages([]);
|
||||||
|
} finally {
|
||||||
|
setPackagesLoading(false);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, [isSuperAdmin, isEdit]);
|
||||||
|
|
||||||
async function handleSubmit() {
|
async function handleSubmit() {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -130,7 +163,8 @@ export default function MobileEventFormPage() {
|
|||||||
slug: `${Date.now()}`,
|
slug: `${Date.now()}`,
|
||||||
event_type_id: form.eventTypeId ?? undefined,
|
event_type_id: form.eventTypeId ?? undefined,
|
||||||
event_date: form.date || undefined,
|
event_date: form.date || undefined,
|
||||||
status: (form.published ? 'published' : 'draft') as const,
|
status: form.published ? 'published' : 'draft',
|
||||||
|
package_id: isSuperAdmin ? form.packageId ?? undefined : undefined,
|
||||||
settings: {
|
settings: {
|
||||||
location: form.location,
|
location: form.location,
|
||||||
guest_upload_visibility: form.autoApproveUploads ? 'immediate' : 'review',
|
guest_upload_visibility: form.autoApproveUploads ? 'immediate' : 'review',
|
||||||
@@ -152,7 +186,8 @@ export default function MobileEventFormPage() {
|
|||||||
slug: `${Date.now()}`,
|
slug: `${Date.now()}`,
|
||||||
event_type_id: form.eventTypeId ?? undefined,
|
event_type_id: form.eventTypeId ?? undefined,
|
||||||
event_date: form.date || undefined,
|
event_date: form.date || undefined,
|
||||||
status: (form.published ? 'published' : 'draft') as const,
|
status: form.published ? 'published' : 'draft',
|
||||||
|
package_id: isSuperAdmin ? form.packageId ?? undefined : undefined,
|
||||||
settings: {
|
settings: {
|
||||||
location: form.location,
|
location: form.location,
|
||||||
guest_upload_visibility: form.autoApproveUploads ? 'immediate' : 'review',
|
guest_upload_visibility: form.autoApproveUploads ? 'immediate' : 'review',
|
||||||
@@ -223,6 +258,31 @@ export default function MobileEventFormPage() {
|
|||||||
/>
|
/>
|
||||||
</MobileField>
|
</MobileField>
|
||||||
|
|
||||||
|
{isSuperAdmin && !isEdit ? (
|
||||||
|
<MobileField label={t('eventForm.fields.package.label', 'Package')}>
|
||||||
|
{packagesLoading ? (
|
||||||
|
<Text fontSize="$sm" color={muted}>{t('eventForm.fields.package.loading', 'Loading packages…')}</Text>
|
||||||
|
) : packages.length === 0 ? (
|
||||||
|
<Text fontSize="$sm" color={muted}>{t('eventForm.fields.package.empty', 'No packages available yet.')}</Text>
|
||||||
|
) : (
|
||||||
|
<MobileSelect
|
||||||
|
value={form.packageId ?? ''}
|
||||||
|
onChange={(e) => setForm((prev) => ({ ...prev, packageId: Number(e.target.value) }))}
|
||||||
|
>
|
||||||
|
<option value="">{t('eventForm.fields.package.placeholder', 'Select package')}</option>
|
||||||
|
{packages.map((pkg) => (
|
||||||
|
<option key={pkg.id} value={pkg.id}>
|
||||||
|
{pkg.name || `#${pkg.id}`}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</MobileSelect>
|
||||||
|
)}
|
||||||
|
<Text fontSize="$xs" color={muted}>
|
||||||
|
{t('eventForm.fields.package.help', 'This controls the event’s premium limits.')}
|
||||||
|
</Text>
|
||||||
|
</MobileField>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<MobileField label={t('eventForm.fields.date.label', 'Date & time')}>
|
<MobileField label={t('eventForm.fields.date.label', 'Date & time')}>
|
||||||
<XStack alignItems="center" space="$2">
|
<XStack alignItems="center" space="$2">
|
||||||
<NativeDateTimeInput
|
<NativeDateTimeInput
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { YStack, XStack } from '@tamagui/stacks';
|
|||||||
import { SizableText as Text } from '@tamagui/text';
|
import { SizableText as Text } from '@tamagui/text';
|
||||||
import { MobileShell, HeaderActionButton } from './components/MobileShell';
|
import { MobileShell, HeaderActionButton } from './components/MobileShell';
|
||||||
import { MobileCard, CTAButton, PillBadge, SkeletonCard } from './components/Primitives';
|
import { MobileCard, CTAButton, PillBadge, SkeletonCard } from './components/Primitives';
|
||||||
import { MobileSelect } from './components/FormControls';
|
import { MobileSelect, MobileField } from './components/FormControls';
|
||||||
import { useEventContext } from '../context/EventContext';
|
import { useEventContext } from '../context/EventContext';
|
||||||
import {
|
import {
|
||||||
approveAndLiveShowPhoto,
|
approveAndLiveShowPhoto,
|
||||||
@@ -216,8 +216,8 @@ export default function MobileEventLiveShowQueuePage() {
|
|||||||
</MobileCard>
|
</MobileCard>
|
||||||
|
|
||||||
<MobileCard>
|
<MobileCard>
|
||||||
|
<MobileField label={t('liveShowQueue.filterLabel', 'Live status')}>
|
||||||
<MobileSelect
|
<MobileSelect
|
||||||
label={t('liveShowQueue.filterLabel', 'Live status')}
|
|
||||||
value={statusFilter}
|
value={statusFilter}
|
||||||
onChange={(event) => setStatusFilter(event.target.value as LiveShowQueueStatus)}
|
onChange={(event) => setStatusFilter(event.target.value as LiveShowQueueStatus)}
|
||||||
>
|
>
|
||||||
@@ -227,6 +227,7 @@ export default function MobileEventLiveShowQueuePage() {
|
|||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</MobileSelect>
|
</MobileSelect>
|
||||||
|
</MobileField>
|
||||||
</MobileCard>
|
</MobileCard>
|
||||||
|
|
||||||
{error ? (
|
{error ? (
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { MobileField, MobileInput, MobileSelect } from './components/FormControl
|
|||||||
import { getEvent, getLiveShowLink, rotateLiveShowLink, updateEvent, LiveShowLink, LiveShowSettings, TenantEvent } from '../api';
|
import { getEvent, getLiveShowLink, rotateLiveShowLink, updateEvent, LiveShowLink, LiveShowSettings, TenantEvent } from '../api';
|
||||||
import { isAuthError } from '../auth/tokens';
|
import { isAuthError } from '../auth/tokens';
|
||||||
import { getApiErrorMessage } from '../lib/apiError';
|
import { getApiErrorMessage } from '../lib/apiError';
|
||||||
import { formatEventDate, resolveEventDisplayName } from '../lib/events';
|
import { resolveEventDisplayName } from '../lib/events';
|
||||||
import { adminPath } from '../constants';
|
import { adminPath } from '../constants';
|
||||||
import { useBackNavigation } from './hooks/useBackNavigation';
|
import { useBackNavigation } from './hooks/useBackNavigation';
|
||||||
import { useAdminTheme } from './theme';
|
import { useAdminTheme } from './theme';
|
||||||
@@ -262,8 +262,7 @@ export default function MobileEventLiveShowSettingsPage() {
|
|||||||
return (
|
return (
|
||||||
<MobileShell
|
<MobileShell
|
||||||
activeTab="home"
|
activeTab="home"
|
||||||
title={event ? resolveEventDisplayName(event) : t('liveShowSettings.title', 'Live Show settings')}
|
title={t('liveShowSettings.title', 'Live Show settings')}
|
||||||
subtitle={event?.event_date ? formatEventDate(event.event_date, locale) ?? undefined : undefined}
|
|
||||||
onBack={back}
|
onBack={back}
|
||||||
headerActions={
|
headerActions={
|
||||||
<HeaderActionButton onPress={() => load()} ariaLabel={t('common.refresh', 'Refresh')}>
|
<HeaderActionButton onPress={() => load()} ariaLabel={t('common.refresh', 'Refresh')}>
|
||||||
@@ -341,7 +340,7 @@ export default function MobileEventLiveShowSettingsPage() {
|
|||||||
{liveShowLink?.qr_code_data_url ? (
|
{liveShowLink?.qr_code_data_url ? (
|
||||||
<XStack space="$2" alignItems="center" marginTop="$2" flexWrap="wrap">
|
<XStack space="$2" alignItems="center" marginTop="$2" flexWrap="wrap">
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={() => downloadQr(liveShowLink.qr_code_data_url, 'live-show-qr.png')}
|
onPress={() => downloadQr(liveShowLink.qr_code_data_url!, 'live-show-qr.png')}
|
||||||
title={t('liveShowSettings.link.downloadQr', 'Download QR')}
|
title={t('liveShowSettings.link.downloadQr', 'Download QR')}
|
||||||
aria-label={t('liveShowSettings.link.downloadQr', 'Download QR')}
|
aria-label={t('liveShowSettings.link.downloadQr', 'Download QR')}
|
||||||
style={{ borderRadius: 12, cursor: 'pointer' }}
|
style={{ borderRadius: 12, cursor: 'pointer' }}
|
||||||
@@ -578,14 +577,14 @@ function resolveName(name: TenantEvent['name']): string {
|
|||||||
return 'Event';
|
return 'Event';
|
||||||
}
|
}
|
||||||
|
|
||||||
function copyToClipboard(value: string, t: (key: string, fallback?: string) => string) {
|
function copyToClipboard(value: string, t: any) {
|
||||||
navigator.clipboard
|
navigator.clipboard
|
||||||
.writeText(value)
|
.writeText(value)
|
||||||
.then(() => toast.success(t('liveShowSettings.link.copySuccess', 'Link copied')))
|
.then(() => toast.success(t('liveShowSettings.link.copySuccess', 'Link copied')))
|
||||||
.catch(() => toast.error(t('liveShowSettings.link.copyFailed', 'Link could not be copied')));
|
.catch(() => toast.error(t('liveShowSettings.link.copyFailed', 'Link could not be copied')));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function shareLink(value: string, event: TenantEvent | null, t: (key: string, fallback?: string) => string) {
|
async function shareLink(value: string, event: TenantEvent | null, t: any) {
|
||||||
if (navigator.share) {
|
if (navigator.share) {
|
||||||
try {
|
try {
|
||||||
await navigator.share({
|
await navigator.share({
|
||||||
@@ -713,7 +712,7 @@ function IconAction({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<XStack alignItems="center" justifyContent="center">
|
<XStack alignItems="center" justifyContent="center">
|
||||||
{React.isValidElement(children) ? React.cloneElement(children, { color }) : children}
|
{React.isValidElement(children) ? React.cloneElement(children as any, { color }) : children}
|
||||||
</XStack>
|
</XStack>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import {
|
|||||||
} from '../api';
|
} from '../api';
|
||||||
import { isAuthError } from '../auth/tokens';
|
import { isAuthError } from '../auth/tokens';
|
||||||
import { getApiErrorMessage } from '../lib/apiError';
|
import { getApiErrorMessage } from '../lib/apiError';
|
||||||
import { formatEventDate, resolveEventDisplayName } from '../lib/events';
|
import { formatEventDate } from '../lib/events';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
import { adminPath } from '../constants';
|
import { adminPath } from '../constants';
|
||||||
import { useBackNavigation } from './hooks/useBackNavigation';
|
import { useBackNavigation } from './hooks/useBackNavigation';
|
||||||
@@ -146,9 +146,7 @@ export default function MobileEventPhotoboothPage() {
|
|||||||
: t('photobooth.credentials.heading', 'FTP (Classic)');
|
: t('photobooth.credentials.heading', 'FTP (Classic)');
|
||||||
|
|
||||||
const isActive = Boolean(status?.enabled);
|
const isActive = Boolean(status?.enabled);
|
||||||
const title = event ? resolveEventDisplayName(event) : t('management.header.appName', 'Event Admin');
|
const title = t('photobooth.title', 'Photobooth');
|
||||||
const subtitle =
|
|
||||||
event?.event_date ? formatEventDate(event.event_date, locale) : t('header.selectEvent', 'Select an event to continue');
|
|
||||||
|
|
||||||
const handleToggle = (checked: boolean) => {
|
const handleToggle = (checked: boolean) => {
|
||||||
if (!slug || updating) return;
|
if (!slug || updating) return;
|
||||||
@@ -163,7 +161,6 @@ export default function MobileEventPhotoboothPage() {
|
|||||||
<MobileShell
|
<MobileShell
|
||||||
activeTab="home"
|
activeTab="home"
|
||||||
title={title}
|
title={title}
|
||||||
subtitle={subtitle ?? undefined}
|
|
||||||
onBack={back}
|
onBack={back}
|
||||||
headerActions={
|
headerActions={
|
||||||
<HeaderActionButton onPress={() => load()} ariaLabel={t('common.refresh', 'Refresh')}>
|
<HeaderActionButton onPress={() => load()} ariaLabel={t('common.refresh', 'Refresh')}>
|
||||||
|
|||||||
@@ -453,7 +453,7 @@ export default function MobileEventPhotosPage() {
|
|||||||
toast.error(t('mobilePhotos.loadFailed', 'Fotos konnten nicht geladen werden.'));
|
toast.error(t('mobilePhotos.loadFailed', 'Fotos konnten nicht geladen werden.'));
|
||||||
}
|
}
|
||||||
if (active) {
|
if (active) {
|
||||||
setLightboxWithUrl(null, { replace: true });
|
setLightboxWithUrl(null);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (active) {
|
if (active) {
|
||||||
@@ -616,7 +616,7 @@ export default function MobileEventPhotosPage() {
|
|||||||
function startAddonCheckout(scopeOrKey: 'photos' | 'gallery' | 'guests' | string) {
|
function startAddonCheckout(scopeOrKey: 'photos' | 'gallery' | 'guests' | string) {
|
||||||
if (!slug) return;
|
if (!slug) return;
|
||||||
const { scope, addonKey } = resolveScopeAndAddonKey(scopeOrKey);
|
const { scope, addonKey } = resolveScopeAndAddonKey(scopeOrKey);
|
||||||
setConsentTarget({ scope, addonKey });
|
setConsentTarget({ scope: scope as any, addonKey });
|
||||||
setConsentOpen(true);
|
setConsentOpen(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -635,7 +635,7 @@ export default function MobileEventPhotosPage() {
|
|||||||
cancel_url: currentUrl,
|
cancel_url: currentUrl,
|
||||||
accepted_terms: consents.acceptedTerms,
|
accepted_terms: consents.acceptedTerms,
|
||||||
accepted_waiver: consents.acceptedWaiver,
|
accepted_waiver: consents.acceptedWaiver,
|
||||||
});
|
} as any);
|
||||||
if (checkout.checkout_url) {
|
if (checkout.checkout_url) {
|
||||||
window.location.href = checkout.checkout_url;
|
window.location.href = checkout.checkout_url;
|
||||||
} else {
|
} else {
|
||||||
@@ -710,7 +710,7 @@ export default function MobileEventPhotosPage() {
|
|||||||
<XStack space="$2">
|
<XStack space="$2">
|
||||||
<CTAButton
|
<CTAButton
|
||||||
label={selectionMode ? t('common.done', 'Done') : t('common.select', 'Select')}
|
label={selectionMode ? t('common.done', 'Done') : t('common.select', 'Select')}
|
||||||
tone={selectionMode ? 'solid' : 'ghost'}
|
tone={selectionMode ? 'primary' : 'ghost'}
|
||||||
fullWidth={false}
|
fullWidth={false}
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
if (selectionMode) {
|
if (selectionMode) {
|
||||||
@@ -768,7 +768,7 @@ export default function MobileEventPhotosPage() {
|
|||||||
addons={catalogAddons}
|
addons={catalogAddons}
|
||||||
onCheckout={startAddonCheckout}
|
onCheckout={startAddonCheckout}
|
||||||
busyScope={busyScope}
|
busyScope={busyScope}
|
||||||
translate={translateLimits(t)}
|
translate={translateLimits(t as any)}
|
||||||
textColor={text}
|
textColor={text}
|
||||||
borderColor={border}
|
borderColor={border}
|
||||||
/>
|
/>
|
||||||
@@ -1343,7 +1343,7 @@ function PhotoQuickActions({ photo, disabled = false, muted, surface, onAction }
|
|||||||
key={action.key}
|
key={action.key}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
aria-label={action.label}
|
aria-label={action.label}
|
||||||
onPress={(event) => {
|
onPress={(event: any) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
if (!disabled) {
|
if (!disabled) {
|
||||||
onAction(action.key);
|
onAction(action.key);
|
||||||
|
|||||||
@@ -1,74 +1,72 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useLocation, useNavigate, useParams } from 'react-router-dom';
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Check, Copy, Download, Share2, Sparkles, Trophy, Users } from 'lucide-react';
|
||||||
import { YStack, XStack } from '@tamagui/stacks';
|
import { YStack, XStack } from '@tamagui/stacks';
|
||||||
import { SizableText as Text } from '@tamagui/text';
|
import { SizableText as Text } from '@tamagui/text';
|
||||||
import { Pressable } from '@tamagui/react-native-web-lite';
|
import { Pressable } from '@tamagui/react-native-web-lite';
|
||||||
import { Sparkles, Camera, Link2, QrCode, RefreshCcw, Shield, Archive, ShoppingCart, Clock3, Share2 } from 'lucide-react';
|
import { MobileShell } from './components/MobileShell';
|
||||||
import toast from 'react-hot-toast';
|
import { MobileCard, CTAButton, PillBadge, SkeletonCard } from './components/Primitives';
|
||||||
|
import { LegalConsentSheet } from './components/LegalConsentSheet';
|
||||||
import {
|
import {
|
||||||
getEvent,
|
getEvent,
|
||||||
getEventStats,
|
getEventStats,
|
||||||
getEventQrInvites,
|
getEventQrInvites,
|
||||||
toggleEvent,
|
|
||||||
updateEvent,
|
updateEvent,
|
||||||
createEventAddonCheckout,
|
TenantEvent,
|
||||||
|
EventStats,
|
||||||
|
EventQrInvite,
|
||||||
|
EventAddonCatalogItem,
|
||||||
getAddonCatalog,
|
getAddonCatalog,
|
||||||
submitTenantFeedback,
|
createEventAddonCheckout,
|
||||||
type TenantEvent,
|
|
||||||
type EventStats,
|
|
||||||
type EventQrInvite,
|
|
||||||
type EventAddonCatalogItem,
|
|
||||||
} from '../api';
|
} from '../api';
|
||||||
import { isAuthError } from '../auth/tokens';
|
import { isAuthError } from '../auth/tokens';
|
||||||
import { getApiErrorMessage } from '../lib/apiError';
|
import { getApiErrorMessage } from '../lib/apiError';
|
||||||
import { HeaderActionButton, MobileShell } from './components/MobileShell';
|
|
||||||
import { MobileCard, CTAButton, PillBadge, SkeletonCard } from './components/Primitives';
|
|
||||||
import { adminPath } from '../constants';
|
import { adminPath } from '../constants';
|
||||||
import { selectAddonKeyForScope } from './addons';
|
import toast from 'react-hot-toast';
|
||||||
import { LegalConsentSheet } from './components/LegalConsentSheet';
|
|
||||||
import { useBackNavigation } from './hooks/useBackNavigation';
|
import { useBackNavigation } from './hooks/useBackNavigation';
|
||||||
import { useAdminTheme } from './theme';
|
import { useAdminTheme } from './theme';
|
||||||
|
|
||||||
|
type GalleryCounts = {
|
||||||
|
photos: number;
|
||||||
|
likes: number;
|
||||||
|
pending: number;
|
||||||
|
};
|
||||||
|
|
||||||
export default function MobileEventRecapPage() {
|
export default function MobileEventRecapPage() {
|
||||||
const { slug } = useParams<{ slug?: string }>();
|
const { slug } = useParams<{ slug: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
|
||||||
const { t } = useTranslation('management');
|
const { t } = useTranslation('management');
|
||||||
const { textStrong, text, muted, subtle, danger, border, primary, accentSoft } = useAdminTheme();
|
const { textStrong, text, muted, border, primary, successText, danger } = useAdminTheme();
|
||||||
|
|
||||||
const [event, setEvent] = React.useState<TenantEvent | null>(null);
|
const [event, setEvent] = React.useState<TenantEvent | null>(null);
|
||||||
const [stats, setStats] = React.useState<EventStats | null>(null);
|
const [stats, setEventStats] = React.useState<EventStats | null>(null);
|
||||||
const [invites, setInvites] = React.useState<EventQrInvite[]>([]);
|
const [invites, setInvites] = React.useState<EventQrInvite[]>([]);
|
||||||
const [addons, setAddons] = React.useState<EventAddonCatalogItem[]>([]);
|
const [addons, setAddons] = React.useState<EventAddonCatalogItem[]>([]);
|
||||||
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 [busy, setBusy] = React.useState(false);
|
|
||||||
const [archiveBusy, setArchiveBusy] = React.useState(false);
|
|
||||||
const [checkoutBusy, setCheckoutBusy] = React.useState(false);
|
|
||||||
const [consentOpen, setConsentOpen] = React.useState(false);
|
const [consentOpen, setConsentOpen] = React.useState(false);
|
||||||
const [consentBusy, setConsentBusy] = React.useState(false);
|
const [busyScope, setBusyScope] = React.useState<string | null>(null);
|
||||||
const [consentAddonKey, setConsentAddonKey] = React.useState<string | null>(null);
|
|
||||||
const back = useBackNavigation(slug ? adminPath(`/mobile/events/${slug}`) : adminPath('/mobile/events'));
|
const back = useBackNavigation(slug ? adminPath(`/mobile/events/${slug}`) : adminPath('/mobile/events'));
|
||||||
|
|
||||||
const load = React.useCallback(async () => {
|
const load = React.useCallback(async () => {
|
||||||
if (!slug) return;
|
if (!slug) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const [eventData, statsData, inviteData, addonData] = await Promise.all([
|
const [eventData, statsData, invitesData, addonsData] = await Promise.all([
|
||||||
getEvent(slug),
|
getEvent(slug),
|
||||||
getEventStats(slug),
|
getEventStats(slug),
|
||||||
getEventQrInvites(slug),
|
getEventQrInvites(slug),
|
||||||
getAddonCatalog(),
|
getAddonCatalog(),
|
||||||
]);
|
]);
|
||||||
setEvent(eventData);
|
setEvent(eventData);
|
||||||
setStats(statsData);
|
setEventStats(statsData);
|
||||||
setInvites(inviteData ?? []);
|
setInvites(invitesData);
|
||||||
setAddons(addonData ?? []);
|
setAddons(addonsData);
|
||||||
setError(null);
|
setError(null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!isAuthError(err)) {
|
if (!isAuthError(err)) {
|
||||||
setError(getApiErrorMessage(err, t('events.errors.loadFailed', 'Event konnte nicht geladen werden.')));
|
setError(getApiErrorMessage(err, t('events.errors.loadFailed', 'Recap konnte nicht geladen werden.')));
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -79,323 +77,242 @@ export default function MobileEventRecapPage() {
|
|||||||
void load();
|
void load();
|
||||||
}, [load]);
|
}, [load]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
const handleCheckout = async (addonKey: string) => {
|
||||||
if (!location.search) return;
|
if (!slug || busyScope) return;
|
||||||
const params = new URLSearchParams(location.search);
|
setBusyScope(addonKey);
|
||||||
if (params.get('addon_success')) {
|
try {
|
||||||
toast.success(t('events.success.addonApplied', 'Add-on angewendet. Limits aktualisieren sich in Kürze.'));
|
const { checkout_url } = await createEventAddonCheckout(slug, {
|
||||||
params.delete('addon_success');
|
addon_key: addonKey,
|
||||||
navigate({ pathname: location.pathname, search: params.toString() ? `?${params.toString()}` : '' }, { replace: true });
|
success_url: window.location.href,
|
||||||
void load();
|
cancel_url: window.location.href,
|
||||||
|
});
|
||||||
|
if (checkout_url) {
|
||||||
|
window.location.href = checkout_url;
|
||||||
}
|
}
|
||||||
}, [location.search, location.pathname, t, navigate, load]);
|
} catch (err) {
|
||||||
|
toast.error(getApiErrorMessage(err, t('events.errors.checkoutFailed', 'Bezahlvorgang konnte nicht gestartet werden.')));
|
||||||
|
setBusyScope(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (!slug) {
|
const handleConsentConfirm = async (consents: { acceptedTerms: boolean }) => {
|
||||||
|
if (!slug || !busyScope) return;
|
||||||
|
try {
|
||||||
|
const { checkout_url } = await createEventAddonCheckout(slug, {
|
||||||
|
addon_key: busyScope,
|
||||||
|
success_url: window.location.href,
|
||||||
|
cancel_url: window.location.href,
|
||||||
|
accepted_terms: consents.acceptedTerms,
|
||||||
|
} as any);
|
||||||
|
if (checkout_url) {
|
||||||
|
window.location.href = checkout_url;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(getApiErrorMessage(err, t('events.errors.checkoutFailed', 'Bezahlvorgang konnte nicht gestartet werden.')));
|
||||||
|
setBusyScope(null);
|
||||||
|
setConsentOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<MobileShell activeTab="home" title={t('events.errors.notFoundTitle', 'Event nicht gefunden')} onBack={back}>
|
<MobileShell activeTab="home" title={t('events.recap.title', 'Event Recap')} onBack={back}>
|
||||||
|
<YStack space="$3">
|
||||||
|
<SkeletonCard height={120} />
|
||||||
|
<SkeletonCard height={200} />
|
||||||
|
<SkeletonCard height={150} />
|
||||||
|
</YStack>
|
||||||
|
</MobileShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !event) {
|
||||||
|
return (
|
||||||
|
<MobileShell activeTab="home" title={t('events.recap.title', 'Event Recap')} onBack={back}>
|
||||||
<MobileCard>
|
<MobileCard>
|
||||||
<Text color={danger}>{t('events.errors.notFoundBody', 'Kehre zur Eventliste zurück und wähle dort ein Event aus.')}</Text>
|
<Text fontWeight="700" color={danger}>
|
||||||
|
{error || t('common.error', 'Ein Fehler ist aufgetreten.')}
|
||||||
|
</Text>
|
||||||
|
<CTAButton label={t('common.retry', 'Erneut versuchen')} onPress={load} tone="ghost" />
|
||||||
</MobileCard>
|
</MobileCard>
|
||||||
</MobileShell>
|
</MobileShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const activeInvite = invites.find((invite) => invite.is_active);
|
const galleryCounts: GalleryCounts = {
|
||||||
const guestLink = event?.public_url ?? activeInvite?.url ?? (activeInvite?.token ? `/g/${activeInvite.token}` : null);
|
photos: stats?.total ?? 0,
|
||||||
const galleryCounts = {
|
likes: stats?.likes ?? 0,
|
||||||
photos: stats?.uploads_total ?? stats?.total ?? 0,
|
|
||||||
pending: stats?.pending_photos ?? 0,
|
pending: stats?.pending_photos ?? 0,
|
||||||
likes: stats?.likes_total ?? stats?.likes ?? 0,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
async function toggleGallery() {
|
const activeInvite = invites.find((i) => i.is_active) ?? invites[0] ?? null;
|
||||||
if (!slug) return;
|
const guestLink = activeInvite?.url ?? '';
|
||||||
setBusy(true);
|
|
||||||
try {
|
|
||||||
const updated = await toggleEvent(slug);
|
|
||||||
setEvent(updated);
|
|
||||||
} catch (err) {
|
|
||||||
if (!isAuthError(err)) {
|
|
||||||
setError(getApiErrorMessage(err, t('events.errors.toggleFailed', 'Status konnte nicht angepasst werden.')));
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
setBusy(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function archiveEvent() {
|
|
||||||
if (!slug || !event) return;
|
|
||||||
setArchiveBusy(true);
|
|
||||||
try {
|
|
||||||
const updated = await updateEvent(slug, { status: 'archived', is_active: false });
|
|
||||||
setEvent(updated);
|
|
||||||
toast.success(t('events.recap.archivedSuccess', 'Event archiviert. Galerie ist geschlossen.'));
|
|
||||||
} catch (err) {
|
|
||||||
if (!isAuthError(err)) {
|
|
||||||
setError(getApiErrorMessage(err, t('events.errors.archiveFailed', 'Archivierung fehlgeschlagen.')));
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
setArchiveBusy(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function startAddonCheckout() {
|
|
||||||
if (!slug) return;
|
|
||||||
const addonKey = selectAddonKeyForScope(addons, 'gallery');
|
|
||||||
setConsentAddonKey(addonKey);
|
|
||||||
setConsentOpen(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function confirmAddonCheckout(consents: { acceptedTerms: boolean; acceptedWaiver: boolean }) {
|
|
||||||
if (!slug || !consentAddonKey) return;
|
|
||||||
const currentUrl = typeof window !== 'undefined' ? `${window.location.origin}${adminPath(`/mobile/events/${slug}/recap`)}` : '';
|
|
||||||
const successUrl = `${currentUrl}?addon_success=1`;
|
|
||||||
setCheckoutBusy(true);
|
|
||||||
setConsentBusy(true);
|
|
||||||
try {
|
|
||||||
const checkout = await createEventAddonCheckout(slug, {
|
|
||||||
addon_key: consentAddonKey,
|
|
||||||
quantity: 1,
|
|
||||||
success_url: successUrl,
|
|
||||||
cancel_url: currentUrl,
|
|
||||||
accepted_terms: consents.acceptedTerms,
|
|
||||||
accepted_waiver: consents.acceptedWaiver,
|
|
||||||
});
|
|
||||||
if (checkout.checkout_url) {
|
|
||||||
window.location.href = checkout.checkout_url;
|
|
||||||
} else {
|
|
||||||
toast.error(t('events.errors.checkoutMissing', 'Checkout konnte nicht gestartet werden.'));
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
if (!isAuthError(err)) {
|
|
||||||
toast.error(getApiErrorMessage(err, t('events.errors.checkoutFailed', 'Add-on Checkout fehlgeschlagen.')));
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
setCheckoutBusy(false);
|
|
||||||
setConsentBusy(false);
|
|
||||||
setConsentOpen(false);
|
|
||||||
setConsentAddonKey(null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function submitFeedback(sentiment: 'positive' | 'neutral' | 'negative') {
|
|
||||||
if (!event) return;
|
|
||||||
try {
|
|
||||||
await submitTenantFeedback({
|
|
||||||
category: 'event_workspace_after_event',
|
|
||||||
event_slug: event.slug,
|
|
||||||
sentiment,
|
|
||||||
metadata: {
|
|
||||||
event_name: resolveName(event.name),
|
|
||||||
guest_link: guestLink,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
toast.success(t('events.feedback.submitted', 'Danke!'));
|
|
||||||
} catch (err) {
|
|
||||||
toast.error(getApiErrorMessage(err, t('events.feedback.genericError', 'Feedback konnte nicht gesendet werden.')));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MobileShell
|
<MobileShell
|
||||||
activeTab="home"
|
activeTab="home"
|
||||||
title={event ? resolveName(event.name) : t('events.placeholders.untitled', 'Unbenanntes Event')}
|
title={t('events.recap.title', 'Event Recap')}
|
||||||
subtitle={event?.event_date ? formatDate(event.event_date) : undefined}
|
|
||||||
onBack={back}
|
onBack={back}
|
||||||
headerActions={
|
|
||||||
<HeaderActionButton onPress={() => load()} ariaLabel={t('common.refresh', 'Refresh')}>
|
|
||||||
<RefreshCcw size={18} color={textStrong} />
|
|
||||||
</HeaderActionButton>
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{error ? (
|
<YStack space="$4">
|
||||||
<MobileCard>
|
{/* Status & Summary */}
|
||||||
<Text color={danger}>{error}</Text>
|
<MobileCard space="$3">
|
||||||
</MobileCard>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{loading ? (
|
|
||||||
<YStack space="$2">
|
|
||||||
{Array.from({ length: 4 }).map((_, idx) => (
|
|
||||||
<SkeletonCard key={`sk-${idx}`} height={90} />
|
|
||||||
))}
|
|
||||||
</YStack>
|
|
||||||
) : event && stats ? (
|
|
||||||
<YStack space="$3">
|
|
||||||
<MobileCard space="$2">
|
|
||||||
<XStack alignItems="center" justifyContent="space-between">
|
<XStack alignItems="center" justifyContent="space-between">
|
||||||
<YStack space="$1">
|
<YStack space="$1">
|
||||||
<Text fontSize="$xs" color={muted} fontWeight="700" letterSpacing={1.2}>
|
<Text fontSize="$xl" fontWeight="800" color={textStrong}>
|
||||||
{t('events.recap.badge', 'Nachbereitung')}
|
{t('events.recap.done', 'Event beendet')}
|
||||||
</Text>
|
</Text>
|
||||||
<Text fontSize="$lg" fontWeight="800" color={textStrong}>{resolveName(event.name)}</Text>
|
|
||||||
<Text fontSize="$sm" color={muted}>
|
<Text fontSize="$sm" color={muted}>
|
||||||
{t('events.recap.subtitle', 'Abschluss, Export und Galerie-Laufzeit verwalten.')}
|
{formatDate(event.event_date)}
|
||||||
</Text>
|
</Text>
|
||||||
</YStack>
|
</YStack>
|
||||||
<PillBadge tone={event.is_active ? 'success' : 'muted'}>
|
<PillBadge tone="success">{t('events.recap.statusClosed', 'Archiviert')}</PillBadge>
|
||||||
{event.is_active ? t('events.recap.galleryOpen', 'Galerie geöffnet') : t('events.recap.galleryClosed', 'Galerie geschlossen')}
|
|
||||||
</PillBadge>
|
|
||||||
</XStack>
|
</XStack>
|
||||||
<XStack space="$2" marginTop="$2" flexWrap="wrap">
|
|
||||||
<CTAButton
|
<XStack flexWrap="wrap" gap="$2" marginTop="$1">
|
||||||
label={event.is_active ? t('events.recap.closeGallery', 'Galerie schließen') : t('events.recap.openGallery', 'Galerie öffnen')}
|
<Stat label={t('events.stats.uploads', 'Uploads')} value={String(galleryCounts.photos)} />
|
||||||
onPress={toggleGallery}
|
<Stat label={t('events.stats.pending', 'Offen')} value={String(galleryCounts.pending)} />
|
||||||
loading={busy}
|
<Stat label={t('events.stats.likes', 'Likes')} value={String(galleryCounts.likes)} />
|
||||||
/>
|
|
||||||
<CTAButton label={t('events.recap.moderate', 'Uploads ansehen')} tone="ghost" onPress={() => navigate(adminPath(`/mobile/events/${slug}/photos`))} />
|
|
||||||
<CTAButton label={t('events.actions.edit', 'Bearbeiten')} tone="ghost" onPress={() => navigate(adminPath(`/mobile/events/${slug}/edit`))} />
|
|
||||||
</XStack>
|
</XStack>
|
||||||
</MobileCard>
|
</MobileCard>
|
||||||
|
|
||||||
<MobileCard space="$2">
|
{/* Share Section */}
|
||||||
<XStack alignItems="center" justifyContent="space-between">
|
<MobileCard space="$3">
|
||||||
<Text fontSize="$md" fontWeight="800" color={textStrong}>
|
|
||||||
{t('events.recap.galleryTitle', 'Galerie-Status')}
|
|
||||||
</Text>
|
|
||||||
<PillBadge tone="muted">{t('events.recap.galleryCounts', '{{photos}} Fotos, {{pending}} offen, {{likes}} Likes', galleryCounts)}</PillBadge>
|
|
||||||
</XStack>
|
|
||||||
<XStack space="$2" marginTop="$2" flexWrap="wrap">
|
|
||||||
<Stat pill label={t('events.stats.uploads', 'Uploads')} value={String(galleryCounts.photos)} />
|
|
||||||
<Stat pill label={t('events.stats.pending', 'Offen')} value={String(galleryCounts.pending)} />
|
|
||||||
<Stat pill label={t('events.stats.likes', 'Likes')} value={String(galleryCounts.likes)} />
|
|
||||||
</XStack>
|
|
||||||
</MobileCard>
|
|
||||||
|
|
||||||
<MobileCard space="$2">
|
|
||||||
<XStack alignItems="center" space="$2">
|
<XStack alignItems="center" space="$2">
|
||||||
<Link2 size={16} color={textStrong} />
|
<Share2 size={18} color={primary} />
|
||||||
<Text fontSize="$md" fontWeight="800" color={textStrong}>
|
<Text fontSize="$md" fontWeight="800" color={textStrong}>
|
||||||
{t('events.recap.shareLink', 'Gäste-Link')}
|
{t('events.recap.shareGallery', 'Galerie teilen')}
|
||||||
</Text>
|
</Text>
|
||||||
</XStack>
|
</XStack>
|
||||||
{guestLink ? (
|
<Text fontSize="$sm" color={text}>
|
||||||
<Text fontSize="$sm" color={text} selectable>
|
{t('events.recap.shareBody', 'Deine Gäste können die Galerie auch nach dem Event weiterhin ansehen.')}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<YStack space="$2" marginTop="$1">
|
||||||
|
<XStack
|
||||||
|
backgroundColor={border}
|
||||||
|
padding="$3"
|
||||||
|
borderRadius={12}
|
||||||
|
alignItems="center"
|
||||||
|
justifyContent="space-between"
|
||||||
|
>
|
||||||
|
<Text fontSize="$xs" color={muted} numberOfLines={1} flex={1}>
|
||||||
{guestLink}
|
{guestLink}
|
||||||
</Text>
|
</Text>
|
||||||
) : (
|
|
||||||
<Text fontSize="$sm" color={muted}>
|
|
||||||
{t('events.recap.noPublicUrl', 'Kein Gäste-Link gesetzt. Lege den öffentlichen Link im Event-Setup fest.')}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
<XStack space="$2" marginTop="$2">
|
|
||||||
<CTAButton label={t('events.recap.copy', 'Kopieren')} tone="ghost" onPress={() => guestLink && copyToClipboard(guestLink, t)} />
|
<CTAButton label={t('events.recap.copy', 'Kopieren')} tone="ghost" onPress={() => guestLink && copyToClipboard(guestLink, t)} />
|
||||||
{guestLink ? (
|
</XStack>
|
||||||
|
{typeof navigator !== 'undefined' && !!navigator.share && (
|
||||||
<CTAButton label={t('events.recap.share', 'Teilen')} tone="ghost" onPress={() => shareLink(guestLink, event, t)} />
|
<CTAButton label={t('events.recap.share', 'Teilen')} tone="ghost" onPress={() => shareLink(guestLink, event, t)} />
|
||||||
) : null}
|
)}
|
||||||
</XStack>
|
</YStack>
|
||||||
|
|
||||||
{activeInvite?.qr_code_data_url ? (
|
{activeInvite?.qr_code_data_url ? (
|
||||||
<XStack space="$2" alignItems="center" marginTop="$2">
|
<YStack alignItems="center" space="$2" marginTop="$2">
|
||||||
<img
|
<YStack
|
||||||
src={activeInvite.qr_code_data_url}
|
padding="$2"
|
||||||
alt={t('events.qr.qrAlt', 'QR code')}
|
backgroundColor="white"
|
||||||
style={{ width: 96, height: 96 }}
|
borderRadius={12}
|
||||||
/>
|
borderWidth={1}
|
||||||
<CTAButton label={t('events.recap.downloadQr', 'QR herunterladen')} tone="ghost" onPress={() => downloadQr(activeInvite.qr_code_data_url)} />
|
borderColor={border}
|
||||||
</XStack>
|
>
|
||||||
) : null}
|
<img src={activeInvite.qr_code_data_url} alt="QR" style={{ width: 120, height: 120 }} />
|
||||||
</MobileCard>
|
</YStack>
|
||||||
|
<CTAButton label={t('events.recap.downloadQr', 'QR herunterladen')} tone="ghost" onPress={() => downloadQr(activeInvite.qr_code_data_url!)} />
|
||||||
<MobileCard space="$2">
|
|
||||||
<XStack alignItems="center" space="$2">
|
|
||||||
<ShoppingCart size={16} color={textStrong} />
|
|
||||||
<Text fontSize="$md" fontWeight="800" color={textStrong}>
|
|
||||||
{t('events.sections.addons.title', 'Add-ons & Upgrades')}
|
|
||||||
</Text>
|
|
||||||
</XStack>
|
|
||||||
<Text fontSize="$sm" color={muted}>
|
|
||||||
{t('events.sections.addons.description', 'Zusätzliche Kontingente freischalten.')}
|
|
||||||
</Text>
|
|
||||||
<CTAButton
|
|
||||||
label={t('events.recap.extendGallery', 'Galerie verlängern')}
|
|
||||||
onPress={() => {
|
|
||||||
startAddonCheckout();
|
|
||||||
}}
|
|
||||||
loading={checkoutBusy}
|
|
||||||
/>
|
|
||||||
</MobileCard>
|
|
||||||
|
|
||||||
<MobileCard space="$2">
|
|
||||||
<XStack alignItems="center" space="$2">
|
|
||||||
<Shield size={16} color={textStrong} />
|
|
||||||
<Text fontSize="$md" fontWeight="800" color={textStrong}>
|
|
||||||
{t('events.recap.settingsTitle', 'Gast-Einstellungen')}
|
|
||||||
</Text>
|
|
||||||
</XStack>
|
|
||||||
<ToggleRow
|
|
||||||
label={t('events.recap.downloads', 'Downloads erlauben')}
|
|
||||||
value={Boolean(event.settings?.guest_downloads_enabled)}
|
|
||||||
onToggle={(value) => updateSetting(event, setEvent, slug, 'guest_downloads_enabled', value, setError, t)}
|
|
||||||
/>
|
|
||||||
<ToggleRow
|
|
||||||
label={t('events.recap.sharing', 'Sharing erlauben')}
|
|
||||||
value={Boolean(event.settings?.guest_sharing_enabled)}
|
|
||||||
onToggle={(value) => updateSetting(event, setEvent, slug, 'guest_sharing_enabled', value, setError, t)}
|
|
||||||
/>
|
|
||||||
</MobileCard>
|
|
||||||
|
|
||||||
<MobileCard space="$2">
|
|
||||||
<XStack alignItems="center" space="$2">
|
|
||||||
<Archive size={16} color={textStrong} />
|
|
||||||
<Text fontSize="$md" fontWeight="800" color={textStrong}>
|
|
||||||
{t('events.recap.archiveTitle', 'Event archivieren')}
|
|
||||||
</Text>
|
|
||||||
</XStack>
|
|
||||||
<Text fontSize="$sm" color={muted}>
|
|
||||||
{t('events.recap.archiveCopy', 'Schließt die Galerie und markiert das Event als abgeschlossen.')}
|
|
||||||
</Text>
|
|
||||||
<CTAButton label={t('events.recap.archive', 'Archivieren')} onPress={() => archiveEvent()} loading={archiveBusy} />
|
|
||||||
</MobileCard>
|
|
||||||
|
|
||||||
<MobileCard space="$2">
|
|
||||||
<XStack alignItems="center" space="$2">
|
|
||||||
<Sparkles size={16} color={textStrong} />
|
|
||||||
<Text fontSize="$md" fontWeight="800" color={textStrong}>
|
|
||||||
{t('events.recap.feedbackTitle', 'Wie lief das Event?')}
|
|
||||||
</Text>
|
|
||||||
</XStack>
|
|
||||||
<XStack space="$2" marginTop="$2" flexWrap="wrap">
|
|
||||||
<CTAButton label={t('events.feedback.positive', 'War super')} tone="ghost" onPress={() => void submitFeedback('positive')} />
|
|
||||||
<CTAButton label={t('events.feedback.neutral', 'In Ordnung')} tone="ghost" onPress={() => void submitFeedback('neutral')} />
|
|
||||||
<CTAButton label={t('events.feedback.negative', 'Brauch(t)e Unterstützung')} tone="ghost" onPress={() => void submitFeedback('negative')} />
|
|
||||||
</XStack>
|
|
||||||
</MobileCard>
|
|
||||||
</YStack>
|
</YStack>
|
||||||
) : null}
|
) : null}
|
||||||
|
</MobileCard>
|
||||||
|
|
||||||
|
{/* Settings */}
|
||||||
|
<MobileCard space="$3">
|
||||||
|
<XStack alignItems="center" space="$2">
|
||||||
|
<Users size={18} color={primary} />
|
||||||
|
<Text fontSize="$md" fontWeight="800" color={textStrong}>
|
||||||
|
{t('events.recap.settings', 'Nachlauf-Optionen')}
|
||||||
|
</Text>
|
||||||
|
</XStack>
|
||||||
|
|
||||||
|
<YStack space="$1.5">
|
||||||
|
<ToggleOption
|
||||||
|
label={t('events.recap.allowDownloads', 'Gäste dürfen Fotos laden')}
|
||||||
|
value={Boolean(event.settings?.guest_downloads_enabled)}
|
||||||
|
onToggle={(value) => updateSetting(event, setEvent, slug, 'guest_downloads_enabled', value, setError, t as any)}
|
||||||
|
/>
|
||||||
|
<ToggleOption
|
||||||
|
label={t('events.recap.allowSharing', 'Gäste dürfen Fotos teilen')}
|
||||||
|
value={Boolean(event.settings?.guest_sharing_enabled)}
|
||||||
|
onToggle={(value) => updateSetting(event, setEvent, slug, 'guest_sharing_enabled', value, setError, t as any)}
|
||||||
|
/>
|
||||||
|
</YStack>
|
||||||
|
</MobileCard>
|
||||||
|
|
||||||
|
{/* Extensions */}
|
||||||
|
<MobileCard space="$3">
|
||||||
|
<XStack alignItems="center" space="$2">
|
||||||
|
<Sparkles size={18} color={primary} />
|
||||||
|
<Text fontSize="$md" fontWeight="800" color={textStrong}>
|
||||||
|
{t('events.recap.addons', 'Galerie verlängern')}
|
||||||
|
</Text>
|
||||||
|
</XStack>
|
||||||
|
<Text fontSize="$sm" color={text}>
|
||||||
|
{t('events.recap.addonBody', 'Die Online-Zeit deiner Galerie neigt sich dem Ende? Hier kannst du sie verlängern.')}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<YStack space="$2">
|
||||||
|
{addons
|
||||||
|
.filter((a) => a.key === 'gallery_extension')
|
||||||
|
.map((addon) => (
|
||||||
|
<CTAButton
|
||||||
|
key={addon.key}
|
||||||
|
label={t('events.recap.buyExtension', 'Galerie um 30 Tage verlängern')}
|
||||||
|
onPress={() => handleCheckout(addon.key)}
|
||||||
|
loading={busyScope === addon.key}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</YStack>
|
||||||
|
</MobileCard>
|
||||||
|
</YStack>
|
||||||
|
|
||||||
<LegalConsentSheet
|
<LegalConsentSheet
|
||||||
open={consentOpen}
|
open={consentOpen}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
if (consentBusy) return;
|
|
||||||
setConsentOpen(false);
|
setConsentOpen(false);
|
||||||
setConsentAddonKey(null);
|
setBusyScope(null);
|
||||||
}}
|
}}
|
||||||
onConfirm={confirmAddonCheckout}
|
onConfirm={handleConsentConfirm}
|
||||||
busy={consentBusy}
|
busy={Boolean(busyScope)}
|
||||||
t={t}
|
t={t as any}
|
||||||
/>
|
/>
|
||||||
</MobileShell>
|
</MobileShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Stat({ label, value }: { label: string; value: string }) {
|
function Stat({ label, value, pill }: { label: string; value: string; pill?: boolean }) {
|
||||||
const { border, muted, textStrong } = useAdminTheme();
|
const { textStrong, muted, accentSoft, border } = useAdminTheme();
|
||||||
return (
|
return (
|
||||||
<MobileCard borderColor={border} space="$1.5">
|
<YStack
|
||||||
|
paddingHorizontal="$3"
|
||||||
|
paddingVertical="$2"
|
||||||
|
borderRadius={12}
|
||||||
|
borderWidth={1}
|
||||||
|
borderColor={border}
|
||||||
|
backgroundColor={pill ? accentSoft : 'transparent'}
|
||||||
|
minWidth={80}
|
||||||
|
>
|
||||||
<Text fontSize="$xs" color={muted}>
|
<Text fontSize="$xs" color={muted}>
|
||||||
{label}
|
{label}
|
||||||
</Text>
|
</Text>
|
||||||
<Text fontSize="$md" fontWeight="800" color={textStrong}>
|
<Text fontSize="$md" fontWeight="800" color={textStrong}>
|
||||||
{value}
|
{value}
|
||||||
</Text>
|
</Text>
|
||||||
</MobileCard>
|
</YStack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ToggleRow({ label, value, onToggle }: { label: string; value: boolean; onToggle: (value: boolean) => void }) {
|
function ToggleOption({ label, value, onToggle }: { label: string; value: boolean; onToggle: (val: boolean) => void }) {
|
||||||
const { textStrong } = useAdminTheme();
|
const { textStrong } = useAdminTheme();
|
||||||
return (
|
return (
|
||||||
<XStack alignItems="center" justifyContent="space-between" marginTop="$1.5">
|
<XStack alignItems="center" justifyContent="space-between" paddingVertical="$1">
|
||||||
<Text fontSize="$sm" color={textStrong}>
|
<Text fontSize="$sm" color={textStrong} fontWeight="600">
|
||||||
{label}
|
{label}
|
||||||
</Text>
|
</Text>
|
||||||
<input
|
<input
|
||||||
@@ -433,27 +350,25 @@ async function updateSetting(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function copyToClipboard(value: string, t: (key: string, fallback?: string) => string) {
|
function copyToClipboard(value: string, t: any) {
|
||||||
navigator.clipboard
|
if (typeof window !== 'undefined') {
|
||||||
.writeText(value)
|
void window.navigator.clipboard.writeText(value);
|
||||||
.then(() => toast.success(t('events.recap.copySuccess', 'Link kopiert')))
|
toast.success(t('events.recap.copySuccess', 'Link kopiert'));
|
||||||
.catch(() => toast.error(t('events.recap.copyError', 'Link konnte nicht kopiert werden.')));
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function shareLink(value: string, event: TenantEvent, t: (key: string, fallback?: string) => string) {
|
async function shareLink(value: string, event: TenantEvent | null, t: any) {
|
||||||
if (navigator.share) {
|
if (typeof window !== 'undefined' && navigator.share) {
|
||||||
try {
|
try {
|
||||||
await navigator.share({
|
await navigator.share({
|
||||||
title: resolveName(event.name),
|
title: resolveName(event?.name ?? ''),
|
||||||
text: t('events.recap.shareGuests', 'Gäste-Galerie teilen'),
|
text: t('events.recap.shareText', 'Schau dir die Fotos von unserem Event an!'),
|
||||||
url: value,
|
url: value,
|
||||||
});
|
});
|
||||||
return;
|
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// silently ignore or fallback to copy
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
copyToClipboard(value, t);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function downloadQr(dataUrl: string) {
|
function downloadQr(dataUrl: string) {
|
||||||
|
|||||||
@@ -183,7 +183,7 @@ export default function MobileEventTasksPage() {
|
|||||||
setSearchTerm('');
|
setSearchTerm('');
|
||||||
}, [slug]);
|
}, [slug]);
|
||||||
|
|
||||||
const scrollToSection = (ref: React.RefObject<HTMLDivElement>) => {
|
const scrollToSection = (ref: React.RefObject<HTMLDivElement | null>) => {
|
||||||
if (ref.current) {
|
if (ref.current) {
|
||||||
ref.current.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
ref.current.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
}
|
}
|
||||||
@@ -561,8 +561,8 @@ export default function MobileEventTasksPage() {
|
|||||||
/>
|
/>
|
||||||
</XStack>
|
</XStack>
|
||||||
</MobileCard>
|
</MobileCard>
|
||||||
<YGroup borderWidth={1} borderColor={border} borderRadius="$4" overflow="hidden">
|
<YGroup {...({ borderWidth: 1, borderColor: border, borderRadius: "$4", overflow: "hidden" } as any)}>
|
||||||
<YGroup.Item bordered>
|
<YGroup.Item>
|
||||||
<ListItem
|
<ListItem
|
||||||
hoverTheme
|
hoverTheme
|
||||||
pressTheme
|
pressTheme
|
||||||
@@ -661,9 +661,9 @@ export default function MobileEventTasksPage() {
|
|||||||
<Text fontSize="$sm" color={muted}>
|
<Text fontSize="$sm" color={muted}>
|
||||||
{t('events.tasks.count', '{{count}} Tasks', { count: filteredTasks.length })}
|
{t('events.tasks.count', '{{count}} Tasks', { count: filteredTasks.length })}
|
||||||
</Text>
|
</Text>
|
||||||
<YGroup borderWidth={1} borderColor={border} borderRadius="$4" overflow="hidden">
|
<YGroup {...({ borderWidth: 1, borderColor: border, borderRadius: "$4", overflow: "hidden" } as any)}>
|
||||||
{filteredTasks.map((task, idx) => (
|
{filteredTasks.map((task, idx) => (
|
||||||
<YGroup.Item key={task.id} bordered={idx < filteredTasks.length - 1}>
|
<YGroup.Item key={task.id}>
|
||||||
<ListItem
|
<ListItem
|
||||||
hoverTheme
|
hoverTheme
|
||||||
pressTheme
|
pressTheme
|
||||||
@@ -694,7 +694,7 @@ export default function MobileEventTasksPage() {
|
|||||||
icon={<Trash2 size={14} color={dangerText} />}
|
icon={<Trash2 size={14} color={dangerText} />}
|
||||||
aria-label={t('events.tasks.remove', 'Remove task')}
|
aria-label={t('events.tasks.remove', 'Remove task')}
|
||||||
disabled={busyId === task.id}
|
disabled={busyId === task.id}
|
||||||
onPress={(event) => {
|
onPress={(event: any) => {
|
||||||
event?.stopPropagation?.();
|
event?.stopPropagation?.();
|
||||||
setDeleteCandidate(task);
|
setDeleteCandidate(task);
|
||||||
}}
|
}}
|
||||||
@@ -729,9 +729,9 @@ export default function MobileEventTasksPage() {
|
|||||||
{t('events.tasks.libraryEmpty', 'Keine weiteren Aufgaben verfügbar.')}
|
{t('events.tasks.libraryEmpty', 'Keine weiteren Aufgaben verfügbar.')}
|
||||||
</Text>
|
</Text>
|
||||||
) : (
|
) : (
|
||||||
<YGroup borderWidth={1} borderColor={border} borderRadius="$4" overflow="hidden">
|
<YGroup {...({ borderWidth: 1, borderColor: border, borderRadius: "$4", overflow: "hidden" } as any)}>
|
||||||
{(expandedLibrary ? library : library.slice(0, 6)).map((task, idx, arr) => (
|
{(expandedLibrary ? library : library.slice(0, 6)).map((task, idx, arr) => (
|
||||||
<YGroup.Item key={`lib-${task.id}`} bordered={idx < arr.length - 1}>
|
<YGroup.Item key={`lib-${task.id}`}>
|
||||||
<ListItem
|
<ListItem
|
||||||
hoverTheme
|
hoverTheme
|
||||||
pressTheme
|
pressTheme
|
||||||
@@ -786,9 +786,9 @@ export default function MobileEventTasksPage() {
|
|||||||
{t('events.tasks.collectionsEmpty', 'Keine Pakete vorhanden.')}
|
{t('events.tasks.collectionsEmpty', 'Keine Pakete vorhanden.')}
|
||||||
</Text>
|
</Text>
|
||||||
) : (
|
) : (
|
||||||
<YGroup borderWidth={1} borderColor={border} borderRadius="$4" overflow="hidden">
|
<YGroup {...({ borderWidth: 1, borderColor: border, borderRadius: "$4", overflow: "hidden" } as any)}>
|
||||||
{(expandedCollections ? collections : collections.slice(0, 6)).map((collection, idx, arr) => (
|
{(expandedCollections ? collections : collections.slice(0, 6)).map((collection, idx, arr) => (
|
||||||
<YGroup.Item key={collection.id} bordered={idx < arr.length - 1}>
|
<YGroup.Item key={collection.id}>
|
||||||
<ListItem
|
<ListItem
|
||||||
hoverTheme
|
hoverTheme
|
||||||
pressTheme
|
pressTheme
|
||||||
@@ -917,9 +917,9 @@ export default function MobileEventTasksPage() {
|
|||||||
style={{ padding: 0 }}
|
style={{ padding: 0 }}
|
||||||
/>
|
/>
|
||||||
</MobileField>
|
</MobileField>
|
||||||
<YGroup borderWidth={1} borderColor={border} borderRadius="$4" overflow="hidden">
|
<YGroup {...({ borderWidth: 1, borderColor: border, borderRadius: "$4", overflow: "hidden" } as any)}>
|
||||||
{emotions.map((em, idx) => (
|
{emotions.map((em, idx) => (
|
||||||
<YGroup.Item key={`emo-${em.id}`} bordered={idx < emotions.length - 1}>
|
<YGroup.Item key={`emo-${em.id}`}>
|
||||||
<ListItem
|
<ListItem
|
||||||
hoverTheme
|
hoverTheme
|
||||||
pressTheme
|
pressTheme
|
||||||
@@ -1000,9 +1000,9 @@ export default function MobileEventTasksPage() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<AlertDialog.Portal>
|
<AlertDialog.Portal>
|
||||||
<AlertDialog.Overlay backgroundColor={`${overlay}66`} />
|
<AlertDialog.Overlay backgroundColor={`${overlay}66` as any} />
|
||||||
<AlertDialog.Content
|
<AlertDialog.Content
|
||||||
borderRadius={20}
|
{...({ borderRadius: 20 } as any)}
|
||||||
borderWidth={1}
|
borderWidth={1}
|
||||||
borderColor={border}
|
borderColor={border}
|
||||||
backgroundColor={surface}
|
backgroundColor={surface}
|
||||||
@@ -1058,8 +1058,8 @@ export default function MobileEventTasksPage() {
|
|||||||
title={t('events.tasks.actions', 'Aktionen')}
|
title={t('events.tasks.actions', 'Aktionen')}
|
||||||
footer={null}
|
footer={null}
|
||||||
>
|
>
|
||||||
<YGroup borderWidth={1} borderColor={border} borderRadius="$4" overflow="hidden">
|
<YGroup {...({ borderWidth: 1, borderColor: border, borderRadius: "$4", overflow: "hidden" } as any)}>
|
||||||
<YGroup.Item bordered>
|
<YGroup.Item>
|
||||||
<ListItem
|
<ListItem
|
||||||
hoverTheme
|
hoverTheme
|
||||||
pressTheme
|
pressTheme
|
||||||
@@ -1077,7 +1077,7 @@ export default function MobileEventTasksPage() {
|
|||||||
iconAfter={<ChevronRight size={14} color={subtle} />}
|
iconAfter={<ChevronRight size={14} color={subtle} />}
|
||||||
/>
|
/>
|
||||||
</YGroup.Item>
|
</YGroup.Item>
|
||||||
<YGroup.Item bordered>
|
<YGroup.Item>
|
||||||
<ListItem
|
<ListItem
|
||||||
hoverTheme
|
hoverTheme
|
||||||
pressTheme
|
pressTheme
|
||||||
|
|||||||
@@ -334,7 +334,7 @@ export default function MobileNotificationsPage() {
|
|||||||
const reload = React.useCallback(async () => {
|
const reload = React.useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const data = await loadNotifications(t, events, { scope: scopeParam, status: statusParam, eventSlug: slug });
|
const data = await loadNotifications(t as any, events, { scope: scopeParam, status: statusParam, eventSlug: slug });
|
||||||
setNotifications(data);
|
setNotifications(data);
|
||||||
setError(null);
|
setError(null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -691,6 +691,7 @@ export default function MobileNotificationsPage() {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
title={selectedNotification?.title ?? t('mobileNotifications.title', 'Notifications')}
|
title={selectedNotification?.title ?? t('mobileNotifications.title', 'Notifications')}
|
||||||
|
snapPoints={[94]}
|
||||||
footer={
|
footer={
|
||||||
selectedNotification && !selectedNotification.is_read ? (
|
selectedNotification && !selectedNotification.is_read ? (
|
||||||
<CTAButton label={t('notificationLogs.markRead', 'Mark as read')} onPress={() => markSelectedRead()} />
|
<CTAButton label={t('notificationLogs.markRead', 'Mark as read')} onPress={() => markSelectedRead()} />
|
||||||
@@ -705,7 +706,7 @@ export default function MobileNotificationsPage() {
|
|||||||
<Text fontSize="$sm" color={muted}>
|
<Text fontSize="$sm" color={muted}>
|
||||||
{selectedNotification.body}
|
{selectedNotification.body}
|
||||||
</Text>
|
</Text>
|
||||||
<XStack space="$2">
|
<XStack space="$2" flexWrap="wrap" style={{ rowGap: 8 }}>
|
||||||
<PillBadge tone={selectedNotification.tone === 'warning' ? 'warning' : 'muted'}>
|
<PillBadge tone={selectedNotification.tone === 'warning' ? 'warning' : 'muted'}>
|
||||||
{selectedNotification.scope}
|
{selectedNotification.scope}
|
||||||
</PillBadge>
|
</PillBadge>
|
||||||
|
|||||||
@@ -1,25 +1,31 @@
|
|||||||
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, ShoppingBag, Sparkles, Star } from 'lucide-react';
|
import { Check, ChevronRight, ShieldCheck, Sparkles, X } 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, createTenantPaddleCheckout, Package, getTenantPackagesOverview, TenantPackageSummary } from '../api';
|
import { getPackages, 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, surface, accentSoft, warningText } = useAdminTheme();
|
const { textStrong, muted, border, primary, accentSoft } = 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);
|
||||||
@@ -39,7 +45,7 @@ export default function MobilePackageShopPage() {
|
|||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<MobileShell title={t('shop.title', 'Upgrade Package')} showBack activeTab="profile">
|
<MobileShell title={t('shop.title', 'Upgrade Package')} onBack={() => navigate(-1)} activeTab="profile">
|
||||||
<YStack space="$3">
|
<YStack space="$3">
|
||||||
<SkeletonCard height={150} />
|
<SkeletonCard height={150} />
|
||||||
<SkeletonCard height={150} />
|
<SkeletonCard height={150} />
|
||||||
@@ -57,21 +63,38 @@ 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) => {
|
||||||
// 1. Recommended feature first
|
if (recommendedPackageId) {
|
||||||
const aHasFeature = recommendedFeature && a.features?.[recommendedFeature];
|
if (a.id === recommendedPackageId && b.id !== recommendedPackageId) return -1;
|
||||||
const bHasFeature = recommendedFeature && b.features?.[recommendedFeature];
|
if (b.id === recommendedPackageId && a.id !== recommendedPackageId) return 1;
|
||||||
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')} showBack activeTab="profile">
|
<MobileShell title={t('shop.title', 'Upgrade Package')} onBack={() => navigate(-1)} activeTab="profile">
|
||||||
<YStack space="$4">
|
<YStack space="$4">
|
||||||
{recommendedFeature && (
|
{recommendedFeature && (
|
||||||
<MobileCard borderColor={primary} backgroundColor={accentSoft} space="$2" padding="$3">
|
<MobileCard borderColor={primary} backgroundColor={accentSoft} space="$2" padding="$3">
|
||||||
@@ -93,23 +116,45 @@ export default function MobilePackageShopPage() {
|
|||||||
</Text>
|
</Text>
|
||||||
</YStack>
|
</YStack>
|
||||||
|
|
||||||
<YStack space="$3">
|
{packageEntries.length > 1 ? (
|
||||||
{sortedPackages.map((pkg) => {
|
<XStack space="$2" paddingHorizontal="$2">
|
||||||
const owned = inventory?.packages?.find(p => p.package_id === pkg.id);
|
<CTAButton
|
||||||
const isActive = inventory?.activePackage?.package_id === pkg.id;
|
label={t('shop.compare.toggleCards', 'Cards')}
|
||||||
const isRecommended = recommendedFeature && pkg.features?.[recommendedFeature];
|
tone={viewMode === 'cards' ? 'primary' : 'ghost'}
|
||||||
|
fullWidth={false}
|
||||||
return (
|
onPress={() => setViewMode('cards')}
|
||||||
<PackageShopCard
|
style={{ flex: 1 }}
|
||||||
key={pkg.id}
|
|
||||||
pkg={pkg}
|
|
||||||
owned={owned}
|
|
||||||
isActive={isActive}
|
|
||||||
isRecommended={isRecommended}
|
|
||||||
onSelect={() => setSelectedPackage(pkg)}
|
|
||||||
/>
|
/>
|
||||||
);
|
<CTAButton
|
||||||
})}
|
label={t('shop.compare.toggleCompare', 'Compare')}
|
||||||
|
tone={viewMode === 'compare' ? 'primary' : 'ghost'}
|
||||||
|
fullWidth={false}
|
||||||
|
onPress={() => setViewMode('compare')}
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
/>
|
||||||
|
</XStack>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<YStack space="$3">
|
||||||
|
{viewMode === 'compare' ? (
|
||||||
|
<PackageShopCompareView
|
||||||
|
entries={packageEntries}
|
||||||
|
onSelect={(pkg) => setSelectedPackage(pkg)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
packageEntries.map((entry) => (
|
||||||
|
<PackageShopCard
|
||||||
|
key={entry.pkg.id}
|
||||||
|
pkg={entry.pkg}
|
||||||
|
owned={entry.owned}
|
||||||
|
isActive={entry.isActive}
|
||||||
|
isRecommended={entry.isRecommended}
|
||||||
|
isUpgrade={entry.isUpgrade}
|
||||||
|
isDowngrade={entry.isDowngrade}
|
||||||
|
onSelect={() => setSelectedPackage(entry.pkg)}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
</YStack>
|
</YStack>
|
||||||
</YStack>
|
</YStack>
|
||||||
</MobileShell>
|
</MobileShell>
|
||||||
@@ -121,34 +166,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 hasRemainingEvents = owned && (owned.remaining_events === null || owned.remaining_events > 0);
|
const statusLabel = getPackageStatusLabel({ t, isActive, owned });
|
||||||
const statusLabel = isActive
|
const isSubdued = Boolean((isDowngrade || !isUpgrade) && !isActive);
|
||||||
? t('shop.status.active', 'Active Plan')
|
const canSelect = canSelectPackage(isUpgrade, isActive);
|
||||||
: 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={onSelect}
|
onPress={canSelect ? onSelect : undefined}
|
||||||
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={{ backgroundColor: accentSoft }}
|
pressStyle={canSelect ? { backgroundColor: accentSoft } : undefined}
|
||||||
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">
|
||||||
@@ -156,7 +201,9 @@ function PackageShopCard({
|
|||||||
<Text fontSize="$lg" fontWeight="800" color={textStrong}>
|
<Text fontSize="$lg" fontWeight="800" color={textStrong}>
|
||||||
{pkg.name}
|
{pkg.name}
|
||||||
</Text>
|
</Text>
|
||||||
{isRecommended && <PillBadge tone="primary">{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>
|
||||||
|
|
||||||
@@ -171,7 +218,9 @@ function PackageShopCard({
|
|||||||
)}
|
)}
|
||||||
</XStack>
|
</XStack>
|
||||||
</YStack>
|
</YStack>
|
||||||
<ChevronRight size={20} color={muted} marginTop="$2" />
|
<YStack marginTop="$2">
|
||||||
|
<ChevronRight size={20} color={muted} />
|
||||||
|
</YStack>
|
||||||
</XStack>
|
</XStack>
|
||||||
|
|
||||||
<YStack space="$1.5">
|
<YStack space="$1.5">
|
||||||
@@ -185,19 +234,25 @@ function PackageShopCard({
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{/* Render specific feature if it was requested */}
|
{/* Render specific feature if it was requested */}
|
||||||
{Object.entries(pkg.features || {})
|
{getEnabledPackageFeatures(pkg)
|
||||||
.filter(([key, val]) => val === true && (!pkg.max_photos || key !== 'photos'))
|
.filter((key) => !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={isActive ? t('shop.manage', 'Manage Plan') : t('shop.select', 'Select')}
|
label={
|
||||||
onPress={onSelect}
|
isActive
|
||||||
tone={isActive ? 'ghost' : 'primary'}
|
? t('shop.manage', 'Manage Plan')
|
||||||
|
: isUpgrade
|
||||||
|
? t('shop.select', 'Select')
|
||||||
|
: t('shop.selectDisabled', 'Not available')
|
||||||
|
}
|
||||||
|
onPress={canSelect ? onSelect : undefined}
|
||||||
|
tone={isActive || !isUpgrade ? 'ghost' : 'primary'}
|
||||||
|
disabled={!canSelect}
|
||||||
/>
|
/>
|
||||||
</MobileCard>
|
</MobileCard>
|
||||||
);
|
);
|
||||||
@@ -213,32 +268,228 @@ 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, danger } = useAdminTheme();
|
const { textStrong, muted, border, primary } = 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, setBusy] = React.useState(false);
|
const { busy, startCheckout } = usePackageCheckout();
|
||||||
|
|
||||||
const canProceed = agbAccepted && withdrawalAccepted;
|
const canProceed = agbAccepted && withdrawalAccepted;
|
||||||
|
|
||||||
const handleCheckout = async () => {
|
const handleCheckout = async () => {
|
||||||
if (!canProceed || busy) return;
|
if (!canProceed || busy) return;
|
||||||
setBusy(true);
|
await startCheckout(pkg.id);
|
||||||
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 (
|
||||||
<MobileShell title={t('shop.confirmTitle', 'Confirm Purchase')} onBack={onCancel}>
|
<MobileShell title={t('shop.confirmTitle', 'Confirm Purchase')} onBack={onCancel} activeTab="profile">
|
||||||
<YStack space="$4">
|
<YStack space="$4">
|
||||||
<MobileCard space="$2" borderColor={border}>
|
<MobileCard space="$2" borderColor={border}>
|
||||||
<Text fontSize="$sm" color={muted}>{t('shop.confirmSubtitle', 'You are upgrading to:')}</Text>
|
<Text fontSize="$sm" color={muted}>{t('shop.confirmSubtitle', 'You are upgrading to:')}</Text>
|
||||||
|
|||||||
@@ -82,9 +82,10 @@ export default function MobileProfilePage() {
|
|||||||
<Text fontSize="$md" fontWeight="800" color={textColor}>
|
<Text fontSize="$md" fontWeight="800" color={textColor}>
|
||||||
{t('mobileProfile.settings', 'Settings')}
|
{t('mobileProfile.settings', 'Settings')}
|
||||||
</Text>
|
</Text>
|
||||||
<YGroup borderRadius="$4" borderWidth={1} borderColor={borderColor} overflow="hidden">
|
<YStack space="$4">
|
||||||
<YGroup.Item bordered>
|
<YGroup {...({ borderRadius: "$4", borderWidth: 1, borderColor: borderColor, overflow: "hidden" } as any)}>
|
||||||
<Pressable onPress={() => navigate(adminPath('/mobile/settings'))}>
|
<YGroup.Item>
|
||||||
|
<Pressable onPress={() => navigate(adminPath('/mobile/profile/security'))}>
|
||||||
<ListItem
|
<ListItem
|
||||||
hoverTheme
|
hoverTheme
|
||||||
pressTheme
|
pressTheme
|
||||||
@@ -99,7 +100,7 @@ export default function MobileProfilePage() {
|
|||||||
/>
|
/>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</YGroup.Item>
|
</YGroup.Item>
|
||||||
<YGroup.Item bordered>
|
<YGroup.Item>
|
||||||
<Pressable onPress={() => navigate(adminPath('/mobile/billing#packages'))}>
|
<Pressable onPress={() => navigate(adminPath('/mobile/billing#packages'))}>
|
||||||
<ListItem
|
<ListItem
|
||||||
hoverTheme
|
hoverTheme
|
||||||
@@ -115,7 +116,7 @@ export default function MobileProfilePage() {
|
|||||||
/>
|
/>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</YGroup.Item>
|
</YGroup.Item>
|
||||||
<YGroup.Item bordered>
|
<YGroup.Item>
|
||||||
<Pressable onPress={() => navigate(adminPath('/mobile/billing#invoices'))}>
|
<Pressable onPress={() => navigate(adminPath('/mobile/billing#invoices'))}>
|
||||||
<ListItem
|
<ListItem
|
||||||
hoverTheme
|
hoverTheme
|
||||||
@@ -131,7 +132,7 @@ export default function MobileProfilePage() {
|
|||||||
/>
|
/>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</YGroup.Item>
|
</YGroup.Item>
|
||||||
<YGroup.Item bordered>
|
<YGroup.Item>
|
||||||
<Pressable onPress={() => navigate(ADMIN_DATA_EXPORTS_PATH)}>
|
<Pressable onPress={() => navigate(ADMIN_DATA_EXPORTS_PATH)}>
|
||||||
<ListItem
|
<ListItem
|
||||||
hoverTheme
|
hoverTheme
|
||||||
@@ -147,7 +148,7 @@ export default function MobileProfilePage() {
|
|||||||
/>
|
/>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</YGroup.Item>
|
</YGroup.Item>
|
||||||
<YGroup.Item bordered>
|
<YGroup.Item>
|
||||||
<ListItem
|
<ListItem
|
||||||
paddingVertical="$2"
|
paddingVertical="$2"
|
||||||
paddingHorizontal="$3"
|
paddingHorizontal="$3"
|
||||||
@@ -203,6 +204,7 @@ export default function MobileProfilePage() {
|
|||||||
/>
|
/>
|
||||||
</YGroup.Item>
|
</YGroup.Item>
|
||||||
</YGroup>
|
</YGroup>
|
||||||
|
</YStack>
|
||||||
</MobileCard>
|
</MobileCard>
|
||||||
|
|
||||||
<CTAButton
|
<CTAButton
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ export default function MobileQrLayoutCustomizePage() {
|
|||||||
const layoutParam = searchParams.get('layout');
|
const layoutParam = searchParams.get('layout');
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { t } = useTranslation('management');
|
const { t } = useTranslation('management');
|
||||||
const { textStrong, danger } = useAdminTheme();
|
const { textStrong, danger, muted, border, primary, surface, surfaceMuted, overlay, shadow } = useAdminTheme();
|
||||||
|
|
||||||
const [event, setEvent] = React.useState<TenantEvent | null>(null);
|
const [event, setEvent] = React.useState<TenantEvent | null>(null);
|
||||||
const [invite, setInvite] = React.useState<EventQrInvite | null>(null);
|
const [invite, setInvite] = React.useState<EventQrInvite | null>(null);
|
||||||
@@ -420,9 +420,9 @@ function renderEventName(name: TenantEvent['name'] | null | undefined): string |
|
|||||||
|
|
||||||
function getDefaultSlots(): Record<string, SlotDefinition> {
|
function getDefaultSlots(): Record<string, SlotDefinition> {
|
||||||
return {
|
return {
|
||||||
headline: { x: 0.08, y: 0.12, w: 0.84, fontSize: 90, fontWeight: 800, fontFamily: DEFAULT_DISPLAY_FONT, align: 'center' },
|
headline: { x: 0.08, y: 0.12, w: 0.84, fontSize: 90, fontWeight: 800, fontFamily: DEFAULT_DISPLAY_FONT, align: 'center' as const },
|
||||||
subtitle: { x: 0.1, y: 0.21, w: 0.8, fontSize: 37, fontWeight: 600, fontFamily: DEFAULT_BODY_FONT, align: 'center' },
|
subtitle: { x: 0.1, y: 0.21, w: 0.8, fontSize: 37, fontWeight: 600, fontFamily: DEFAULT_BODY_FONT, align: 'center' as const },
|
||||||
description: { x: 0.1, y: 0.66, w: 0.8, fontSize: 26, fontFamily: DEFAULT_BODY_FONT, lineHeight: 1.4, align: 'center' },
|
description: { x: 0.1, y: 0.66, w: 0.8, fontSize: 26, fontFamily: DEFAULT_BODY_FONT, lineHeight: 1.4, align: 'center' as const },
|
||||||
instructions: { x: 0.1, y: 0.7, w: 0.8, fontSize: 24, fontFamily: DEFAULT_BODY_FONT, lineHeight: 1.5 },
|
instructions: { x: 0.1, y: 0.7, w: 0.8, fontSize: 24, fontFamily: DEFAULT_BODY_FONT, lineHeight: 1.5 },
|
||||||
qr: { x: 0.39, y: 0.37, w: 0.27 },
|
qr: { x: 0.39, y: 0.37, w: 0.27 },
|
||||||
};
|
};
|
||||||
@@ -453,9 +453,9 @@ function resolveSlots(layout: EventQrInviteLayout | null, isFoldable: boolean, o
|
|||||||
|
|
||||||
const baseSlots = isFoldable
|
const baseSlots = isFoldable
|
||||||
? {
|
? {
|
||||||
headline: { x: 0.08, y: 0.15, w: 0.84, fontSize: 50, fontWeight: 800, fontFamily: DEFAULT_DISPLAY_FONT, align: 'center' },
|
headline: { x: 0.08, y: 0.15, w: 0.84, fontSize: 50, fontWeight: 800, fontFamily: DEFAULT_DISPLAY_FONT, align: 'center' as const },
|
||||||
subtitle: { x: 0.1, y: 0.21, w: 0.8, fontSize: 37, fontWeight: 600, fontFamily: DEFAULT_BODY_FONT, align: 'center' },
|
subtitle: { x: 0.1, y: 0.21, w: 0.8, fontSize: 37, fontWeight: 600, fontFamily: DEFAULT_BODY_FONT, align: 'center' as const },
|
||||||
description: { x: 0.1, y: 0.66, w: 0.8, fontSize: 13, fontFamily: DEFAULT_BODY_FONT, lineHeight: 1.4, align: 'center' },
|
description: { x: 0.1, y: 0.66, w: 0.8, fontSize: 13, fontFamily: DEFAULT_BODY_FONT, lineHeight: 1.4, align: 'center' as const },
|
||||||
instructions: { x: 0.12, y: 0.36, w: 0.76, fontSize: 13, fontFamily: DEFAULT_BODY_FONT, lineHeight: 1.3 },
|
instructions: { x: 0.12, y: 0.36, w: 0.76, fontSize: 13, fontFamily: DEFAULT_BODY_FONT, lineHeight: 1.3 },
|
||||||
qr: { x: 0.3, y: 0.3, w: 0.28 },
|
qr: { x: 0.3, y: 0.3, w: 0.28 },
|
||||||
}
|
}
|
||||||
@@ -520,8 +520,8 @@ function buildFabricOptions({
|
|||||||
const elements: LayoutElement[] = [];
|
const elements: LayoutElement[] = [];
|
||||||
const textColor = layout?.preview?.text ?? ADMIN_COLORS.backdrop;
|
const textColor = layout?.preview?.text ?? ADMIN_COLORS.backdrop;
|
||||||
const accentColor = layout?.preview?.accent ?? ADMIN_COLORS.primary;
|
const accentColor = layout?.preview?.accent ?? ADMIN_COLORS.primary;
|
||||||
const secondaryColor = layout?.preview?.secondary ?? ADMIN_COLORS.text;
|
const secondaryColor = (layout?.preview as any)?.secondary ?? ADMIN_COLORS.text;
|
||||||
const badgeColor = layout?.preview?.badge ?? accentColor;
|
const badgeColor = (layout?.preview as any)?.badge ?? accentColor;
|
||||||
|
|
||||||
const rotatePoint = (cx: number, cy: number, x: number, y: number, angleDeg: number) => {
|
const rotatePoint = (cx: number, cy: number, x: number, y: number, angleDeg: number) => {
|
||||||
const rad = (angleDeg * Math.PI) / 180;
|
const rad = (angleDeg * Math.PI) / 180;
|
||||||
@@ -862,15 +862,18 @@ function TextStep({
|
|||||||
textFields,
|
textFields,
|
||||||
onChange,
|
onChange,
|
||||||
onSave,
|
onSave,
|
||||||
|
onBulkAdd,
|
||||||
saving,
|
saving,
|
||||||
}: {
|
}: {
|
||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
textFields: { headline: string; subtitle: string; description: string; instructions: string[] };
|
textFields: { headline: string; subtitle: string; description: string; instructions: string[] };
|
||||||
onChange: (fields: { headline: string; subtitle: string; description: string; instructions: string[] }) => void;
|
onChange: (fields: { headline: string; subtitle: string; description: string; instructions: string[] }) => void;
|
||||||
onSave: () => void;
|
onSave: () => void;
|
||||||
|
onBulkAdd?: () => void;
|
||||||
saving: boolean;
|
saving: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation('management');
|
const { t } = useTranslation('management');
|
||||||
|
const { textStrong, border, surface, muted } = useAdminTheme();
|
||||||
|
|
||||||
const updateField = (key: 'headline' | 'subtitle' | 'description', value: string) => {
|
const updateField = (key: 'headline' | 'subtitle' | 'description', value: string) => {
|
||||||
onChange({ ...textFields, [key]: value });
|
onChange({ ...textFields, [key]: value });
|
||||||
@@ -941,7 +944,7 @@ function TextStep({
|
|||||||
onChangeText={(val) => updateInstruction(idx, val)}
|
onChangeText={(val) => updateInstruction(idx, val)}
|
||||||
placeholder={t('events.qr.instructionsPlaceholder', 'Schritt hinzufügen')}
|
placeholder={t('events.qr.instructionsPlaceholder', 'Schritt hinzufügen')}
|
||||||
numberOfLines={2}
|
numberOfLines={2}
|
||||||
flex={1}
|
{...({ flex: 1 } as any)}
|
||||||
size="$4"
|
size="$4"
|
||||||
/>
|
/>
|
||||||
<Pressable onPress={() => removeInstruction(idx)} disabled={textFields.instructions.length === 1}>
|
<Pressable onPress={() => removeInstruction(idx)} disabled={textFields.instructions.length === 1}>
|
||||||
@@ -1096,7 +1099,8 @@ function PreviewStep({
|
|||||||
|
|
||||||
const aspectRatio = `${canvasBase.width}/${canvasBase.height}`;
|
const aspectRatio = `${canvasBase.width}/${canvasBase.height}`;
|
||||||
const paper = resolvePaper(layout);
|
const paper = resolvePaper(layout);
|
||||||
const isLandscape = ((layout?.orientation ?? (isFoldable ? 'landscape' : 'portrait')) || '').toLowerCase() === 'landscape';
|
const orientation = ((layout?.orientation ?? (isFoldable ? 'landscape' : 'portrait')) || '').toString().toLowerCase();
|
||||||
|
const isLandscape = orientation === 'landscape';
|
||||||
const orientationLabel = isLandscape
|
const orientationLabel = isLandscape
|
||||||
? t('events.qr.orientation.landscape', 'Landscape')
|
? t('events.qr.orientation.landscape', 'Landscape')
|
||||||
: t('events.qr.orientation.portrait', 'Portrait');
|
: t('events.qr.orientation.portrait', 'Portrait');
|
||||||
@@ -1157,14 +1161,14 @@ function PreviewStep({
|
|||||||
try {
|
try {
|
||||||
await loadFonts();
|
await loadFonts();
|
||||||
const pdfBytes = await generatePdfBytes(exportOptions, paper, orientation);
|
const pdfBytes = await generatePdfBytes(exportOptions, paper, orientation);
|
||||||
const blob = new Blob([pdfBytes], { type: 'application/pdf' });
|
const blob = new Blob([pdfBytes as any], { type: 'application/pdf' });
|
||||||
triggerDownloadFromBlob(blob, 'qr-layout.pdf');
|
triggerDownloadFromBlob(blob, 'qr-layout.pdf');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(t('events.qr.exportFailed', 'Export fehlgeschlagen'));
|
toast.error(t('events.qr.exportFailed', 'Export fehlgeschlagen'));
|
||||||
console.error(err);
|
console.error(err);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
style={{ flex: 1, minWidth: 0 }}
|
style={{ flex: 1, minWidth: 0 } as any}
|
||||||
/>
|
/>
|
||||||
<CTAButton
|
<CTAButton
|
||||||
label={t('events.qr.exportPng', 'Export PNG')}
|
label={t('events.qr.exportPng', 'Export PNG')}
|
||||||
@@ -1178,7 +1182,7 @@ function PreviewStep({
|
|||||||
console.error(err);
|
console.error(err);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
style={{ flex: 1, minWidth: 0 }}
|
style={{ flex: 1, minWidth: 0 } as any}
|
||||||
/>
|
/>
|
||||||
</XStack>
|
</XStack>
|
||||||
</YStack>
|
</YStack>
|
||||||
@@ -1315,7 +1319,7 @@ function LayoutControls({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Accordion.Item value={slotKey} key={slotKey}>
|
<Accordion.Item value={slotKey} key={slotKey}>
|
||||||
<Accordion.Trigger padding="$2" borderWidth={1} borderColor={border} borderRadius={12} backgroundColor={surfaceMuted}>
|
<Accordion.Trigger {...({ padding: "$2", borderWidth: 1, borderColor: border, borderRadius: 12, backgroundColor: surfaceMuted } as any)}>
|
||||||
<XStack justifyContent="space-between" alignItems="center" flex={1}>
|
<XStack justifyContent="space-between" alignItems="center" flex={1}>
|
||||||
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
|
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
|
||||||
{label}
|
{label}
|
||||||
@@ -1323,7 +1327,7 @@ function LayoutControls({
|
|||||||
<ChevronDown size={16} color={muted} />
|
<ChevronDown size={16} color={muted} />
|
||||||
</XStack>
|
</XStack>
|
||||||
</Accordion.Trigger>
|
</Accordion.Trigger>
|
||||||
<Accordion.Content paddingTop="$2">
|
<Accordion.Content {...({ paddingTop: "$2" } as any)}>
|
||||||
<YStack space="$2" padding="$2" borderWidth={1} borderColor={border} borderRadius={12}>
|
<YStack space="$2" padding="$2" borderWidth={1} borderColor={border} borderRadius={12}>
|
||||||
<XStack space="$3">
|
<XStack space="$3">
|
||||||
<YStack flex={1} space="$1">
|
<YStack flex={1} space="$1">
|
||||||
@@ -1546,7 +1550,7 @@ function LayoutControls({
|
|||||||
|
|
||||||
{qrSlot ? (
|
{qrSlot ? (
|
||||||
<Accordion.Item value="qr">
|
<Accordion.Item value="qr">
|
||||||
<Accordion.Trigger padding="$2" borderWidth={1} borderColor={border} borderRadius={12} backgroundColor={surfaceMuted}>
|
<Accordion.Trigger {...({ padding: "$2", borderWidth: 1, borderColor: border, borderRadius: 12, backgroundColor: surfaceMuted } as any)}>
|
||||||
<XStack justifyContent="space-between" alignItems="center" flex={1}>
|
<XStack justifyContent="space-between" alignItems="center" flex={1}>
|
||||||
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
|
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
|
||||||
{t('events.qr.qr_code_label', 'QR‑Code')}
|
{t('events.qr.qr_code_label', 'QR‑Code')}
|
||||||
@@ -1554,7 +1558,7 @@ function LayoutControls({
|
|||||||
<ChevronDown size={16} color={muted} />
|
<ChevronDown size={16} color={muted} />
|
||||||
</XStack>
|
</XStack>
|
||||||
</Accordion.Trigger>
|
</Accordion.Trigger>
|
||||||
<Accordion.Content paddingTop="$2">
|
<Accordion.Content {...({ paddingTop: "$2" } as any)}>
|
||||||
<YStack space="$2" padding="$2" borderWidth={1} borderColor={border} borderRadius={12}>
|
<YStack space="$2" padding="$2" borderWidth={1} borderColor={border} borderRadius={12}>
|
||||||
<XStack space="$2">
|
<XStack space="$2">
|
||||||
<YStack flex={1} space="$1">
|
<YStack flex={1} space="$1">
|
||||||
|
|||||||
@@ -544,6 +544,7 @@ function PreviewStep({
|
|||||||
presets,
|
presets,
|
||||||
textFields,
|
textFields,
|
||||||
qrUrl,
|
qrUrl,
|
||||||
|
qrImage,
|
||||||
onExport,
|
onExport,
|
||||||
}: {
|
}: {
|
||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
@@ -552,6 +553,7 @@ function PreviewStep({
|
|||||||
presets: { id: string; src: string; label: string }[];
|
presets: { id: string; src: string; label: string }[];
|
||||||
textFields: { headline: string; subtitle: string; description: string; instructions: string[] };
|
textFields: { headline: string; subtitle: string; description: string; instructions: string[] };
|
||||||
qrUrl: string;
|
qrUrl: string;
|
||||||
|
qrImage: string;
|
||||||
onExport: (format: 'pdf' | 'png') => void;
|
onExport: (format: 'pdf' | 'png') => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation('management');
|
const { t } = useTranslation('management');
|
||||||
|
|||||||
@@ -241,8 +241,8 @@ export default function MobileSettingsPage() {
|
|||||||
{t('mobileSettings.notificationsLoading', 'Loading settings ...')}
|
{t('mobileSettings.notificationsLoading', 'Loading settings ...')}
|
||||||
</Text>
|
</Text>
|
||||||
) : (
|
) : (
|
||||||
<YGroup borderRadius="$4" borderWidth={1} borderColor={border} overflow="hidden">
|
<YGroup {...({ borderRadius: "$4", borderWidth: 1, borderColor: border, overflow: "hidden" } as any)}>
|
||||||
<YGroup.Item bordered>
|
<YGroup.Item>
|
||||||
<ListItem
|
<ListItem
|
||||||
hoverTheme
|
hoverTheme
|
||||||
pressTheme
|
pressTheme
|
||||||
@@ -280,7 +280,7 @@ export default function MobileSettingsPage() {
|
|||||||
/>
|
/>
|
||||||
</YGroup.Item>
|
</YGroup.Item>
|
||||||
{AVAILABLE_PREFS.map((key, index) => (
|
{AVAILABLE_PREFS.map((key, index) => (
|
||||||
<YGroup.Item key={key} bordered={index < AVAILABLE_PREFS.length - 1}>
|
<YGroup.Item key={key}>
|
||||||
<ListItem
|
<ListItem
|
||||||
hoverTheme
|
hoverTheme
|
||||||
pressTheme
|
pressTheme
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ vi.mock('../../api', () => ({
|
|||||||
getEvent: vi.fn(),
|
getEvent: vi.fn(),
|
||||||
updateEvent: vi.fn(),
|
updateEvent: vi.fn(),
|
||||||
getEventTypes: vi.fn().mockResolvedValue([]),
|
getEventTypes: vi.fn().mockResolvedValue([]),
|
||||||
|
getPackages: vi.fn().mockResolvedValue([]),
|
||||||
trackOnboarding: vi.fn(),
|
trackOnboarding: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -81,6 +82,10 @@ vi.mock('../theme', () => ({
|
|||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../auth/context', () => ({
|
||||||
|
useAuth: () => ({ user: { role: 'tenant_admin' } }),
|
||||||
|
}));
|
||||||
|
|
||||||
import { getEventTypes } from '../../api';
|
import { getEventTypes } from '../../api';
|
||||||
import MobileEventFormPage from '../EventFormPage';
|
import MobileEventFormPage from '../EventFormPage';
|
||||||
|
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ describe('LimitWarnings', () => {
|
|||||||
used: 100,
|
used: 100,
|
||||||
remaining: 0,
|
remaining: 0,
|
||||||
percentage: 100,
|
percentage: 100,
|
||||||
state: 'limit_reached',
|
state: 'limit_reached' as const,
|
||||||
threshold_reached: null,
|
threshold_reached: null,
|
||||||
next_threshold: null,
|
next_threshold: null,
|
||||||
thresholds: [],
|
thresholds: [],
|
||||||
|
|||||||
33
resources/js/admin/mobile/__tests__/analytics.test.ts
Normal file
33
resources/js/admin/mobile/__tests__/analytics.test.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { resolveMaxCount, resolveTimelineHours } from '../lib/analytics';
|
||||||
|
|
||||||
|
describe('resolveMaxCount', () => {
|
||||||
|
it('defaults to 1 for empty input', () => {
|
||||||
|
expect(resolveMaxCount([])).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the highest count', () => {
|
||||||
|
expect(resolveMaxCount([2, 5, 3])).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never returns less than 1', () => {
|
||||||
|
expect(resolveMaxCount([0])).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('resolveTimelineHours', () => {
|
||||||
|
it('uses fallback when data is missing', () => {
|
||||||
|
expect(resolveTimelineHours([], 12)).toBe(12);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calculates rounded hours from timestamps', () => {
|
||||||
|
const start = new Date('2024-01-01T10:00:00Z').toISOString();
|
||||||
|
const end = new Date('2024-01-01T21:00:00Z').toISOString();
|
||||||
|
expect(resolveTimelineHours([start, end], 12)).toBe(11);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never returns less than 1', () => {
|
||||||
|
const start = new Date('2024-01-01T10:00:00Z').toISOString();
|
||||||
|
expect(resolveTimelineHours([start, start], 12)).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
42
resources/js/admin/mobile/__tests__/billingCheckout.test.ts
Normal file
42
resources/js/admin/mobile/__tests__/billingCheckout.test.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
CHECKOUT_STORAGE_KEY,
|
||||||
|
PENDING_CHECKOUT_TTL_MS,
|
||||||
|
isCheckoutExpired,
|
||||||
|
loadPendingCheckout,
|
||||||
|
shouldClearPendingCheckout,
|
||||||
|
storePendingCheckout,
|
||||||
|
} from '../lib/billingCheckout';
|
||||||
|
|
||||||
|
describe('billingCheckout helpers', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
sessionStorage.clear();
|
||||||
|
});
|
||||||
|
it('detects expired pending checkout', () => {
|
||||||
|
const pending = { packageId: 12, startedAt: 0 };
|
||||||
|
expect(isCheckoutExpired(pending, PENDING_CHECKOUT_TTL_MS + 1)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps pending checkout when active package differs', () => {
|
||||||
|
const pending = { packageId: 12, startedAt: Date.now() };
|
||||||
|
expect(shouldClearPendingCheckout(pending, 18, pending.startedAt)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears pending checkout when active package matches', () => {
|
||||||
|
const now = Date.now();
|
||||||
|
const pending = { packageId: 12, startedAt: now };
|
||||||
|
expect(shouldClearPendingCheckout(pending, 12, now)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stores and loads pending checkout from session storage', () => {
|
||||||
|
const pending = { packageId: 7, checkoutSessionId: 'sess_123', startedAt: Date.now() };
|
||||||
|
storePendingCheckout(pending);
|
||||||
|
expect(loadPendingCheckout(pending.startedAt)).toEqual(pending);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears pending checkout storage', () => {
|
||||||
|
storePendingCheckout({ packageId: 7, checkoutSessionId: 'sess_123', startedAt: Date.now() });
|
||||||
|
storePendingCheckout(null);
|
||||||
|
expect(sessionStorage.getItem(CHECKOUT_STORAGE_KEY)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
83
resources/js/admin/mobile/__tests__/packageShop.test.ts
Normal file
83
resources/js/admin/mobile/__tests__/packageShop.test.ts
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
buildPackageComparisonRows,
|
||||||
|
classifyPackageChange,
|
||||||
|
getEnabledPackageFeatures,
|
||||||
|
selectRecommendedPackageId,
|
||||||
|
} from '../lib/packageShop';
|
||||||
|
|
||||||
|
describe('classifyPackageChange', () => {
|
||||||
|
const active = {
|
||||||
|
id: 1,
|
||||||
|
price: 200,
|
||||||
|
max_photos: 100,
|
||||||
|
max_guests: 50,
|
||||||
|
gallery_days: 30,
|
||||||
|
features: { advanced_analytics: false },
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
it('returns neutral when no active package', () => {
|
||||||
|
expect(classifyPackageChange(active, null)).toEqual({ isUpgrade: false, isDowngrade: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks upgrade when candidate adds features', () => {
|
||||||
|
const candidate = { ...active, id: 2, price: 150, features: { advanced_analytics: true } } as any;
|
||||||
|
expect(classifyPackageChange(candidate, active)).toEqual({ isUpgrade: true, isDowngrade: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks downgrade when candidate removes features or limits', () => {
|
||||||
|
const candidate = { ...active, id: 3, max_photos: 50, features: { advanced_analytics: false } } as any;
|
||||||
|
expect(classifyPackageChange(candidate, active)).toEqual({ isUpgrade: false, isDowngrade: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats mixed changes as downgrade', () => {
|
||||||
|
const candidate = { ...active, id: 4, max_photos: 200, gallery_days: 10, features: { advanced_analytics: false } } as any;
|
||||||
|
expect(classifyPackageChange(candidate, active)).toEqual({ isUpgrade: false, isDowngrade: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('selectRecommendedPackageId', () => {
|
||||||
|
const packages = [
|
||||||
|
{ id: 1, price: 100, features: { advanced_analytics: false } },
|
||||||
|
{ id: 2, price: 150, features: { advanced_analytics: true } },
|
||||||
|
{ id: 3, price: 200, features: { advanced_analytics: true } },
|
||||||
|
] as any;
|
||||||
|
|
||||||
|
it('returns null when no feature is requested', () => {
|
||||||
|
expect(selectRecommendedPackageId(packages, null, 100)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('selects the cheapest upgrade with the feature', () => {
|
||||||
|
const active = { id: 10, price: 120, max_photos: 100, max_guests: 50, gallery_days: 30, features: {} } as any;
|
||||||
|
expect(selectRecommendedPackageId(packages, 'advanced_analytics', active)).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to cheapest feature package if no upgrades exist', () => {
|
||||||
|
const active = { id: 10, price: 250, max_photos: 999, max_guests: 999, gallery_days: 365, features: { advanced_analytics: true } } as any;
|
||||||
|
expect(selectRecommendedPackageId(packages, 'advanced_analytics', active)).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('buildPackageComparisonRows', () => {
|
||||||
|
it('includes limit rows and enabled feature rows', () => {
|
||||||
|
const rows = buildPackageComparisonRows([
|
||||||
|
{ features: { advanced_analytics: true, custom_branding: false } },
|
||||||
|
{ features: { custom_branding: true, watermark_removal: true } },
|
||||||
|
] as any);
|
||||||
|
|
||||||
|
expect(rows.map((row) => row.id)).toEqual([
|
||||||
|
'limit.max_photos',
|
||||||
|
'limit.max_guests',
|
||||||
|
'limit.gallery_days',
|
||||||
|
'feature.advanced_analytics',
|
||||||
|
'feature.custom_branding',
|
||||||
|
'feature.watermark_removal',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getEnabledPackageFeatures', () => {
|
||||||
|
it('accepts array payloads', () => {
|
||||||
|
expect(getEnabledPackageFeatures({ features: ['custom_branding', ''] } as any)).toEqual(['custom_branding']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -39,9 +39,9 @@ describe('buildInitialTextFields', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('resolveLayoutForFormat', () => {
|
describe('resolveLayoutForFormat', () => {
|
||||||
const layouts: EventQrInviteLayout[] = [
|
const layouts = [
|
||||||
{ id: 'portrait-a4', paper: 'a4', orientation: 'portrait', panel_mode: 'single', format_hint: 'poster-a4' } as EventQrInviteLayout,
|
{ id: 'portrait-a4', paper: 'a4', orientation: 'portrait', panel_mode: 'single', format_hint: 'poster-a4' } as any as EventQrInviteLayout,
|
||||||
{ id: 'foldable', paper: 'a4', orientation: 'landscape', panel_mode: 'double-mirror', format_hint: 'foldable-a5' } as EventQrInviteLayout,
|
{ id: 'foldable', paper: 'a4', orientation: 'landscape', panel_mode: 'double-mirror', format_hint: 'foldable-a5' } as any as EventQrInviteLayout,
|
||||||
];
|
];
|
||||||
|
|
||||||
it('returns portrait layout for A4 poster', () => {
|
it('returns portrait layout for A4 poster', () => {
|
||||||
|
|||||||
@@ -96,10 +96,10 @@ export const buildPackageUsageMetrics = (pkg: TenantPackageSummary): PackageUsag
|
|||||||
const resolvedGalleryUsed = normalizeCount(galleryUsed);
|
const resolvedGalleryUsed = normalizeCount(galleryUsed);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{ key: 'events', limit: eventLimit, used: eventUsed, remaining: resolvedEventRemaining },
|
{ key: 'events' as const, limit: eventLimit, used: eventUsed, remaining: resolvedEventRemaining },
|
||||||
{ key: 'guests', limit: guestLimit, used: guestUsed, remaining: resolvedGuestRemaining },
|
{ key: 'guests' as const, limit: guestLimit, used: guestUsed, remaining: resolvedGuestRemaining },
|
||||||
{ key: 'photos', limit: photoLimit, used: photoUsed, remaining: resolvedPhotoRemaining },
|
{ key: 'photos' as const, limit: photoLimit, used: photoUsed, remaining: resolvedPhotoRemaining },
|
||||||
{ key: 'gallery', limit: galleryLimit, used: resolvedGalleryUsed, remaining: resolvedGalleryRemaining },
|
{ key: 'gallery' as const, limit: galleryLimit, used: resolvedGalleryUsed, remaining: resolvedGalleryRemaining },
|
||||||
].filter((metric) => metric.limit !== null);
|
].filter((metric) => metric.limit !== null);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import { useLocation } from 'react-router-dom';
|
||||||
import { YStack, XStack } from '@tamagui/stacks';
|
import { YStack, XStack } from '@tamagui/stacks';
|
||||||
import { SizableText as Text } from '@tamagui/text';
|
import { SizableText as Text } from '@tamagui/text';
|
||||||
import { Pressable } from '@tamagui/react-native-web-lite';
|
import { Pressable } from '@tamagui/react-native-web-lite';
|
||||||
import { Home, CheckSquare, Image as ImageIcon, User } from 'lucide-react';
|
import { Home, CheckSquare, Image as ImageIcon, User, LayoutDashboard } from 'lucide-react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { withAlpha } from './colors';
|
import { withAlpha } from './colors';
|
||||||
import { useAdminTheme } from '../theme';
|
import { useAdminTheme } from '../theme';
|
||||||
|
import { adminPath } from '../../constants';
|
||||||
|
|
||||||
const ICON_SIZE = 20;
|
const ICON_SIZE = 20;
|
||||||
|
|
||||||
@@ -13,12 +15,16 @@ export type NavKey = 'home' | 'tasks' | 'uploads' | 'profile';
|
|||||||
|
|
||||||
export function BottomNav({ active, onNavigate }: { active: NavKey; onNavigate: (key: NavKey) => void }) {
|
export function BottomNav({ active, onNavigate }: { active: NavKey; onNavigate: (key: NavKey) => void }) {
|
||||||
const { t } = useTranslation('mobile');
|
const { t } = useTranslation('mobile');
|
||||||
|
const location = useLocation();
|
||||||
const { surface, border, primary, accentSoft, muted, subtle, shadow } = useAdminTheme();
|
const { surface, border, primary, accentSoft, muted, subtle, shadow } = useAdminTheme();
|
||||||
const surfaceColor = surface;
|
const surfaceColor = surface;
|
||||||
const navSurface = withAlpha(surfaceColor, 0.92);
|
const navSurface = withAlpha(surfaceColor, 0.92);
|
||||||
const [pressedKey, setPressedKey] = React.useState<NavKey | null>(null);
|
const [pressedKey, setPressedKey] = React.useState<NavKey | null>(null);
|
||||||
|
|
||||||
|
const isDeepHome = active === 'home' && location.pathname !== adminPath('/mobile/dashboard');
|
||||||
|
|
||||||
const items: Array<{ key: NavKey; icon: React.ComponentType<{ size?: number; color?: string }>; label: string }> = [
|
const items: Array<{ key: NavKey; icon: React.ComponentType<{ size?: number; color?: string }>; label: string }> = [
|
||||||
{ key: 'home', icon: Home, label: t('nav.home', 'Home') },
|
{ key: 'home', icon: isDeepHome ? LayoutDashboard : Home, label: t('nav.home', 'Home') },
|
||||||
{ key: 'tasks', icon: CheckSquare, label: t('nav.tasks', 'Tasks') },
|
{ key: 'tasks', icon: CheckSquare, label: t('nav.tasks', 'Tasks') },
|
||||||
{ key: 'uploads', icon: ImageIcon, label: t('nav.uploads', 'Uploads') },
|
{ key: 'uploads', icon: ImageIcon, label: t('nav.uploads', 'Uploads') },
|
||||||
{ key: 'profile', icon: User, label: t('nav.profile', 'Profile') },
|
{ key: 'profile', icon: User, label: t('nav.profile', 'Profile') },
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ export const MobileInput = React.forwardRef<HTMLInputElement, React.ComponentPro
|
|||||||
<Input
|
<Input
|
||||||
ref={ref as React.Ref<any>}
|
ref={ref as React.Ref<any>}
|
||||||
{...props}
|
{...props}
|
||||||
type={type}
|
{...({ type } as any)}
|
||||||
secureTextEntry={isPassword}
|
secureTextEntry={isPassword}
|
||||||
onChangeText={(value) => {
|
onChangeText={(value) => {
|
||||||
onChange?.({ target: { value } } as React.ChangeEvent<HTMLInputElement>);
|
onChange?.({ target: { value } } as React.ChangeEvent<HTMLInputElement>);
|
||||||
@@ -75,11 +75,11 @@ export const MobileInput = React.forwardRef<HTMLInputElement, React.ComponentPro
|
|||||||
focusStyle={{
|
focusStyle={{
|
||||||
borderColor: hasError ? danger : primary,
|
borderColor: hasError ? danger : primary,
|
||||||
boxShadow: `0 0 0 3px ${ringColor}`,
|
boxShadow: `0 0 0 3px ${ringColor}`,
|
||||||
}}
|
} as any}
|
||||||
hoverStyle={{
|
hoverStyle={{
|
||||||
borderColor,
|
borderColor,
|
||||||
}}
|
} as any}
|
||||||
style={style}
|
style={style as any}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -97,11 +97,11 @@ export const MobileTextArea = React.forwardRef<
|
|||||||
<TextArea
|
<TextArea
|
||||||
ref={ref as React.Ref<any>}
|
ref={ref as React.Ref<any>}
|
||||||
{...props}
|
{...props}
|
||||||
|
{...({ minHeight: compact ? 72 : 96 } as any)}
|
||||||
onChangeText={(value) => {
|
onChangeText={(value) => {
|
||||||
onChange?.({ target: { value } } as React.ChangeEvent<HTMLTextAreaElement>);
|
onChange?.({ target: { value } } as React.ChangeEvent<HTMLTextAreaElement>);
|
||||||
}}
|
}}
|
||||||
size={compact ? '$3' : '$4'}
|
size={compact ? '$3' : '$4'}
|
||||||
minHeight={compact ? 72 : 96}
|
|
||||||
borderRadius={12}
|
borderRadius={12}
|
||||||
padding="$3"
|
padding="$3"
|
||||||
width="100%"
|
width="100%"
|
||||||
@@ -112,11 +112,11 @@ export const MobileTextArea = React.forwardRef<
|
|||||||
focusStyle={{
|
focusStyle={{
|
||||||
borderColor: hasError ? danger : primary,
|
borderColor: hasError ? danger : primary,
|
||||||
boxShadow: `0 0 0 3px ${ringColor}`,
|
boxShadow: `0 0 0 3px ${ringColor}`,
|
||||||
}}
|
} as any}
|
||||||
hoverStyle={{
|
hoverStyle={{
|
||||||
borderColor,
|
borderColor,
|
||||||
}}
|
} as any}
|
||||||
style={{ resize: 'vertical', ...style }}
|
style={{ resize: 'vertical', ...style } as any}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -173,36 +173,36 @@ export function MobileSelect({
|
|||||||
width="100%"
|
width="100%"
|
||||||
borderRadius={12}
|
borderRadius={12}
|
||||||
borderWidth={1}
|
borderWidth={1}
|
||||||
borderColor={borderColor}
|
borderColor={borderColor as any}
|
||||||
backgroundColor={surface}
|
backgroundColor={surface as any}
|
||||||
paddingVertical={compact ? 6 : 10}
|
paddingVertical={compact ? 6 : 10}
|
||||||
paddingHorizontal="$3"
|
paddingHorizontal="$3"
|
||||||
disabled={props.disabled}
|
disabled={props.disabled}
|
||||||
onFocus={props.onFocus}
|
onFocus={props.onFocus as any}
|
||||||
onBlur={props.onBlur}
|
onBlur={props.onBlur as any}
|
||||||
iconAfter={<ChevronDown size={16} color={subtle} />}
|
iconAfter={<ChevronDown size={16} color={subtle} />}
|
||||||
focusStyle={{
|
focusStyle={{
|
||||||
borderColor: hasError ? danger : primary,
|
borderColor: (hasError ? danger : primary) as any,
|
||||||
boxShadow: `0 0 0 3px ${ringColor}`,
|
boxShadow: `0 0 0 3px ${ringColor}`,
|
||||||
}}
|
}}
|
||||||
hoverStyle={{
|
hoverStyle={{
|
||||||
borderColor,
|
borderColor: borderColor as any,
|
||||||
}}
|
}}
|
||||||
style={style}
|
style={style as any}
|
||||||
>
|
>
|
||||||
<Select.Value placeholder={props.placeholder ?? emptyOption?.label ?? ''} color={text} />
|
<Select.Value placeholder={props.placeholder ?? (emptyOption?.label as any) ?? ''} {...({ color: text } as any)} />
|
||||||
</Select.Trigger>
|
</Select.Trigger>
|
||||||
<Select.Content
|
<Select.Content
|
||||||
zIndex={200000}
|
zIndex={200000}
|
||||||
borderRadius={14}
|
{...({ borderRadius: 14 } as any)}
|
||||||
borderWidth={1}
|
borderWidth={1}
|
||||||
borderColor={border}
|
borderColor={border}
|
||||||
backgroundColor={surface}
|
backgroundColor={surface as any}
|
||||||
>
|
>
|
||||||
<Select.Viewport padding="$2">
|
<Select.Viewport {...({ padding: "$2" } as any)}>
|
||||||
<Select.Group>
|
<Select.Group>
|
||||||
{options.map((option, index) => (
|
{options.map((option, index) => (
|
||||||
<Select.Item key={`${option.value}-${index}`} value={option.value} disabled={option.disabled}>
|
<Select.Item index={index} key={`${option.value}-${index}`} value={option.value} disabled={option.disabled}>
|
||||||
<Select.ItemText>{option.label}</Select.ItemText>
|
<Select.ItemText>{option.label}</Select.ItemText>
|
||||||
</Select.Item>
|
</Select.Item>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { MobileSheet } from './Sheet';
|
|||||||
import { CTAButton } from './Primitives';
|
import { CTAButton } from './Primitives';
|
||||||
import { useAdminTheme } from '../theme';
|
import { useAdminTheme } from '../theme';
|
||||||
|
|
||||||
type Translator = (key: string, defaultValue?: string) => string;
|
type Translator = any;
|
||||||
|
|
||||||
type LegalConsentSheetProps = {
|
type LegalConsentSheetProps = {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -51,7 +51,7 @@ export function LegalConsentSheet({
|
|||||||
borderRadius: 4,
|
borderRadius: 4,
|
||||||
appearance: 'auto',
|
appearance: 'auto',
|
||||||
WebkitAppearance: 'auto',
|
WebkitAppearance: 'auto',
|
||||||
} as const;
|
} as any;
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (open) {
|
if (open) {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { Suspense } from 'react';
|
import React, { Suspense } from 'react';
|
||||||
import { useLocation, useNavigate } from 'react-router-dom';
|
import { useLocation, useNavigate } from 'react-router-dom';
|
||||||
import { ChevronDown, ChevronLeft, Bell, QrCode } from 'lucide-react';
|
import { ChevronLeft, Bell, QrCode } from 'lucide-react';
|
||||||
import { YStack, XStack } from '@tamagui/stacks';
|
import { YStack, XStack } from '@tamagui/stacks';
|
||||||
import { SizableText as Text } from '@tamagui/text';
|
import { SizableText as Text } from '@tamagui/text';
|
||||||
import { Pressable } from '@tamagui/react-native-web-lite';
|
import { Pressable } from '@tamagui/react-native-web-lite';
|
||||||
@@ -9,11 +9,10 @@ import { useEventContext } from '../../context/EventContext';
|
|||||||
import { BottomNav, NavKey } from './BottomNav';
|
import { BottomNav, NavKey } from './BottomNav';
|
||||||
import { useMobileNav } from '../hooks/useMobileNav';
|
import { useMobileNav } from '../hooks/useMobileNav';
|
||||||
import { adminPath } from '../../constants';
|
import { adminPath } from '../../constants';
|
||||||
import { MobileSheet } from './Sheet';
|
import { MobileCard, CTAButton } from './Primitives';
|
||||||
import { MobileCard, PillBadge, CTAButton } from './Primitives';
|
|
||||||
import { useNotificationsBadge } from '../hooks/useNotificationsBadge';
|
import { useNotificationsBadge } from '../hooks/useNotificationsBadge';
|
||||||
import { useOnlineStatus } from '../hooks/useOnlineStatus';
|
import { useOnlineStatus } from '../hooks/useOnlineStatus';
|
||||||
import { formatEventDate, resolveEventDisplayName } from '../../lib/events';
|
import { resolveEventDisplayName } from '../../lib/events';
|
||||||
import { TenantEvent, getEvents } from '../../api';
|
import { TenantEvent, getEvents } from '../../api';
|
||||||
import { withAlpha } from './colors';
|
import { withAlpha } from './colors';
|
||||||
import { setTabHistory } from '../lib/tabHistory';
|
import { setTabHistory } from '../lib/tabHistory';
|
||||||
@@ -31,11 +30,11 @@ type MobileShellProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function MobileShell({ title, subtitle, children, activeTab, onBack, headerActions }: MobileShellProps) {
|
export function MobileShell({ title, subtitle, children, activeTab, onBack, headerActions }: MobileShellProps) {
|
||||||
const { events, activeEvent, hasMultipleEvents, hasEvents, selectEvent } = useEventContext();
|
const { events, activeEvent, selectEvent } = useEventContext();
|
||||||
const { go } = useMobileNav(activeEvent?.slug);
|
const { go } = useMobileNav(activeEvent?.slug, activeTab);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const { t, i18n } = useTranslation('mobile');
|
const { t } = useTranslation('mobile');
|
||||||
const { count: notificationCount } = useNotificationsBadge();
|
const { count: notificationCount } = useNotificationsBadge();
|
||||||
const online = useOnlineStatus();
|
const online = useOnlineStatus();
|
||||||
const { background, surface, border, text, muted, warningBg, warningText, primary, danger, shadow } = useAdminTheme();
|
const { background, surface, border, text, muted, warningBg, warningText, primary, danger, shadow } = useAdminTheme();
|
||||||
@@ -45,16 +44,13 @@ export function MobileShell({ title, subtitle, children, activeTab, onBack, head
|
|||||||
const textColor = text;
|
const textColor = text;
|
||||||
const mutedText = muted;
|
const mutedText = muted;
|
||||||
const headerSurface = withAlpha(surfaceColor, 0.94);
|
const headerSurface = withAlpha(surfaceColor, 0.94);
|
||||||
const [pickerOpen, setPickerOpen] = React.useState(false);
|
|
||||||
const [fallbackEvents, setFallbackEvents] = React.useState<TenantEvent[]>([]);
|
const [fallbackEvents, setFallbackEvents] = React.useState<TenantEvent[]>([]);
|
||||||
const [loadingEvents, setLoadingEvents] = React.useState(false);
|
const [loadingEvents, setLoadingEvents] = React.useState(false);
|
||||||
const [attemptedFetch, setAttemptedFetch] = React.useState(false);
|
const [attemptedFetch, setAttemptedFetch] = React.useState(false);
|
||||||
const [queuedPhotoCount, setQueuedPhotoCount] = React.useState(0);
|
const [queuedPhotoCount, setQueuedPhotoCount] = React.useState(0);
|
||||||
|
const [isCompactHeader, setIsCompactHeader] = React.useState(false);
|
||||||
|
|
||||||
const locale = i18n.language?.startsWith('en') ? 'en-GB' : 'de-DE';
|
|
||||||
const effectiveEvents = events.length ? events : fallbackEvents;
|
const effectiveEvents = events.length ? events : fallbackEvents;
|
||||||
const effectiveHasMultiple = hasMultipleEvents || effectiveEvents.length > 1;
|
|
||||||
const effectiveHasEvents = hasEvents || effectiveEvents.length > 0;
|
|
||||||
const effectiveActive = activeEvent ?? (effectiveEvents.length === 1 ? effectiveEvents[0] : null);
|
const effectiveActive = activeEvent ?? (effectiveEvents.length === 1 ? effectiveEvents[0] : null);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
@@ -74,19 +70,17 @@ export function MobileShell({ title, subtitle, children, activeTab, onBack, head
|
|||||||
.finally(() => setLoadingEvents(false));
|
.finally(() => setLoadingEvents(false));
|
||||||
}, [events.length, loadingEvents, attemptedFetch, activeEvent, selectEvent]);
|
}, [events.length, loadingEvents, attemptedFetch, activeEvent, selectEvent]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (!pickerOpen) return;
|
|
||||||
if (effectiveEvents.length) return;
|
|
||||||
setLoadingEvents(true);
|
|
||||||
getEvents({ force: true })
|
|
||||||
.then((list) => setFallbackEvents(list ?? []))
|
|
||||||
.catch(() => setFallbackEvents([]))
|
|
||||||
.finally(() => setLoadingEvents(false));
|
|
||||||
}, [pickerOpen, effectiveEvents.length]);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const path = `${location.pathname}${location.search}${location.hash}`;
|
const path = `${location.pathname}${location.search}${location.hash}`;
|
||||||
|
|
||||||
|
// Blacklist transient paths from being saved in tab history
|
||||||
|
const isBlacklisted =
|
||||||
|
location.pathname.includes('/billing/shop') ||
|
||||||
|
location.pathname.includes('/welcome');
|
||||||
|
|
||||||
|
if (!isBlacklisted) {
|
||||||
setTabHistory(activeTab, path);
|
setTabHistory(activeTab, path);
|
||||||
|
}
|
||||||
}, [activeTab, location.hash, location.pathname, location.search]);
|
}, [activeTab, location.hash, location.pathname, location.search]);
|
||||||
|
|
||||||
const refreshQueuedActions = React.useCallback(() => {
|
const refreshQueuedActions = React.useCallback(() => {
|
||||||
@@ -106,44 +100,26 @@ export function MobileShell({ title, subtitle, children, activeTab, onBack, head
|
|||||||
};
|
};
|
||||||
}, [refreshQueuedActions]);
|
}, [refreshQueuedActions]);
|
||||||
|
|
||||||
const eventTitle = title ?? (effectiveActive ? resolveEventDisplayName(effectiveActive) : t('header.appName', 'Event Admin'));
|
React.useEffect(() => {
|
||||||
const subtitleText =
|
if (typeof window === 'undefined' || !window.matchMedia) {
|
||||||
subtitle ??
|
return;
|
||||||
(effectiveActive?.event_date
|
}
|
||||||
? formatEventDate(effectiveActive.event_date, locale) ?? ''
|
const query = window.matchMedia('(max-width: 520px)');
|
||||||
: effectiveHasEvents
|
const handleChange = (event: MediaQueryListEvent) => {
|
||||||
? t('header.selectEvent', 'Select an event to continue')
|
setIsCompactHeader(event.matches);
|
||||||
: t('header.empty', 'Create your first event to get started'));
|
};
|
||||||
|
setIsCompactHeader(query.matches);
|
||||||
|
query.addEventListener?.('change', handleChange);
|
||||||
|
return () => {
|
||||||
|
query.removeEventListener?.('change', handleChange);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
const showEventSwitcher = effectiveHasMultiple;
|
const pageTitle = title ?? t('header.appName', 'Event Admin');
|
||||||
|
const eventContext = !isCompactHeader && effectiveActive ? resolveEventDisplayName(effectiveActive) : null;
|
||||||
|
const subtitleText = subtitle ?? eventContext ?? '';
|
||||||
const showQr = Boolean(effectiveActive?.slug);
|
const showQr = Boolean(effectiveActive?.slug);
|
||||||
|
const headerBackButton = onBack ? (
|
||||||
return (
|
|
||||||
<YStack backgroundColor={backgroundColor} minHeight="100vh" alignItems="center">
|
|
||||||
<YStack
|
|
||||||
backgroundColor={headerSurface}
|
|
||||||
borderBottomWidth={1}
|
|
||||||
borderColor={borderColor}
|
|
||||||
paddingHorizontal="$4"
|
|
||||||
paddingTop="$4"
|
|
||||||
paddingBottom="$3"
|
|
||||||
shadowColor={shadow}
|
|
||||||
shadowOpacity={0.06}
|
|
||||||
shadowRadius={10}
|
|
||||||
shadowOffset={{ width: 0, height: 4 }}
|
|
||||||
width="100%"
|
|
||||||
maxWidth={800}
|
|
||||||
position="sticky"
|
|
||||||
top={0}
|
|
||||||
zIndex={60}
|
|
||||||
style={{
|
|
||||||
paddingTop: 'calc(env(safe-area-inset-top, 0px) + 16px)',
|
|
||||||
backdropFilter: 'blur(12px)',
|
|
||||||
WebkitBackdropFilter: 'blur(12px)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<XStack alignItems="center" justifyContent="space-between" minHeight={48} space="$3">
|
|
||||||
{onBack ? (
|
|
||||||
<HeaderActionButton onPress={onBack} ariaLabel={t('actions.back', 'Back')}>
|
<HeaderActionButton onPress={onBack} ariaLabel={t('actions.back', 'Back')}>
|
||||||
<XStack alignItems="center" space="$1.5">
|
<XStack alignItems="center" space="$1.5">
|
||||||
<ChevronLeft size={28} color={primary} strokeWidth={2.5} />
|
<ChevronLeft size={28} color={primary} strokeWidth={2.5} />
|
||||||
@@ -151,27 +127,22 @@ export function MobileShell({ title, subtitle, children, activeTab, onBack, head
|
|||||||
</HeaderActionButton>
|
</HeaderActionButton>
|
||||||
) : (
|
) : (
|
||||||
<XStack width={28} />
|
<XStack width={28} />
|
||||||
)}
|
);
|
||||||
|
const headerTitle = (
|
||||||
<XStack alignItems="center" space="$2.5" flex={1} justifyContent="flex-end">
|
<XStack alignItems="center" space="$1" flex={1} minWidth={0} justifyContent="flex-end">
|
||||||
<XStack alignItems="center" space="$1" maxWidth="55%">
|
<YStack alignItems="flex-end" maxWidth="100%">
|
||||||
<Pressable
|
|
||||||
disabled={!showEventSwitcher}
|
|
||||||
onPress={() => setPickerOpen(true)}
|
|
||||||
style={{ alignItems: 'flex-end' }}
|
|
||||||
>
|
|
||||||
<Text fontSize="$lg" fontWeight="800" fontFamily="$display" color={textColor} textAlign="right" numberOfLines={1}>
|
<Text fontSize="$lg" fontWeight="800" fontFamily="$display" color={textColor} textAlign="right" numberOfLines={1}>
|
||||||
{eventTitle}
|
{pageTitle}
|
||||||
</Text>
|
</Text>
|
||||||
{subtitleText ? (
|
{subtitleText ? (
|
||||||
<Text fontSize="$xs" color={mutedText} textAlign="right" numberOfLines={1} fontFamily="$body">
|
<Text fontSize="$xs" color={mutedText} textAlign="right" numberOfLines={1} fontFamily="$body">
|
||||||
{subtitleText}
|
{subtitleText}
|
||||||
</Text>
|
</Text>
|
||||||
) : null}
|
) : null}
|
||||||
</Pressable>
|
</YStack>
|
||||||
{showEventSwitcher ? <ChevronDown size={14} color={textColor} /> : null}
|
|
||||||
</XStack>
|
</XStack>
|
||||||
|
);
|
||||||
|
const headerActionsRow = (
|
||||||
<XStack alignItems="center" space="$2">
|
<XStack alignItems="center" space="$2">
|
||||||
<HeaderActionButton
|
<HeaderActionButton
|
||||||
onPress={() => navigate(adminPath('/mobile/notifications'))}
|
onPress={() => navigate(adminPath('/mobile/notifications'))}
|
||||||
@@ -213,25 +184,66 @@ export function MobileShell({ title, subtitle, children, activeTab, onBack, head
|
|||||||
ariaLabel={t('header.quickQr', 'Quick QR')}
|
ariaLabel={t('header.quickQr', 'Quick QR')}
|
||||||
>
|
>
|
||||||
<XStack
|
<XStack
|
||||||
|
width={34}
|
||||||
height={34}
|
height={34}
|
||||||
paddingHorizontal="$3"
|
|
||||||
borderRadius={12}
|
borderRadius={12}
|
||||||
backgroundColor={primary}
|
backgroundColor={primary}
|
||||||
alignItems="center"
|
alignItems="center"
|
||||||
justifyContent="center"
|
justifyContent="center"
|
||||||
space="$1.5"
|
|
||||||
>
|
>
|
||||||
<QrCode size={16} color="white" />
|
<QrCode size={16} color="white" />
|
||||||
<Text fontSize="$xs" fontWeight="800" color="white">
|
|
||||||
{t('header.quickQr', 'Quick QR')}
|
|
||||||
</Text>
|
|
||||||
</XStack>
|
</XStack>
|
||||||
</HeaderActionButton>
|
</HeaderActionButton>
|
||||||
) : null}
|
) : null}
|
||||||
{headerActions ?? null}
|
{headerActions ?? null}
|
||||||
</XStack>
|
</XStack>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<YStack backgroundColor={backgroundColor} minHeight="100vh" alignItems="center">
|
||||||
|
<YStack
|
||||||
|
backgroundColor={headerSurface}
|
||||||
|
borderBottomWidth={1}
|
||||||
|
borderColor={borderColor}
|
||||||
|
paddingHorizontal="$4"
|
||||||
|
paddingTop="$4"
|
||||||
|
paddingBottom="$3"
|
||||||
|
shadowColor={shadow}
|
||||||
|
shadowOpacity={0.06}
|
||||||
|
shadowRadius={10}
|
||||||
|
shadowOffset={{ width: 0, height: 4 }}
|
||||||
|
width="100%"
|
||||||
|
maxWidth={800}
|
||||||
|
position="sticky"
|
||||||
|
top={0}
|
||||||
|
zIndex={60}
|
||||||
|
style={{
|
||||||
|
paddingTop: 'calc(env(safe-area-inset-top, 0px) + 16px)',
|
||||||
|
backdropFilter: 'blur(12px)',
|
||||||
|
WebkitBackdropFilter: 'blur(12px)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isCompactHeader ? (
|
||||||
|
<YStack space="$2">
|
||||||
|
<XStack alignItems="center" justifyContent="space-between" minHeight={48} space="$3">
|
||||||
|
{headerBackButton}
|
||||||
|
<XStack flex={1} minWidth={0} justifyContent="flex-end">
|
||||||
|
{headerTitle}
|
||||||
</XStack>
|
</XStack>
|
||||||
</XStack>
|
</XStack>
|
||||||
|
<XStack alignItems="center" justifyContent="flex-end">
|
||||||
|
{headerActionsRow}
|
||||||
|
</XStack>
|
||||||
|
</YStack>
|
||||||
|
) : (
|
||||||
|
<XStack alignItems="center" justifyContent="space-between" minHeight={48} space="$3">
|
||||||
|
{headerBackButton}
|
||||||
|
<XStack alignItems="center" space="$2.5" flex={1} justifyContent="flex-end" minWidth={0}>
|
||||||
|
{headerTitle}
|
||||||
|
{headerActionsRow}
|
||||||
|
</XStack>
|
||||||
|
</XStack>
|
||||||
|
)}
|
||||||
</YStack>
|
</YStack>
|
||||||
|
|
||||||
<YStack
|
<YStack
|
||||||
@@ -282,75 +294,6 @@ export function MobileShell({ title, subtitle, children, activeTab, onBack, head
|
|||||||
|
|
||||||
<BottomNav active={activeTab} onNavigate={go} />
|
<BottomNav active={activeTab} onNavigate={go} />
|
||||||
|
|
||||||
<MobileSheet
|
|
||||||
open={pickerOpen}
|
|
||||||
onClose={() => setPickerOpen(false)}
|
|
||||||
title={t('header.eventSwitcher', 'Choose an event')}
|
|
||||||
footer={null}
|
|
||||||
bottomOffsetPx={110}
|
|
||||||
>
|
|
||||||
<YStack space="$2">
|
|
||||||
{effectiveEvents.length === 0 ? (
|
|
||||||
<MobileCard alignItems="flex-start" space="$2">
|
|
||||||
<Text fontSize="$sm" color={textColor} fontWeight="700">
|
|
||||||
{t('header.noEventsTitle', 'Create your first event')}
|
|
||||||
</Text>
|
|
||||||
<Text fontSize="$xs" color={mutedText}>
|
|
||||||
{t('header.noEventsBody', 'Start an event to access tasks, uploads, QR posters and more.')}
|
|
||||||
</Text>
|
|
||||||
<Pressable onPress={() => navigate(adminPath('/mobile/events/new'))}>
|
|
||||||
<XStack alignItems="center" space="$2">
|
|
||||||
<Text fontSize="$sm" color={primary} fontWeight="700">
|
|
||||||
{t('header.createEvent', 'Create event')}
|
|
||||||
</Text>
|
|
||||||
</XStack>
|
|
||||||
</Pressable>
|
|
||||||
</MobileCard>
|
|
||||||
) : (
|
|
||||||
effectiveEvents.map((event) => (
|
|
||||||
<Pressable
|
|
||||||
key={event.slug}
|
|
||||||
onPress={() => {
|
|
||||||
const targetSlug = event.slug ?? null;
|
|
||||||
selectEvent(targetSlug);
|
|
||||||
setPickerOpen(false);
|
|
||||||
if (targetSlug) {
|
|
||||||
navigate(adminPath(`/mobile/events/${targetSlug}`));
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<XStack alignItems="center" justifyContent="space-between" paddingVertical="$2">
|
|
||||||
<YStack space="$0.5">
|
|
||||||
<Text fontSize="$sm" fontWeight="700" color={textColor}>
|
|
||||||
{resolveEventDisplayName(event)}
|
|
||||||
</Text>
|
|
||||||
<Text fontSize="$xs" color={mutedText}>
|
|
||||||
{formatEventDate(event.event_date, locale) ?? t('header.noDate', 'Date tbd')}
|
|
||||||
</Text>
|
|
||||||
</YStack>
|
|
||||||
<PillBadge tone={event.slug === activeEvent?.slug ? 'success' : 'muted'}>
|
|
||||||
{event.slug === activeEvent?.slug
|
|
||||||
? t('header.active', 'Active')
|
|
||||||
: (event.status ?? '—')}
|
|
||||||
</PillBadge>
|
|
||||||
</XStack>
|
|
||||||
</Pressable>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
{activeEvent ? (
|
|
||||||
<Pressable
|
|
||||||
onPress={() => {
|
|
||||||
selectEvent(null);
|
|
||||||
setPickerOpen(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Text fontSize="$xs" color={mutedText} textAlign="center">
|
|
||||||
{t('header.clearSelection', 'Clear selection')}
|
|
||||||
</Text>
|
|
||||||
</Pressable>
|
|
||||||
) : null}
|
|
||||||
</YStack>
|
|
||||||
</MobileSheet>
|
|
||||||
</YStack>
|
</YStack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -85,6 +85,9 @@ export function CTAButton({
|
|||||||
fullWidth = true,
|
fullWidth = true,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
loading = false,
|
loading = false,
|
||||||
|
style,
|
||||||
|
iconLeft,
|
||||||
|
iconRight,
|
||||||
}: {
|
}: {
|
||||||
label: string;
|
label: string;
|
||||||
onPress: () => void;
|
onPress: () => void;
|
||||||
@@ -92,6 +95,9 @@ export function CTAButton({
|
|||||||
fullWidth?: boolean;
|
fullWidth?: boolean;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
|
style?: any;
|
||||||
|
iconLeft?: React.ReactNode;
|
||||||
|
iconRight?: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const { primary, surface, border, text, danger } = useAdminTheme();
|
const { primary, surface, border, text, danger } = useAdminTheme();
|
||||||
const isPrimary = tone === 'primary';
|
const isPrimary = tone === 'primary';
|
||||||
@@ -108,6 +114,7 @@ export function CTAButton({
|
|||||||
width: fullWidth ? '100%' : undefined,
|
width: fullWidth ? '100%' : undefined,
|
||||||
flex: fullWidth ? undefined : 1,
|
flex: fullWidth ? undefined : 1,
|
||||||
opacity: isDisabled ? 0.6 : 1,
|
opacity: isDisabled ? 0.6 : 1,
|
||||||
|
...style,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<XStack
|
<XStack
|
||||||
@@ -118,10 +125,13 @@ export function CTAButton({
|
|||||||
backgroundColor={backgroundColor}
|
backgroundColor={backgroundColor}
|
||||||
borderWidth={isPrimary || isDanger ? 0 : 1}
|
borderWidth={isPrimary || isDanger ? 0 : 1}
|
||||||
borderColor={borderColor}
|
borderColor={borderColor}
|
||||||
|
space="$2"
|
||||||
>
|
>
|
||||||
|
{iconLeft}
|
||||||
<Text fontSize="$sm" fontWeight="800" color={labelColor}>
|
<Text fontSize="$sm" fontWeight="800" color={labelColor}>
|
||||||
{label}
|
{label}
|
||||||
</Text>
|
</Text>
|
||||||
|
{iconRight}
|
||||||
</XStack>
|
</XStack>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ describe('MobileSheet', () => {
|
|||||||
const onClose = vi.fn();
|
const onClose = vi.fn();
|
||||||
|
|
||||||
render(
|
render(
|
||||||
<MobileSheet open title="Test Sheet" onClose={onClose}>
|
<MobileSheet open title="Test Sheet" onClose={onClose} snapPoints={[94]} contentSpacing="$2" padding="$3" paddingBottom="$6">
|
||||||
<div>Body</div>
|
<div>Body</div>
|
||||||
</MobileSheet>,
|
</MobileSheet>,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -12,11 +12,26 @@ type SheetProps = {
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
footer?: React.ReactNode;
|
footer?: React.ReactNode;
|
||||||
|
snapPoints?: number[];
|
||||||
|
contentSpacing?: string;
|
||||||
|
padding?: string;
|
||||||
|
paddingBottom?: string;
|
||||||
/** Optional bottom offset so content sits above the bottom nav/safe-area. */
|
/** Optional bottom offset so content sits above the bottom nav/safe-area. */
|
||||||
bottomOffsetPx?: number;
|
bottomOffsetPx?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function MobileSheet({ open, title, onClose, children, footer, bottomOffsetPx = 88 }: SheetProps) {
|
export function MobileSheet({
|
||||||
|
open,
|
||||||
|
title,
|
||||||
|
onClose,
|
||||||
|
children,
|
||||||
|
footer,
|
||||||
|
snapPoints = [82],
|
||||||
|
contentSpacing = '$3',
|
||||||
|
padding = '$4',
|
||||||
|
paddingBottom = '$7',
|
||||||
|
bottomOffsetPx = 88,
|
||||||
|
}: SheetProps) {
|
||||||
const { t } = useTranslation('mobile');
|
const { t } = useTranslation('mobile');
|
||||||
const { surface, textStrong, muted, overlay, shadow, border } = useAdminTheme();
|
const { surface, textStrong, muted, overlay, shadow, border } = useAdminTheme();
|
||||||
const bottomOffset = `max(env(safe-area-inset-bottom, 0px), ${bottomOffsetPx}px)`;
|
const bottomOffset = `max(env(safe-area-inset-bottom, 0px), ${bottomOffsetPx}px)`;
|
||||||
@@ -28,39 +43,41 @@ export function MobileSheet({ open, title, onClose, children, footer, bottomOffs
|
|||||||
<Sheet
|
<Sheet
|
||||||
modal
|
modal
|
||||||
open={open}
|
open={open}
|
||||||
onOpenChange={(next) => {
|
onOpenChange={(next: boolean) => {
|
||||||
if (!next) {
|
if (!next) {
|
||||||
onClose();
|
onClose();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
snapPoints={[82]}
|
snapPoints={snapPoints}
|
||||||
snapPointsMode="percent"
|
snapPointsMode="percent"
|
||||||
dismissOnOverlayPress
|
dismissOnOverlayPress
|
||||||
dismissOnSnapToBottom
|
dismissOnSnapToBottom
|
||||||
zIndex={100000}
|
zIndex={100000}
|
||||||
>
|
>
|
||||||
<Sheet.Overlay backgroundColor={`${overlay}66`} />
|
<Sheet.Overlay {...({ backgroundColor: `${overlay}66` } as any)} />
|
||||||
<Sheet.Frame
|
<Sheet.Frame
|
||||||
width="100%"
|
{...({
|
||||||
maxWidth={520}
|
width: '100%',
|
||||||
alignSelf="center"
|
maxWidth: 520,
|
||||||
borderTopLeftRadius={24}
|
alignSelf: 'center',
|
||||||
borderTopRightRadius={24}
|
borderTopLeftRadius: 24,
|
||||||
backgroundColor={surface}
|
borderTopRightRadius: 24,
|
||||||
padding="$4"
|
backgroundColor: surface,
|
||||||
paddingBottom="$7"
|
padding,
|
||||||
shadowColor={shadow}
|
paddingBottom,
|
||||||
shadowOpacity={0.12}
|
shadowColor: shadow,
|
||||||
shadowRadius={18}
|
shadowOpacity: 0.12,
|
||||||
shadowOffset={{ width: 0, height: -8 }}
|
shadowRadius: 18,
|
||||||
|
shadowOffset: { width: 0, height: -8 },
|
||||||
|
} as any)}
|
||||||
style={{ marginBottom: bottomOffset }}
|
style={{ marginBottom: bottomOffset }}
|
||||||
>
|
>
|
||||||
<Sheet.Handle height={5} width={48} backgroundColor={border} borderRadius={999} marginBottom="$3" />
|
<Sheet.Handle height={5} width={48} backgroundColor={border} borderRadius={999} marginBottom="$3" />
|
||||||
<Sheet.ScrollView
|
<Sheet.ScrollView
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
contentContainerStyle={{ paddingBottom: 6 }}
|
{...({ contentContainerStyle: { paddingBottom: 6 } } as any)}
|
||||||
>
|
>
|
||||||
<YStack space="$3">
|
<YStack space={contentSpacing}>
|
||||||
<XStack alignItems="center" justifyContent="space-between">
|
<XStack alignItems="center" justifyContent="space-between">
|
||||||
<Text fontSize="$md" fontWeight="800" color={textStrong}>
|
<Text fontSize="$md" fontWeight="800" color={textStrong}>
|
||||||
{title}
|
{title}
|
||||||
|
|||||||
@@ -38,10 +38,10 @@ describe('LegalConsentSheet', () => {
|
|||||||
it('renders the required consent checkboxes when open', () => {
|
it('renders the required consent checkboxes when open', () => {
|
||||||
const { getAllByRole } = render(
|
const { getAllByRole } = render(
|
||||||
<LegalConsentSheet
|
<LegalConsentSheet
|
||||||
open
|
open={true}
|
||||||
onClose={vi.fn()}
|
onClose={vi.fn()}
|
||||||
onConfirm={vi.fn()}
|
onConfirm={vi.fn()}
|
||||||
t={(key, fallback) => fallback ?? key}
|
t={(key: string, fallback?: string) => fallback ?? key}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,143 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { act, render, screen } from '@testing-library/react';
|
||||||
|
import { MemoryRouter } from 'react-router-dom';
|
||||||
|
|
||||||
|
vi.mock('react-i18next', () => ({
|
||||||
|
useTranslation: () => ({ t: (key: string, fallback?: string) => fallback ?? key, i18n: { language: 'en-GB' } }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@tamagui/core', () => ({
|
||||||
|
useTheme: () => ({
|
||||||
|
background: { val: '#FFF8F5' },
|
||||||
|
surface: { val: '#ffffff' },
|
||||||
|
borderColor: { val: '#e5e7eb' },
|
||||||
|
color: { val: '#1f2937' },
|
||||||
|
gray: { val: '#6b7280' },
|
||||||
|
red10: { val: '#b91c1c' },
|
||||||
|
shadowColor: { val: 'rgba(0,0,0,0.12)' },
|
||||||
|
primary: { val: '#FF5A5F' },
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@tamagui/stacks', () => ({
|
||||||
|
YStack: ({ children, ...props }: { children: React.ReactNode }) => <div {...props}>{children}</div>,
|
||||||
|
XStack: ({ children, ...props }: { children: React.ReactNode }) => <div {...props}>{children}</div>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@tamagui/text', () => ({
|
||||||
|
SizableText: ({ children, ...props }: { children: React.ReactNode }) => <span {...props}>{children}</span>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@tamagui/react-native-web-lite', () => ({
|
||||||
|
Pressable: ({ children, onPress, ...props }: { children: React.ReactNode; onPress?: () => void }) => (
|
||||||
|
<button type="button" onClick={onPress} {...props}>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../BottomNav', () => ({
|
||||||
|
BottomNav: () => <div data-testid="bottom-nav" />,
|
||||||
|
NavKey: {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../context/EventContext', () => ({
|
||||||
|
useEventContext: () => ({
|
||||||
|
events: [],
|
||||||
|
activeEvent: { slug: 'event-1', name: 'Test Event', event_date: '2024-01-01', status: 'active', settings: {} },
|
||||||
|
hasMultipleEvents: false,
|
||||||
|
hasEvents: true,
|
||||||
|
selectEvent: vi.fn(),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../hooks/useMobileNav', () => ({
|
||||||
|
useMobileNav: () => ({ go: vi.fn(), slug: 'event-1' }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../hooks/useNotificationsBadge', () => ({
|
||||||
|
useNotificationsBadge: () => ({ count: 0 }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../hooks/useOnlineStatus', () => ({
|
||||||
|
useOnlineStatus: () => true,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../api', () => ({
|
||||||
|
getEvents: vi.fn().mockResolvedValue([]),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../lib/tabHistory', () => ({
|
||||||
|
setTabHistory: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../lib/photoModerationQueue', () => ({
|
||||||
|
loadPhotoQueue: vi.fn(() => []),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../lib/queueStatus', () => ({
|
||||||
|
countQueuedPhotoActions: vi.fn(() => 0),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../theme', () => ({
|
||||||
|
useAdminTheme: () => ({
|
||||||
|
background: '#FFF8F5',
|
||||||
|
surface: '#ffffff',
|
||||||
|
border: '#e5e7eb',
|
||||||
|
text: '#1f2937',
|
||||||
|
muted: '#6b7280',
|
||||||
|
warningBg: '#fff7ed',
|
||||||
|
warningText: '#92400e',
|
||||||
|
primary: '#FF5A5F',
|
||||||
|
danger: '#b91c1c',
|
||||||
|
shadow: 'rgba(0,0,0,0.12)',
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { MobileShell } from '../MobileShell';
|
||||||
|
|
||||||
|
describe('MobileShell', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
window.matchMedia = vi.fn().mockReturnValue({
|
||||||
|
matches: false,
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders quick QR as icon-only button', async () => {
|
||||||
|
await act(async () => {
|
||||||
|
render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<MobileShell activeTab="home">
|
||||||
|
<div>Body</div>
|
||||||
|
</MobileShell>
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByLabelText('Quick QR')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('Quick QR')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides the event context on compact headers', async () => {
|
||||||
|
window.matchMedia = vi.fn().mockReturnValue({
|
||||||
|
matches: true,
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<MobileShell activeTab="home">
|
||||||
|
<div>Body</div>
|
||||||
|
</MobileShell>
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.queryByText('Test Event')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,20 +1,28 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate, useLocation } from 'react-router-dom';
|
||||||
import { useEventContext } from '../../context/EventContext';
|
import { useEventContext } from '../../context/EventContext';
|
||||||
import { NavKey } from '../components/BottomNav';
|
import { NavKey } from '../components/BottomNav';
|
||||||
import { resolveTabTarget } from '../lib/tabHistory';
|
import { resolveTabTarget, resolveDefaultTarget } from '../lib/tabHistory';
|
||||||
|
import { adminPath } from '../../constants';
|
||||||
|
|
||||||
export function useMobileNav(currentSlug?: string | null) {
|
export function useMobileNav(currentSlug?: string | null, activeTab?: NavKey) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
const { activeEvent } = useEventContext();
|
const { activeEvent } = useEventContext();
|
||||||
const slug = currentSlug ?? activeEvent?.slug ?? null;
|
const slug = currentSlug ?? activeEvent?.slug ?? null;
|
||||||
|
|
||||||
const go = React.useCallback(
|
const go = React.useCallback(
|
||||||
(key: NavKey) => {
|
(key: NavKey) => {
|
||||||
|
// Tap-to-reset: If the user taps the tab they are already on, reset to root.
|
||||||
|
if (key === activeTab) {
|
||||||
|
navigate(resolveDefaultTarget(key, slug));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const target = resolveTabTarget(key, slug);
|
const target = resolveTabTarget(key, slug);
|
||||||
navigate(target);
|
navigate(target);
|
||||||
},
|
},
|
||||||
[navigate, slug]
|
[navigate, activeTab, slug]
|
||||||
);
|
);
|
||||||
|
|
||||||
return { go, slug };
|
return { go, slug };
|
||||||
|
|||||||
59
resources/js/admin/mobile/hooks/usePackageCheckout.ts
Normal file
59
resources/js/admin/mobile/hooks/usePackageCheckout.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
|
import { createTenantPaddleCheckout } from '../../api';
|
||||||
|
import { adminPath } from '../../constants';
|
||||||
|
import { getApiErrorMessage } from '../../lib/apiError';
|
||||||
|
import { storePendingCheckout } from '../lib/billingCheckout';
|
||||||
|
|
||||||
|
export function usePackageCheckout(): {
|
||||||
|
busy: boolean;
|
||||||
|
startCheckout: (packageId: number) => Promise<void>;
|
||||||
|
} {
|
||||||
|
const { t } = useTranslation('management');
|
||||||
|
const [busy, setBusy] = React.useState(false);
|
||||||
|
|
||||||
|
const startCheckout = React.useCallback(
|
||||||
|
async (packageId: number) => {
|
||||||
|
if (busy) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
throw new Error('Checkout is only available in the browser.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const billingUrl = new URL(adminPath('/mobile/billing'), window.location.origin);
|
||||||
|
const successUrl = new URL(billingUrl);
|
||||||
|
successUrl.searchParams.set('checkout', 'success');
|
||||||
|
successUrl.searchParams.set('package_id', String(packageId));
|
||||||
|
const cancelUrl = new URL(billingUrl);
|
||||||
|
cancelUrl.searchParams.set('checkout', 'cancel');
|
||||||
|
cancelUrl.searchParams.set('package_id', String(packageId));
|
||||||
|
|
||||||
|
const { checkout_url, checkout_session_id } = await createTenantPaddleCheckout(packageId, {
|
||||||
|
success_url: successUrl.toString(),
|
||||||
|
return_url: cancelUrl.toString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (checkout_session_id) {
|
||||||
|
storePendingCheckout({
|
||||||
|
packageId,
|
||||||
|
checkoutSessionId: checkout_session_id,
|
||||||
|
startedAt: Date.now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
window.location.href = checkout_url;
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(getApiErrorMessage(err, t('shop.errors.checkout', 'Checkout failed')));
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[busy, t],
|
||||||
|
);
|
||||||
|
|
||||||
|
return { busy, startCheckout };
|
||||||
|
}
|
||||||
28
resources/js/admin/mobile/lib/analytics.ts
Normal file
28
resources/js/admin/mobile/lib/analytics.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
export function resolveMaxCount(values: number[]): number {
|
||||||
|
if (!Array.isArray(values) || values.length === 0) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.max(...values, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveTimelineHours(timestamps: string[], fallbackHours = 12): number {
|
||||||
|
if (!Array.isArray(timestamps) || timestamps.length < 2) {
|
||||||
|
return fallbackHours;
|
||||||
|
}
|
||||||
|
|
||||||
|
const times = timestamps
|
||||||
|
.map((value) => new Date(value).getTime())
|
||||||
|
.filter((value) => Number.isFinite(value));
|
||||||
|
|
||||||
|
if (times.length < 2) {
|
||||||
|
return fallbackHours;
|
||||||
|
}
|
||||||
|
|
||||||
|
const min = Math.min(...times);
|
||||||
|
const max = Math.max(...times);
|
||||||
|
const diff = Math.max(0, max - min);
|
||||||
|
const hours = diff / (1000 * 60 * 60);
|
||||||
|
|
||||||
|
return Math.max(1, Math.round(hours));
|
||||||
|
}
|
||||||
82
resources/js/admin/mobile/lib/billingCheckout.ts
Normal file
82
resources/js/admin/mobile/lib/billingCheckout.ts
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
export type PendingCheckout = {
|
||||||
|
packageId: number | null;
|
||||||
|
checkoutSessionId?: string | null;
|
||||||
|
startedAt: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PENDING_CHECKOUT_TTL_MS = 1000 * 60 * 30;
|
||||||
|
export const CHECKOUT_STORAGE_KEY = 'admin.billing.checkout.pending.v1';
|
||||||
|
|
||||||
|
export function isCheckoutExpired(
|
||||||
|
pending: PendingCheckout,
|
||||||
|
now = Date.now(),
|
||||||
|
ttl = PENDING_CHECKOUT_TTL_MS,
|
||||||
|
): boolean {
|
||||||
|
return now - pending.startedAt > ttl;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadPendingCheckout(
|
||||||
|
now = Date.now(),
|
||||||
|
ttl = PENDING_CHECKOUT_TTL_MS,
|
||||||
|
): PendingCheckout | null {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const raw = window.sessionStorage.getItem(CHECKOUT_STORAGE_KEY);
|
||||||
|
if (! raw) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const parsed = JSON.parse(raw) as PendingCheckout;
|
||||||
|
if (typeof parsed?.startedAt !== 'number') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const packageId =
|
||||||
|
typeof parsed.packageId === 'number' && Number.isFinite(parsed.packageId)
|
||||||
|
? parsed.packageId
|
||||||
|
: null;
|
||||||
|
const checkoutSessionId = typeof parsed.checkoutSessionId === 'string' ? parsed.checkoutSessionId : null;
|
||||||
|
if (now - parsed.startedAt > ttl) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
packageId,
|
||||||
|
checkoutSessionId,
|
||||||
|
startedAt: parsed.startedAt,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function storePendingCheckout(next: PendingCheckout | null): void {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (! next) {
|
||||||
|
window.sessionStorage.removeItem(CHECKOUT_STORAGE_KEY);
|
||||||
|
} else {
|
||||||
|
window.sessionStorage.setItem(CHECKOUT_STORAGE_KEY, JSON.stringify(next));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore storage errors.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldClearPendingCheckout(
|
||||||
|
pending: PendingCheckout,
|
||||||
|
activePackageId: number | null,
|
||||||
|
now = Date.now(),
|
||||||
|
ttl = PENDING_CHECKOUT_TTL_MS,
|
||||||
|
): boolean {
|
||||||
|
if (isCheckoutExpired(pending, now, ttl)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pending.packageId && activePackageId && pending.packageId === activePackageId) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
@@ -15,6 +15,8 @@ const baseEvent = (overrides: Partial<TenantEvent>): TenantEvent => ({
|
|||||||
photo_count: overrides.photo_count ?? 0,
|
photo_count: overrides.photo_count ?? 0,
|
||||||
likes_sum: overrides.likes_sum ?? 0,
|
likes_sum: overrides.likes_sum ?? 0,
|
||||||
engagement_mode: overrides.engagement_mode ?? 'tasks',
|
engagement_mode: overrides.engagement_mode ?? 'tasks',
|
||||||
|
event_type_id: overrides.event_type_id ?? null,
|
||||||
|
event_type: overrides.event_type ?? null,
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('resolveEventStatusKey', () => {
|
describe('resolveEventStatusKey', () => {
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ const baseEvent = (overrides: Partial<TenantEvent>): TenantEvent => ({
|
|||||||
photo_count: overrides.photo_count ?? 0,
|
photo_count: overrides.photo_count ?? 0,
|
||||||
likes_sum: overrides.likes_sum ?? 0,
|
likes_sum: overrides.likes_sum ?? 0,
|
||||||
engagement_mode: overrides.engagement_mode ?? 'tasks',
|
engagement_mode: overrides.engagement_mode ?? 'tasks',
|
||||||
|
event_type_id: overrides.event_type_id ?? null,
|
||||||
|
event_type: overrides.event_type ?? null,
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('buildEventListStats', () => {
|
describe('buildEventListStats', () => {
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ export function resolveOnboardingRedirect({
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!hasEvents && (!hasActivePackage || (remainingEvents !== null && remainingEvents <= 0))) {
|
if (!hasEvents && (!hasActivePackage || (remainingEvents !== undefined && remainingEvents !== null && remainingEvents <= 0))) {
|
||||||
return ADMIN_BILLING_PATH;
|
return ADMIN_BILLING_PATH;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
146
resources/js/admin/mobile/lib/packageShop.ts
Normal file
146
resources/js/admin/mobile/lib/packageShop.ts
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
import type { Package } from '../../api';
|
||||||
|
|
||||||
|
type PackageChange = {
|
||||||
|
isUpgrade: boolean;
|
||||||
|
isDowngrade: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PackageComparisonRow =
|
||||||
|
| {
|
||||||
|
id: string;
|
||||||
|
type: 'limit';
|
||||||
|
limitKey: 'max_photos' | 'max_guests' | 'gallery_days';
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
id: string;
|
||||||
|
type: 'feature';
|
||||||
|
featureKey: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizePackageFeatures(pkg: Package | null): string[] {
|
||||||
|
if (!pkg?.features) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(pkg.features)) {
|
||||||
|
return pkg.features.filter((feature): feature is string => typeof feature === 'string' && feature.trim().length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof pkg.features === 'object') {
|
||||||
|
return Object.entries(pkg.features)
|
||||||
|
.filter(([, enabled]) => enabled)
|
||||||
|
.map(([key]) => key);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getEnabledPackageFeatures(pkg: Package): string[] {
|
||||||
|
return normalizePackageFeatures(pkg);
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectFeatures(pkg: Package | null): Set<string> {
|
||||||
|
return new Set(normalizePackageFeatures(pkg));
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareLimit(candidate: number | null, active: number | null): number {
|
||||||
|
if (active === null) {
|
||||||
|
return candidate === null ? 0 : -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (candidate === null) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (candidate > active) return 1;
|
||||||
|
if (candidate < active) return -1;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function classifyPackageChange(pkg: Package, active: Package | null): PackageChange {
|
||||||
|
if (!active) {
|
||||||
|
return { isUpgrade: false, isDowngrade: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeFeatures = collectFeatures(active);
|
||||||
|
const candidateFeatures = collectFeatures(pkg);
|
||||||
|
|
||||||
|
const hasFeatureUpgrade = Array.from(candidateFeatures).some((feature) => !activeFeatures.has(feature));
|
||||||
|
const hasFeatureDowngrade = Array.from(activeFeatures).some((feature) => !candidateFeatures.has(feature));
|
||||||
|
|
||||||
|
const limitKeys: Array<keyof Package> = ['max_photos', 'max_guests', 'gallery_days'];
|
||||||
|
let hasLimitUpgrade = false;
|
||||||
|
let hasLimitDowngrade = false;
|
||||||
|
|
||||||
|
limitKeys.forEach((key) => {
|
||||||
|
const candidateLimit = pkg[key] ?? null;
|
||||||
|
const activeLimit = active[key] ?? null;
|
||||||
|
const delta = compareLimit(candidateLimit, activeLimit);
|
||||||
|
if (delta > 0) {
|
||||||
|
hasLimitUpgrade = true;
|
||||||
|
} else if (delta < 0) {
|
||||||
|
hasLimitDowngrade = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const hasUpgrade = hasFeatureUpgrade || hasLimitUpgrade;
|
||||||
|
const hasDowngrade = hasFeatureDowngrade || hasLimitDowngrade;
|
||||||
|
|
||||||
|
if (hasUpgrade && !hasDowngrade) {
|
||||||
|
return { isUpgrade: true, isDowngrade: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasDowngrade) {
|
||||||
|
return { isUpgrade: false, isDowngrade: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { isUpgrade: false, isDowngrade: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function selectRecommendedPackageId(
|
||||||
|
packages: Package[],
|
||||||
|
feature: string | null,
|
||||||
|
activePackage: Package | null
|
||||||
|
): number | null {
|
||||||
|
if (!feature) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidates = packages.filter((pkg) => normalizePackageFeatures(pkg).includes(feature));
|
||||||
|
if (candidates.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const upgrades = candidates.filter((pkg) => classifyPackageChange(pkg, activePackage).isUpgrade);
|
||||||
|
const pool = upgrades.length ? upgrades : candidates;
|
||||||
|
const sorted = [...pool].sort((a, b) => a.price - b.price);
|
||||||
|
|
||||||
|
return sorted[0]?.id ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildPackageComparisonRows(packages: Package[]): PackageComparisonRow[] {
|
||||||
|
const limitRows: PackageComparisonRow[] = [
|
||||||
|
{ id: 'limit.max_photos', type: 'limit', limitKey: 'max_photos' },
|
||||||
|
{ id: 'limit.max_guests', type: 'limit', limitKey: 'max_guests' },
|
||||||
|
{ id: 'limit.gallery_days', type: 'limit', limitKey: 'gallery_days' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const featureKeys = new Set<string>();
|
||||||
|
packages.forEach((pkg) => {
|
||||||
|
normalizePackageFeatures(pkg).forEach((key) => {
|
||||||
|
if (key !== 'photos') {
|
||||||
|
featureKeys.add(key);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const featureRows = Array.from(featureKeys)
|
||||||
|
.sort((a, b) => a.localeCompare(b))
|
||||||
|
.map((featureKey) => ({
|
||||||
|
id: `feature.${featureKey}`,
|
||||||
|
type: 'feature' as const,
|
||||||
|
featureKey,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return [...limitRows, ...featureRows];
|
||||||
|
}
|
||||||
@@ -15,7 +15,8 @@ const t = (key: string, options?: Record<string, unknown> | string) => {
|
|||||||
return template
|
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', () => {
|
||||||
@@ -53,6 +54,12 @@ 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);
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { TenantPackageSummary } from '../../api';
|
import type { TenantPackageSummary } from '../../api';
|
||||||
|
|
||||||
type Translate = (key: string, options?: Record<string, unknown> | string) => string;
|
type Translate = any;
|
||||||
|
|
||||||
type LimitUsageOverrides = {
|
type LimitUsageOverrides = {
|
||||||
remainingEvents?: number | null;
|
remainingEvents?: number | null;
|
||||||
@@ -138,6 +138,12 @@ 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,
|
||||||
|
|||||||
@@ -1,24 +1,35 @@
|
|||||||
import { adminPath } from '../../constants';
|
import { adminPath } from '../../constants';
|
||||||
import type { NavKey } from '../components/BottomNav';
|
import type { NavKey } from '../components/BottomNav';
|
||||||
|
|
||||||
const STORAGE_KEY = 'admin-mobile-tab-history-v1';
|
const STORAGE_KEY = 'admin-mobile-tab-history-v2';
|
||||||
|
const EXPIRY_MS = 1000 * 60 * 60 * 2; // 2 hours
|
||||||
|
|
||||||
type TabHistory = Partial<Record<NavKey, string>>;
|
type TabHistory = {
|
||||||
|
paths: Partial<Record<NavKey, string>>;
|
||||||
|
updatedAt: number;
|
||||||
|
};
|
||||||
|
|
||||||
function readHistory(): TabHistory {
|
function readHistory(): TabHistory {
|
||||||
if (typeof window === 'undefined') {
|
if (typeof window === 'undefined') {
|
||||||
return {};
|
return { paths: {}, updatedAt: 0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const raw = window.localStorage.getItem(STORAGE_KEY);
|
const raw = window.localStorage.getItem(STORAGE_KEY);
|
||||||
if (!raw) {
|
if (!raw) {
|
||||||
return {};
|
return { paths: {}, updatedAt: 0 };
|
||||||
}
|
}
|
||||||
const parsed = JSON.parse(raw) as TabHistory;
|
const parsed = JSON.parse(raw) as TabHistory;
|
||||||
return parsed ?? {};
|
|
||||||
|
// Check for expiry
|
||||||
|
if (Date.now() - parsed.updatedAt > EXPIRY_MS) {
|
||||||
|
window.localStorage.removeItem(STORAGE_KEY);
|
||||||
|
return { paths: {}, updatedAt: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed ?? { paths: {}, updatedAt: 0 };
|
||||||
} catch {
|
} catch {
|
||||||
return {};
|
return { paths: {}, updatedAt: 0 };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,15 +47,16 @@ function writeHistory(history: TabHistory): void {
|
|||||||
|
|
||||||
export function setTabHistory(key: NavKey, path: string): void {
|
export function setTabHistory(key: NavKey, path: string): void {
|
||||||
const history = readHistory();
|
const history = readHistory();
|
||||||
history[key] = path;
|
history.paths[key] = path;
|
||||||
|
history.updatedAt = Date.now();
|
||||||
writeHistory(history);
|
writeHistory(history);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getTabHistory(): TabHistory {
|
export function getTabHistory(): Partial<Record<NavKey, string>> {
|
||||||
return readHistory();
|
return readHistory().paths;
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveDefaultTarget(key: NavKey, slug?: string | null): string {
|
export function resolveDefaultTarget(key: NavKey, slug?: string | null): string {
|
||||||
if (key === 'tasks') {
|
if (key === 'tasks') {
|
||||||
return slug ? adminPath(`/mobile/events/${slug}/tasks`) : adminPath('/mobile/tasks');
|
return slug ? adminPath(`/mobile/events/${slug}/tasks`) : adminPath('/mobile/tasks');
|
||||||
}
|
}
|
||||||
@@ -81,7 +93,7 @@ function resolveEventScopedTarget(path: string, slug: string | null | undefined,
|
|||||||
|
|
||||||
export function resolveTabTarget(key: NavKey, slug?: string | null): string {
|
export function resolveTabTarget(key: NavKey, slug?: string | null): string {
|
||||||
const history = readHistory();
|
const history = readHistory();
|
||||||
const stored = history[key];
|
const stored = history.paths[key];
|
||||||
const fallback = resolveDefaultTarget(key, slug);
|
const fallback = resolveDefaultTarget(key, slug);
|
||||||
|
|
||||||
if (!stored) {
|
if (!stored) {
|
||||||
|
|||||||
@@ -3,16 +3,15 @@ export function prefetchMobileRoutes() {
|
|||||||
|
|
||||||
const schedule = (callback: () => void) => {
|
const schedule = (callback: () => void) => {
|
||||||
if ('requestIdleCallback' in window) {
|
if ('requestIdleCallback' in window) {
|
||||||
(window as Window & { requestIdleCallback: (cb: () => void) => number }).requestIdleCallback(callback);
|
(window as any).requestIdleCallback(callback);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
window.setTimeout(callback, 1200);
|
(window as any).setTimeout(callback, 1200);
|
||||||
};
|
};
|
||||||
|
|
||||||
schedule(() => {
|
schedule(() => {
|
||||||
void import('./DashboardPage');
|
void import('./DashboardPage');
|
||||||
void import('./EventsPage');
|
void import('./EventsPage');
|
||||||
void import('./EventDetailPage');
|
|
||||||
void import('./EventPhotosPage');
|
void import('./EventPhotosPage');
|
||||||
void import('./EventTasksPage');
|
void import('./EventTasksPage');
|
||||||
void import('./NotificationsPage');
|
void import('./NotificationsPage');
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ export const ADMIN_ACTION_COLORS = {
|
|||||||
recap: ADMIN_COLORS.warning,
|
recap: ADMIN_COLORS.warning,
|
||||||
packages: ADMIN_COLORS.primary,
|
packages: ADMIN_COLORS.primary,
|
||||||
analytics: '#8b5cf6',
|
analytics: '#8b5cf6',
|
||||||
|
settings: ADMIN_COLORS.success,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ADMIN_GRADIENTS = {
|
export const ADMIN_GRADIENTS = {
|
||||||
|
|||||||
@@ -136,9 +136,9 @@ function PackageCard({
|
|||||||
const { t } = useTranslation('onboarding');
|
const { t } = useTranslation('onboarding');
|
||||||
const { primary, border, accentSoft, muted } = useAdminTheme();
|
const { primary, border, accentSoft, muted } = useAdminTheme();
|
||||||
const badges = [
|
const badges = [
|
||||||
t('packages.card.badges.photos', { count: pkg.max_photos ?? t('summary.details.infinity', '∞') }),
|
t('packages.card.badges.photos', { count: pkg.max_photos ?? 0, defaultValue: 'Unlimited photos' } as any),
|
||||||
t('packages.card.badges.guests', { count: pkg.max_guests ?? t('summary.details.infinity', '∞') }),
|
t('packages.card.badges.guests', { count: pkg.max_guests ?? 0, defaultValue: 'Unlimited guests' } as any),
|
||||||
t('packages.card.badges.days', { count: pkg.gallery_days ?? t('summary.details.infinity', '∞') }),
|
t('packages.card.badges.days', { count: pkg.gallery_days ?? 0, defaultValue: 'Unlimited days' } as any),
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -164,8 +164,8 @@ function PackageCard({
|
|||||||
</XStack>
|
</XStack>
|
||||||
<XStack flexWrap="wrap" space="$2">
|
<XStack flexWrap="wrap" space="$2">
|
||||||
{badges.map((badge) => (
|
{badges.map((badge) => (
|
||||||
<PillBadge key={badge} tone="muted">
|
<PillBadge key={badge as any} tone="muted">
|
||||||
{badge}
|
{badge as any}
|
||||||
</PillBadge>
|
</PillBadge>
|
||||||
))}
|
))}
|
||||||
</XStack>
|
</XStack>
|
||||||
|
|||||||
@@ -127,15 +127,17 @@ export default function WelcomeSummaryPage() {
|
|||||||
<SummaryRow
|
<SummaryRow
|
||||||
label={t('summary.details.section.photosTitle', 'Photos & gallery')}
|
label={t('summary.details.section.photosTitle', 'Photos & gallery')}
|
||||||
value={t('summary.details.section.photosValue', {
|
value={t('summary.details.section.photosValue', {
|
||||||
count: resolvedPackage.max_photos ?? t('summary.details.infinity', '∞'),
|
count: resolvedPackage.max_photos ?? 0,
|
||||||
days: resolvedPackage.gallery_days ?? t('summary.details.infinity', '∞'),
|
days: resolvedPackage.gallery_days ?? 0,
|
||||||
})}
|
defaultValue: 'Unlimited photos for {{days}} days',
|
||||||
|
} as any) as string}
|
||||||
/>
|
/>
|
||||||
<SummaryRow
|
<SummaryRow
|
||||||
label={t('summary.details.section.guestsTitle', 'Guests & team')}
|
label={t('summary.details.section.guestsTitle', 'Guests & team')}
|
||||||
value={t('summary.details.section.guestsValue', {
|
value={t('summary.details.section.guestsValue', {
|
||||||
count: resolvedPackage.max_guests ?? t('summary.details.infinity', '∞'),
|
count: resolvedPackage.max_guests ?? 0,
|
||||||
})}
|
defaultValue: 'Unlimited guests',
|
||||||
|
} as any) as string}
|
||||||
/>
|
/>
|
||||||
{resolvedPackage.remaining_events !== undefined && resolvedPackage.remaining_events !== null ? (
|
{resolvedPackage.remaining_events !== undefined && resolvedPackage.remaining_events !== null ? (
|
||||||
<SummaryRow
|
<SummaryRow
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ const AuthCallbackPage = React.lazy(() => import('./mobile/AuthCallbackPage'));
|
|||||||
const LoginStartPage = React.lazy(() => import('./mobile/LoginStartPage'));
|
const LoginStartPage = React.lazy(() => import('./mobile/LoginStartPage'));
|
||||||
const LogoutPage = React.lazy(() => import('./mobile/LogoutPage'));
|
const LogoutPage = React.lazy(() => import('./mobile/LogoutPage'));
|
||||||
const MobileEventsPage = React.lazy(() => import('./mobile/EventsPage'));
|
const MobileEventsPage = React.lazy(() => import('./mobile/EventsPage'));
|
||||||
const MobileEventDetailPage = React.lazy(() => import('./mobile/EventDetailPage'));
|
|
||||||
const MobileEventPhotoboothPage = React.lazy(() => import('./mobile/EventPhotoboothPage'));
|
const MobileEventPhotoboothPage = React.lazy(() => import('./mobile/EventPhotoboothPage'));
|
||||||
const MobileBrandingPage = React.lazy(() => import('./mobile/BrandingPage'));
|
const MobileBrandingPage = React.lazy(() => import('./mobile/BrandingPage'));
|
||||||
const MobileEventFormPage = React.lazy(() => import('./mobile/EventFormPage'));
|
const MobileEventFormPage = React.lazy(() => import('./mobile/EventFormPage'));
|
||||||
@@ -195,7 +194,7 @@ export const router = createBrowserRouter([
|
|||||||
{ path: 'events/:slug/guest-notifications', element: <RedirectToMobileEvent buildPath={(slug) => `${ADMIN_EVENTS_PATH}/${slug}/guest-notifications`} /> },
|
{ path: 'events/:slug/guest-notifications', element: <RedirectToMobileEvent buildPath={(slug) => `${ADMIN_EVENTS_PATH}/${slug}/guest-notifications`} /> },
|
||||||
{ path: 'events/:slug/toolkit', element: <RedirectToMobileEvent buildPath={(slug) => `${ADMIN_EVENTS_PATH}/${slug}`} /> },
|
{ path: 'events/:slug/toolkit', element: <RedirectToMobileEvent buildPath={(slug) => `${ADMIN_EVENTS_PATH}/${slug}`} /> },
|
||||||
{ path: 'mobile/events', element: <MobileEventsPage /> },
|
{ path: 'mobile/events', element: <MobileEventsPage /> },
|
||||||
{ path: 'mobile/events/:slug', element: <MobileEventDetailPage /> },
|
{ path: 'mobile/events/:slug', element: <MobileDashboardPage /> },
|
||||||
{ path: 'mobile/events/:slug/branding', element: <RequireAdminAccess><MobileBrandingPage /></RequireAdminAccess> },
|
{ path: 'mobile/events/:slug/branding', element: <RequireAdminAccess><MobileBrandingPage /></RequireAdminAccess> },
|
||||||
{ path: 'mobile/events/new', element: <RequireAdminAccess><MobileEventFormPage /></RequireAdminAccess> },
|
{ path: 'mobile/events/new', element: <RequireAdminAccess><MobileEventFormPage /></RequireAdminAccess> },
|
||||||
{ path: 'mobile/events/:slug/edit', element: <RequireAdminAccess><MobileEventFormPage /></RequireAdminAccess> },
|
{ path: 'mobile/events/:slug/edit', element: <RequireAdminAccess><MobileEventFormPage /></RequireAdminAccess> },
|
||||||
|
|||||||
@@ -22,6 +22,12 @@ const InertiaFallback: React.FC = () => (
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const LoadingFallback: React.FC = () => (
|
||||||
|
<div className="flex min-h-screen items-center justify-center text-sm text-muted-foreground">
|
||||||
|
Lade...
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
initSentry('inertia');
|
initSentry('inertia');
|
||||||
|
|
||||||
const LocaleSync: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
const LocaleSync: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||||
@@ -75,11 +81,13 @@ createInertiaApp({
|
|||||||
<AppearanceProvider>
|
<AppearanceProvider>
|
||||||
<ConsentProvider>
|
<ConsentProvider>
|
||||||
<I18nextProvider i18n={i18n}>
|
<I18nextProvider i18n={i18n}>
|
||||||
|
<React.Suspense fallback={<LoadingFallback />}>
|
||||||
<LocaleSync>
|
<LocaleSync>
|
||||||
<App {...props} />
|
<App {...props} />
|
||||||
</LocaleSync>
|
</LocaleSync>
|
||||||
<CookieBanner />
|
<CookieBanner />
|
||||||
<Toaster position="top-right" toastOptions={{ duration: 4000 }} />
|
<Toaster position="top-right" toastOptions={{ duration: 4000 }} />
|
||||||
|
</React.Suspense>
|
||||||
</I18nextProvider>
|
</I18nextProvider>
|
||||||
</ConsentProvider>
|
</ConsentProvider>
|
||||||
</AppearanceProvider>
|
</AppearanceProvider>
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ 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';
|
||||||
@@ -151,7 +150,6 @@ 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);
|
||||||
@@ -258,7 +256,6 @@ 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}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -285,18 +282,14 @@ 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, taskProgress, t }: NotificationButtonProps) {
|
function NotificationButton({ center, eventToken, open, onToggle, panelRef, buttonRef, t }: NotificationButtonProps) {
|
||||||
const badgeCount = center.unreadCount + center.pendingCount + center.queueCount;
|
const badgeCount = center.unreadCount;
|
||||||
const progressRatio = taskProgress
|
const [activeTab, setActiveTab] = React.useState<'unread' | 'all' | 'uploads'>(center.unreadCount > 0 ? 'unread' : 'all');
|
||||||
? 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);
|
||||||
|
|
||||||
@@ -321,7 +314,7 @@ function NotificationButton({ center, eventToken, open, onToggle, panelRef, butt
|
|||||||
case 'unread':
|
case 'unread':
|
||||||
base = unreadNotifications;
|
base = unreadNotifications;
|
||||||
break;
|
break;
|
||||||
case 'status':
|
case 'uploads':
|
||||||
base = uploadNotifications;
|
base = uploadNotifications;
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
@@ -331,7 +324,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 (scopeFilter === 'all') {
|
if (activeTab === 'uploads' || scopeFilter === 'all') {
|
||||||
return filteredNotifications;
|
return filteredNotifications;
|
||||||
}
|
}
|
||||||
return filteredNotifications.filter((item) => {
|
return filteredNotifications.filter((item) => {
|
||||||
@@ -365,10 +358,10 @@ function NotificationButton({ center, eventToken, open, onToggle, panelRef, butt
|
|||||||
>
|
>
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-semibold text-slate-900">{t('header.notifications.title', 'Benachrichtigungen')}</p>
|
<p className="text-sm font-semibold text-slate-900">{t('header.notifications.title', 'Updates')}</p>
|
||||||
<p className="text-xs text-slate-500">
|
<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>
|
||||||
@@ -384,13 +377,14 @@ function NotificationButton({ center, eventToken, open, onToggle, panelRef, butt
|
|||||||
</div>
|
</div>
|
||||||
<NotificationTabs
|
<NotificationTabs
|
||||||
tabs={[
|
tabs={[
|
||||||
{ key: 'unread', label: t('header.notifications.tabUnread', 'Neu'), badge: unreadNotifications.length },
|
{ key: 'unread', label: t('header.notifications.tabUnread', 'Nachrichten'), badge: unreadNotifications.length },
|
||||||
{ key: 'status', label: t('header.notifications.tabStatus', 'Uploads/Status'), badge: uploadNotifications.length },
|
{ key: 'uploads', label: t('header.notifications.tabUploads', 'Uploads'), badge: uploadNotifications.length },
|
||||||
{ key: 'all', label: t('header.notifications.tabAll', 'Alle'), badge: center.notifications.length },
|
{ key: 'all', label: t('header.notifications.tabAll', 'Alle Updates'), 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">
|
||||||
{(
|
{(
|
||||||
@@ -418,33 +412,8 @@ 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}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
)}
|
)}
|
||||||
</div>
|
{activeTab === 'uploads' && (center.pendingCount > 0 || center.queueCount > 0) && (
|
||||||
{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">
|
||||||
@@ -478,30 +447,32 @@ function NotificationButton({ center, eventToken, open, onToggle, panelRef, butt
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{taskProgress && (
|
<div className="mt-3 max-h-80 space-y-2 overflow-y-auto pr-1">
|
||||||
<div className="mt-3 rounded-2xl border border-slate-200 bg-slate-50/90 p-3">
|
{center.loading ? (
|
||||||
<div className="flex items-center justify-between">
|
<NotificationSkeleton />
|
||||||
<div>
|
) : scopedNotifications.length === 0 ? (
|
||||||
<p className="text-xs uppercase tracking-[0.3em] text-slate-400">{t('header.notifications.badgeLabel', 'Badge-Fortschritt')}</p>
|
<NotificationEmptyState
|
||||||
<p className="text-lg font-semibold text-slate-900">
|
t={t}
|
||||||
{taskProgress.completedCount}/{TASK_BADGE_TARGET}
|
message={
|
||||||
</p>
|
activeTab === 'unread'
|
||||||
</div>
|
? t('header.notifications.emptyUnread', 'Du bist auf dem neuesten Stand!')
|
||||||
<Link
|
: activeTab === 'uploads'
|
||||||
to={`/e/${encodeURIComponent(eventToken)}/tasks`}
|
? t('header.notifications.emptyStatus', 'Keine Upload-Hinweise oder Wartungen aktiv.')
|
||||||
className="rounded-full border border-slate-200 px-3 py-1 text-xs font-semibold text-pink-600 transition hover:border-pink-300"
|
: undefined
|
||||||
>
|
}
|
||||||
{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>
|
scopedNotifications.map((item) => (
|
||||||
|
<NotificationListItem
|
||||||
|
key={item.id}
|
||||||
|
item={item}
|
||||||
|
onMarkRead={() => center.markAsRead(item.id)}
|
||||||
|
onDismiss={() => center.dismiss(item.id)}
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
))
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
<NotificationStatusBar
|
<NotificationStatusBar
|
||||||
lastFetchedAt={center.lastFetchedAt}
|
lastFetchedAt={center.lastFetchedAt}
|
||||||
isOffline={center.isOffline}
|
isOffline={center.isOffline}
|
||||||
@@ -509,7 +480,7 @@ function NotificationButton({ center, eventToken, open, onToggle, panelRef, butt
|
|||||||
t={t}
|
t={t}
|
||||||
/>
|
/>
|
||||||
</div>,
|
</div>,
|
||||||
typeof document !== 'undefined' ? document.body : undefined
|
(typeof document !== 'undefined' ? document.body : null) as any
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ export default function RouteTransition({ children }: { children?: React.ReactNo
|
|||||||
initial="enter"
|
initial="enter"
|
||||||
animate="center"
|
animate="center"
|
||||||
exit="exit"
|
exit="exit"
|
||||||
transition={transition}
|
transition={transition as any}
|
||||||
style={{ willChange: 'transform, opacity' }}
|
style={{ willChange: 'transform, opacity' }}
|
||||||
>
|
>
|
||||||
{content}
|
{content}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { describe, expect, it, vi } from 'vitest';
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
import { fireEvent, render, screen } from '@testing-library/react';
|
import { fireEvent, render, screen } from '@testing-library/react';
|
||||||
|
import { MemoryRouter } from 'react-router-dom';
|
||||||
import Header from '../Header';
|
import Header from '../Header';
|
||||||
|
|
||||||
vi.mock('../settings-sheet', () => ({
|
vi.mock('../settings-sheet', () => ({
|
||||||
@@ -37,7 +38,6 @@ 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(),
|
||||||
@@ -87,15 +87,19 @@ vi.mock('../../i18n/useTranslation', () => ({
|
|||||||
|
|
||||||
describe('Header notifications toggle', () => {
|
describe('Header notifications toggle', () => {
|
||||||
it('closes the panel when clicking the bell again', () => {
|
it('closes the panel when clicking the bell again', () => {
|
||||||
render(<Header eventToken="demo" title="Demo" />);
|
render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<Header eventToken="demo" title="Demo" />
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
|
||||||
const bellButton = screen.getByLabelText('Benachrichtigungen anzeigen');
|
const bellButton = screen.getByLabelText('Benachrichtigungen anzeigen');
|
||||||
fireEvent.click(bellButton);
|
fireEvent.click(bellButton);
|
||||||
|
|
||||||
expect(screen.getByText('Benachrichtigungen')).toBeInTheDocument();
|
expect(screen.getByText('Updates')).toBeInTheDocument();
|
||||||
|
|
||||||
fireEvent.click(bellButton);
|
fireEvent.click(bellButton);
|
||||||
|
|
||||||
expect(screen.queryByText('Benachrichtigungen')).not.toBeInTheDocument();
|
expect(screen.queryByText('Updates')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ 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>;
|
||||||
@@ -264,11 +263,9 @@ 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(totalCount);
|
void updateAppBadge(unreadCount);
|
||||||
}, [totalCount]);
|
}, [unreadCount]);
|
||||||
|
|
||||||
const value: NotificationCenterValue = {
|
const value: NotificationCenterValue = {
|
||||||
notifications,
|
notifications,
|
||||||
@@ -276,7 +273,6 @@ export function NotificationCenterProvider({ eventToken, children }: { eventToke
|
|||||||
queueItems: items,
|
queueItems: items,
|
||||||
queueCount,
|
queueCount,
|
||||||
pendingCount,
|
pendingCount,
|
||||||
totalCount,
|
|
||||||
loading,
|
loading,
|
||||||
pendingLoading,
|
pendingLoading,
|
||||||
refresh,
|
refresh,
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { registerRoute } from 'workbox-routing';
|
|||||||
import { CacheFirst, NetworkFirst, StaleWhileRevalidate } from 'workbox-strategies';
|
import { CacheFirst, NetworkFirst, StaleWhileRevalidate } from 'workbox-strategies';
|
||||||
|
|
||||||
declare const self: ServiceWorkerGlobalScope & {
|
declare const self: ServiceWorkerGlobalScope & {
|
||||||
__WB_MANIFEST: Array<import('workbox-precaching').ManifestEntry>;
|
__WB_MANIFEST: Array<any>;
|
||||||
};
|
};
|
||||||
|
|
||||||
clientsClaim();
|
clientsClaim();
|
||||||
@@ -97,7 +97,7 @@ self.addEventListener('message', (event) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
self.addEventListener('sync', (event) => {
|
self.addEventListener('sync', (event: any) => {
|
||||||
if (event.tag === 'upload-queue') {
|
if (event.tag === 'upload-queue') {
|
||||||
event.waitUntil(
|
event.waitUntil(
|
||||||
(async () => {
|
(async () => {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user