Compare commits
3 Commits
cff014ede5
...
3de1d3deab
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3de1d3deab | ||
|
|
e9afbeb028 | ||
|
|
3e2b63f71f |
@@ -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);
|
||||||
|
|||||||
@@ -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';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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'],
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -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.settings,
|
|
||||||
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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,13 +14,11 @@ import { getEventAnalytics, EventAnalytics } from '../api';
|
|||||||
import { ApiError } from '../lib/apiError';
|
import { ApiError } from '../lib/apiError';
|
||||||
import { useAdminTheme } from './theme';
|
import { useAdminTheme } from './theme';
|
||||||
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;
|
||||||
@@ -106,7 +104,6 @@ export default function MobileEventAnalyticsPage() {
|
|||||||
return (
|
return (
|
||||||
<MobileShell
|
<MobileShell
|
||||||
title={t('analytics.title', 'Analytics')}
|
title={t('analytics.title', 'Analytics')}
|
||||||
subtitle={activeEvent?.name as string}
|
|
||||||
activeTab="home"
|
activeTab="home"
|
||||||
onBack={() => navigate(-1)}
|
onBack={() => navigate(-1)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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,7 +9,7 @@ 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';
|
||||||
@@ -18,6 +18,7 @@ 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);
|
||||||
@@ -131,6 +164,7 @@ export default function MobileEventFormPage() {
|
|||||||
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',
|
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',
|
||||||
@@ -153,6 +187,7 @@ export default function MobileEventFormPage() {
|
|||||||
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',
|
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
|
||||||
|
|||||||
@@ -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')}>
|
||||||
|
|||||||
@@ -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')}>
|
||||||
|
|||||||
@@ -152,7 +152,6 @@ export default function MobileEventRecapPage() {
|
|||||||
<MobileShell
|
<MobileShell
|
||||||
activeTab="home"
|
activeTab="home"
|
||||||
title={t('events.recap.title', 'Event Recap')}
|
title={t('events.recap.title', 'Event Recap')}
|
||||||
subtitle={resolveName(event.name)}
|
|
||||||
onBack={back}
|
onBack={back}
|
||||||
>
|
>
|
||||||
<YStack space="$4">
|
<YStack space="$4">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
|
|||||||
@@ -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, activeTab);
|
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,16 +70,6 @@ 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}`;
|
||||||
|
|
||||||
@@ -114,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} />
|
||||||
@@ -159,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'))}
|
||||||
@@ -221,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
|
||||||
@@ -290,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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)`;
|
||||||
@@ -33,7 +48,7 @@ export function MobileSheet({ open, title, onClose, children, footer, bottomOffs
|
|||||||
onClose();
|
onClose();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
snapPoints={[82]}
|
snapPoints={snapPoints}
|
||||||
snapPointsMode="percent"
|
snapPointsMode="percent"
|
||||||
dismissOnOverlayPress
|
dismissOnOverlayPress
|
||||||
dismissOnSnapToBottom
|
dismissOnSnapToBottom
|
||||||
@@ -48,8 +63,8 @@ export function MobileSheet({ open, title, onClose, children, footer, bottomOffs
|
|||||||
borderTopLeftRadius: 24,
|
borderTopLeftRadius: 24,
|
||||||
borderTopRightRadius: 24,
|
borderTopRightRadius: 24,
|
||||||
backgroundColor: surface,
|
backgroundColor: surface,
|
||||||
padding: '$4',
|
padding,
|
||||||
paddingBottom: '$7',
|
paddingBottom,
|
||||||
shadowColor: shadow,
|
shadowColor: shadow,
|
||||||
shadowOpacity: 0.12,
|
shadowOpacity: 0.12,
|
||||||
shadowRadius: 18,
|
shadowRadius: 18,
|
||||||
@@ -62,7 +77,7 @@ export function MobileSheet({ open, title, onClose, children, footer, bottomOffs
|
|||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
{...({ contentContainerStyle: { paddingBottom: 6 } } as any)}
|
{...({ 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}
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ export function prefetchMobileRoutes() {
|
|||||||
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');
|
||||||
|
|||||||
@@ -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> },
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ Route::prefix('v1')->name('api.v1.')->group(function () {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
Route::middleware('throttle:100,1')->group(function () {
|
Route::middleware('throttle:guest-api')->group(function () {
|
||||||
Route::get('/help', [HelpController::class, 'index'])->name('help.index');
|
Route::get('/help', [HelpController::class, 'index'])->name('help.index');
|
||||||
Route::get('/help/{slug}', [HelpController::class, 'show'])->name('help.show');
|
Route::get('/help/{slug}', [HelpController::class, 'show'])->name('help.show');
|
||||||
Route::get('/legal/{slug}', [LegalController::class, 'show'])->name('legal.show');
|
Route::get('/legal/{slug}', [LegalController::class, 'show'])->name('legal.show');
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ use App\Models\TenantPackage;
|
|||||||
use App\Services\EventJoinTokenService;
|
use App\Services\EventJoinTokenService;
|
||||||
use Illuminate\Http\UploadedFile;
|
use Illuminate\Http\UploadedFile;
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
use Tests\Feature\Tenant\TenantTestCase;
|
use Tests\Feature\Tenant\TenantTestCase;
|
||||||
|
|
||||||
@@ -77,6 +78,55 @@ class EventControllerTest extends TenantTestCase
|
|||||||
->assertJsonPath('error.code', 'event_limit_missing');
|
->assertJsonPath('error.code', 'event_limit_missing');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_superadmin_can_create_event_without_tenant_package(): void
|
||||||
|
{
|
||||||
|
$tenant = $this->tenant;
|
||||||
|
$eventType = EventType::factory()->create();
|
||||||
|
$package = Package::factory()->create([
|
||||||
|
'type' => 'endcustomer',
|
||||||
|
'slug' => 'pro',
|
||||||
|
'max_photos' => 100,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$superadmin = \App\Models\User::factory()->create([
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'role' => 'superadmin',
|
||||||
|
'password' => Hash::make('password'),
|
||||||
|
'email_verified_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$login = $this->postJson('/api/v1/tenant-auth/login', [
|
||||||
|
'login' => $superadmin->email,
|
||||||
|
'password' => 'password',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$login->assertOk();
|
||||||
|
$token = (string) $login->json('token');
|
||||||
|
|
||||||
|
$response = $this->withHeader('Authorization', 'Bearer '.$token)
|
||||||
|
->postJson('/api/v1/tenant/events', [
|
||||||
|
'name' => 'Owner Event',
|
||||||
|
'slug' => 'owner-event',
|
||||||
|
'event_date' => Carbon::now()->addDays(10)->toDateString(),
|
||||||
|
'event_type_id' => $eventType->id,
|
||||||
|
'package_id' => $package->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertStatus(201);
|
||||||
|
|
||||||
|
$event = Event::latest()->first();
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('events', [
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'slug' => 'owner-event',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('event_packages', [
|
||||||
|
'event_id' => $event->id,
|
||||||
|
'package_id' => $package->id,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
public function test_create_event_requires_waiver_for_endcustomer_package(): void
|
public function test_create_event_requires_waiver_for_endcustomer_package(): void
|
||||||
{
|
{
|
||||||
$tenant = $this->tenant;
|
$tenant = $this->tenant;
|
||||||
|
|||||||
Reference in New Issue
Block a user